Compare commits

...

261 Commits

Author SHA1 Message Date
Jiayuan Zhang
a6c80d65d3 fix(notifications): fetch system_notifications pref lazily
Settings is the only mounted reader of notificationPreferenceOptions,
so a fresh app start (or any session that never visits Settings) left
the cache empty and the muted preference silently fell back to default
"all". Switch the inbox:new handler to ensureQueryData so the value is
fetched on first use and cached for subsequent events.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:59:01 +08:00
Jiayuan Zhang
56b71d62ce feat(notifications): add system notifications toggle in settings
Add a per-user, per-workspace toggle to enable/disable native OS
notification banners. Reuses the existing notification-preferences
endpoint by introducing a `system_notifications` key alongside the
inbox event groups; the realtime handler reads the cached preference
and skips desktopAPI.showNotification when muted.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:50:58 +08:00
Jiayuan Zhang
6d59505575 fix(quick-create): remove duplicate keyboard shortcut on agent submit button (#2130)
The agent submit button rendered the shortcut hint twice — the i18n
string already contained '(⌘↵)' and the JSX appended another
formatShortcut() suffix. Drop the hardcoded shortcut from the
translations and rely on the platform-aware formatShortcut() in JSX.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 10:43:47 +02:00
Naiyuan Qing
58db751089 ci(lint): enable lint in CI + fix existing lint debt (#2129)
CI was running build + typecheck + test, but never lint. The i18n
guardrail (eslint-plugin-i18next on packages/views/**/*.tsx) was
configured but not enforced, so PRs kept landing user-facing English
strings (chat session delete, project resources, mermaid fallback,
invitations batch page).

Changes:

- .github/workflows/ci.yml: add `lint` to the turbo command
- packages/eslint-config/react.js: split React rules (JSX-only) from
  react-hooks rules (apply to .ts too) — hooks live in .ts modules
  like use-agent-presence.ts, and inline-disable comments need the
  rule registered to resolve
- Translate the 10 lint errors that surfaced:
  - editor/readonly-content.tsx mermaid render-error + rendering
  - issues/issue-detail.tsx Archive tooltip
  - invitations/invitations-page.tsx full page (new invite.batch.*)
- invitations-page.test.tsx wrap with I18nProvider so getByRole queries
  match translated button labels
- core/auth/utils.ts intentional control-char regex: add eslint-disable

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:40:21 +08:00
Naiyuan Qing
ba147708a6 fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968) (#2128)
* fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968)

Opening an issue from Inbox with thousands of timeline entries used to
hard-freeze the browser tab on a synchronous render of every comment +
activity. The whole pipeline was unbounded: the API returned every row,
TanStack Query cached the full array, and IssueDetail mounted N
CommentCards (each running a full react-markdown + lowlight pipeline)
in one frame.

This swaps the timeline endpoint to keyset cursor pagination and rewires
the frontend to useInfiniteQuery so a long issue costs the same as a
short one on first paint.

API:
- GET /issues/:id/timeline now accepts ?before / ?after / ?around (mutex)
  + ?limit (default 50, max 100); response wraps entries with next/prev
  cursors and has_more flags. Cursors are opaque base64 (created_at, id).
- ?around=<entry_id> anchors a window on the target so Inbox notifications
  pointing at an old comment never trigger the freeze.
- New composite indexes on (issue_id, created_at DESC, id DESC) replace
  the redundant single-column ones so keyset queries are index-only scans.
- /issues/:id/comments default branch now caps at 50 instead of returning
  every row unbounded; the unbounded ListComments / ListActivities sqlc
  queries are deleted.

Frontend:
- useIssueTimeline switches to useInfiniteQuery, exposes
  fetchOlder/fetchNewer/jumpToLatest + isAtLatest + newEntriesBelowCount.
- WS handlers respect the at-latest invariant: comment/activity:created
  prepends to pages[0] only when the user is reading the live tail;
  otherwise it just bumps a counter so the UI offers a "Jump to latest"
  affordance without yanking scroll.
- Optimistic mutations adapted to the InfiniteData shape via shared
  helpers (mapAllEntries / filterAllEntries / prependToLatestPage in
  core/issues/timeline-cache.ts) and use setQueriesData so all open
  windows of the same issue stay in sync.
- IssueDetail Activity section gets a TimelineSkeleton placeholder
  during the brief load window plus subtle text-link load-more buttons
  matching the existing Subscribe affordance (no Button chrome). Top
  uses a divider for boundary clarity; bottom shows
  "Jump to latest · N new" weighted slightly heavier when there's
  unread state.
- highlightCommentId now flows into the hook's around parameter so
  Inbox jumps fetch the surrounding 50 entries directly.

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

* chore(agent): default comment list to 50 + prompt hint about long issues

The CLI's "multica issue comment list" used to default to --limit 0
(meaning "fetch every comment"), which lets an agent on a long issue
fill its context window with thousands of rows. The default is now 50;
agents that need older history can pass --limit or --since explicitly.

The local-coding-agent prompt also gains a single-line note about this
in both the comment-triggered and on-assign flows so the agent knows to
scope its fetches when issue size is unknown. Autopilot run-only mode
is intentionally unchanged — it has no issue context to query.

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-05-06 16:27:06 +08:00
Naiyuan Qing
3447764b03 feat(i18n): full rollout — 21 namespaces translated (en + zh-Hans) (#1853)
* feat(i18n): rollout phase — translate 9 namespaces (WIP)

Phase 1 complete (基建 + login + Settings language switcher),
phase 2 partial (Wave 4 done, search done). Pending namespaces
documented inline; another developer can pick up from here.

Infrastructure
--------------
- server: add users.language column + extend PATCH /api/me
  (TestUpdateMeAcceptsLanguage / TestUpdateMePreservesLanguage)
- packages/core/i18n: types / pickLocale (intl-localematcher) /
  browser-cookie-adapter / createI18n (initAsync false +
  useSuspense false) / I18nProvider / LocaleAdapterProvider
- Split server-safe vs React entries:
    @multica/core/i18n        — for proxy/RSC/middleware (no React)
    @multica/core/i18n/react  — for client trees (createContext)
  (RSC vendored React lacks createContext; mixed import would crash
  proxy.ts at module load.)
- packages/views/i18n: useT hook + selector API augmentation
  (i18next v26 default; auto-propagates to apps via the side-effect
  import in use-t.ts).
- apps/web: proxy.ts (Next 16 renamed middleware) merges existing
  legacy/root redirects with x-multica-locale header forwarding;
  layout.tsx reads locale via headers() and pre-loads RSC resources.
- apps/desktop: webPreferences.additionalArguments injects
  systemLocale (no sendSync — avoids main-thread blocking IPC);
  renderer adapter reads via process.argv.
- ESLint: i18next/no-literal-string at file-scope for translated
  files via packages/views/eslint.config.mjs TRANSLATED_FILES.
- glossary.md (packages/views/locales/) freezes term policy:
  Issue / Workspace / Agent / Skill / Autopilot / Daemon / Runtime
  stay English; Inbox / Project / Comment / Member translate.

Translated namespaces (9 / 19)
------------------------------
- auth: login page (web wrapper含 desktop-handoff 文案) + Settings
  Appearance language switcher
- editor: 9 .tsx (bubble-menu / link-hover-card / readonly-content /
  title-editor / extensions: code-block / file-card / image-view /
  mention-suggestion) + 32 keys
- invite: 25 keys
- labels / members / my-issues: Wave 4 全部
- search: command palette 35 keys
- navigation: no user-facing strings (no-op)

Pending (10 / 19)
-----------------
issues (46 files / ~210 keys)
agents (29 files / ~155 keys; presence.ts + config.ts label maps
  允许进 i18n)
onboarding (22 files / ~150 keys)
settings rest / skills / modals / workspace / chat / inbox /
projects / autopilots / layout

Workflow for picking up
-----------------------
- Glossary: packages/views/locales/glossary.md (mandatory read)
- Reference impls: auth/login-page.tsx + editor/* (selector API +
  i18n-provider test wrapper pattern)
- Per namespace:
    1. create locales/{en,zh-Hans}/{ns}.json
    2. add to packages/views/i18n/resources-types.ts
    3. useT('{ns}') + t($ => $.foo) in components
    4. add files to TRANSLATED_FILES in eslint.config.mjs
    5. typecheck + test + lint must pass
- Subagents currently CANNOT write files (sandbox deny). Run as
  hybrid: subagent researches + outputs full JSON + tsx diff,
  controller writes.

Other
-----
- scripts/init-worktree-env.sh: default
  MULTICA_DEV_VERIFICATION_CODE=888888 in dev for deterministic
  login (gated by isProductionEnv).

Verified: pnpm typecheck (6 pkgs ok), pnpm test (232 pass),
make test (Go).

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

* docs(i18n): rewrite glossary aligned with docs zh voice

Switch translation policy to match the canonical CN voice already
established in apps/docs/content/docs/*.zh.mdx (20+ files). The new
rule splits product nouns into two classes:

- Typed entities (issue / project / skill / autopilot / task) — kept as
  lowercase English in CN text, visually marking them as system types.
- Concepts (workspace / agent / daemon / runtime / inbox) — fully
  translated (工作区 / 智能体 / 守护进程 / 运行时 / 收件箱).

Previous glossary kept Workspace / Agent / Daemon / Runtime as English
on "工程惯例" grounds, but docs zh and CN AI ecosystem (Coze / 腾讯元器
/ 百度) consistently translate these. App UI now matches docs voice so
users don't see split personality between the app and its own docs.

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

* fix(i18n): register 6 namespaces and retrofit zh strings to new glossary

Two fixes that were blocking the previously-translated namespaces from
actually rendering in CN:

1. RESOURCES gap — locales/index.ts only loaded common/auth/settings,
   but resources-types.ts declared 12 namespaces and 6 of them had real
   translation content. At runtime i18next would fall back to raw keys
   for editor / invite / labels / members / my-issues / search.
   Register all 9 currently-translated namespaces.

2. Retrofit zh strings to the docs-aligned glossary:
   - "Issue" → "issue" (lowercase entity)
   - "Workspace" → "工作区"
   - "Agent" → "智能体"
   - "Runtime" → "运行时"
   - "Skill" → "skill" (lowercase)
   - "项目" → "project" (lowercase)

Touched: editor.json (sub_issue + mention.group_issues), invite.json
(3 Workspace occurrences), members.json (agents_section / more_agents),
my-issues.json (8 retrofits across page/header/errors), search.json
(13 retrofits across groups/pages/commands/empty).

Verified: pnpm typecheck (6/6) + pnpm test (238/238) all green.

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

* feat(i18n): translate inbox namespace

First namespace through the sub-agent → main-agent integration pipeline.

JSON: en/inbox.json + zh-Hans/inbox.json — 60 keys across page / menu /
list / detail / types / labels / errors. Time-formatter labels are kept
compact in EN ("5m" / "3h" / "2d") and use full units in zh ("5 分钟" /
"5 小时" / "5 天") since raw "5 分" reads as "5 marks/points" in CN.

Component changes converted two module-level statics into hooks so the
strings can flow through i18next:

- inbox-list-item.tsx: `timeAgo` (pure fn) → `useTimeAgo` (hook
  returning a fn). The local copy is a duplicate of @multica/core/utils
  `timeAgo` that is only used by inbox-page; other consumers across
  chat/agents/skills/issues stay on the core util for now and will be
  translated when their namespaces land.

- inbox-detail-label.tsx: `typeLabels` (static const Record) →
  `useTypeLabels` (hook returning the same Record shape). Call sites
  keep the existing `typeLabels[type]` access pattern.

inbox-page.tsx now uses both hooks and `useT('inbox')` selector calls
for all hardcoded strings (~24 sites: header / dropdown menu / list
empty state / detail panel / mobile back / quick-create-failed flow /
all error toasts).

Wired up: resources-types.ts, locales/index.ts RESOURCES, ESLint
TRANSLATED_FILES (3 inbox tsx files now lint-protected).

Verified: pnpm typecheck (6/6) + pnpm --filter @multica/views test
(238/238) + ESLint clean on inbox/.

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

* feat(i18n): translate workspace namespace

Translates the three workspace shell views: create-workspace-form,
new-workspace-page, no-access-page. Also fixes the prior-art
no-unescaped-entities lint errors in no-access-page.tsx — the
apostrophes in "doesn't" / "don't" were JSX text literals that move
into JSON values after translation, so the lint rule no longer fires.

Tests wrapped: workspace/create-workspace-form.test.tsx,
workspace/no-access-page.test.tsx, modals/create-workspace.test.tsx
all now wrap render() with <I18nProvider locale="en"> so the en values
in workspace.json drive the rendered text and the existing assertions
continue to match.

Slug constants kept: WORKSPACE_SLUG_FORMAT_ERROR /
WORKSPACE_SLUG_CONFLICT_ERROR exports in workspace/slug.ts are still
imported by onboarding/steps/step-workspace.tsx (out of scope here).
The workspace shell now reads its strings from workspace.json directly.

Multica.ai brand prefix in the slug input affordance is wrapped with
an inline `// eslint-disable-next-line i18next/no-literal-string` per
glossary policy on brand names.

Renamed sign_in_other → sign_in_different to avoid colliding with
i18next's `_other` plural-suffix convention which the selector-API
typings treated as a plural form of `sign_in`.

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

* feat(i18n): translate projects namespace

Translates the projects list page, project detail page, project picker
dropdown, and project chip — all four user-facing surfaces under
packages/views/projects/components/.

New file: projects/components/labels.ts exposes three hooks that
replace the static `.label` field on PROJECT_STATUS_CONFIG /
PROJECT_PRIORITY_CONFIG and the previous module-level
`formatRelativeDate` helper. Core's `.label` stays untouched (it's
still consumed by search and the create-project modal, both
out-of-scope for this namespace) — those will flip when their
respective namespaces translate.

In zh, the "project" entity stays lowercase English per glossary
(`新建 project`, `还没有 project`, `从 project 移除`). Status / priority /
table column labels translate fully.

The cancelled / done / paused etc. status labels duplicate per-
namespace as `projects.status.*` rather than reading from a future
shared status namespace. This matches the auth/inbox/workspace
pattern of self-contained namespaces. If a generic "issue/project
status" pool emerges later, these can collapse.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on projects/ (1 pre-existing warning
about useEffect/sidebarRef dep, unrelated to i18n).

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

* feat(i18n): translate autopilots namespace

Six tsx files: autopilots-page (list + 6 templates), autopilot-detail-page
(properties / triggers / run history / delete), autopilot-dialog
(create + edit dialog), trigger-config (cron form), and the agent /
timezone pickers.

Hook conversions for module-level helpers that need t():
- summarizeTrigger / describeTrigger → useSummarizeTrigger /
  useDescribeTrigger (no external callers, removed the plain exports)
- formatRelativeDate → useFormatRelativeDate (per-component hook)
- formatCountdown → useFormatCountdown (per-component hook)
- TEMPLATES array now keyed by id; titles + summaries pull from
  templates/{id}/{title,summary} JSON. Prompts stay raw EN since
  they're injected directly into the agent task — translating them
  would translate the agent's instructions, not the user's UI.

Status / execution-mode / run-status enums render via t($ => $.status[k])
with k typed against the core type (no separate hook needed).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on autopilots/.

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

* feat(i18n): translate skills namespace

Seven tsx files: skills-page (list + filters + intro banner),
skill-detail-page (the giant — properties + file tree + sidebar +
conflict banner + delete dialog, ~963 lines), create-skill-dialog
(chooser + manual + URL forms), runtime-local-skill-import-panel
(local runtime browse + import), skill-columns, file-tree, file-viewer.

Notable patterns:
- `createSkillColumns` factory → `useSkillColumns` hook so column
  headers flow through useT. Column identity changes per render is
  fine — DataTable handles it.
- `validateNewFilePath` (pure helper) → `useValidateNewFilePath` hook
  so the 5 validation error messages can be translated.
- skill_files / used_by / description_with_agents use i18next plural
  keys (`_one` / `_other`) — the type system collapses these into a
  single PluralValue access, so call sites use
  `t($ => $.foo, { count })` and i18next picks the form.
- Per glossary, "skill" stays lowercase EN in zh ("新建 skill",
  "已删除 skill", "未找到该 skill").

Test wrapper: runtime-local-skill-import-panel.test.tsx now wraps
render() with <I18nProvider> so the assertion on /Import to Workspace/i
matches the EN translation.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on skills/.

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

* feat(i18n): translate chat namespace

Translates all 10 chat surfaces: FAB tooltip, input placeholders,
message list (replied-in / failed-after / tools group / show-details
/ tool result preview), session history (header + time-ago labels),
chat window (new-chat / restore / expand / minimize / agent + session
dropdowns / starter prompts / empty states), context-anchor button +
card tooltips, no-agent banner, offline / unstable banner, and the
task-status pill (queued / starting up / thinking / typing + tool
labels: running command / reading files / searching code / making
edits / searching web).

Hook conversions:
- formatTimeAgo (chat-session-history) → useFormatTimeAgo
- ElapsedCaption now takes a typed `variant` ("replied" | "failed")
  instead of a free-text `verb` so the i18n key is enumerable
- pickStage (task-status-pill) refactored: pure pickStageKeys returns
  StageKey + optional ToolKey; useResolveStage maps to localized labels

Translation policy notes:
- Starter prompts ("List my open tasks by priority", etc.) are user
  UI when displayed AND the user's input when clicked — translating
  them sends the agent the user's locale-native phrasing, which is
  the right UX for a CN user using a CN agent.
- buildAnchorMarkdown (chat-window) stays in English: it's an
  agent-bound markdown prefix injected into the outgoing message,
  not user-facing UI.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate modals namespace

Translates all 11 modal sources: registry (no UI text), backlog-agent-hint,
set-parent-issue, add-child-issue, delete-issue-confirm, feedback,
issue-picker, create-workspace, create-project, create-issue (manual),
quick-create-issue (agent panel).

Notable patterns:
- create-project re-uses useProjectStatusLabels / useProjectPriorityLabels
  hooks from views/projects/components/labels — same translation source
  as the projects list / detail, no duplication.
- create-issue.tsx: renamed `toast.custom((t) => ...)` callback param to
  `toastId` to avoid shadowing the closure-captured useT() `t` function.
- Test wrapper added to modals/create-issue.test.tsx so the two assertions
  on rendered modal text (success toast + Create another) match the EN
  bundle. modals/create-workspace.test.tsx was already wrapped (workspace
  ns commit).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate settings namespace (rest of tabs)

Builds on the appearance-tab + language switcher already shipped in
Phase 0. Translates the remaining 8 settings surfaces: settings-page
shell (left nav + tab keys), account / profile, notifications-tab
(5 group labels + descriptions), tokens-tab (create / list /
revoke / created dialog), workspace-tab (general fields + danger
zone + leave/delete confirmations), members-tab (invite + role
config + revoke / remove flows), repositories-tab, labs-tab,
delete-workspace-dialog.

Hook conversion: members-tab `roleConfig` static const → `useRoleLabels`
hook returning a Record<MemberRole, {label, description, icon}>. The
icon stays as a typed React component (Crown / Shield / User), so
rendering pattern is unchanged at call sites.

Test wrapper: settings/components/delete-workspace-dialog.test.tsx
now wraps render() with <I18nProvider> (custom render() helper)
because the test asserts on rendered button labels ("Delete workspace",
"Cancel", "Deleting...").

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate runtimes namespace (entry surfaces)

Translates the user-facing runtime list page surfaces:
runtimes-page (header / search / filters / chips / empty / no-matches /
bootstrapping), runtime-detail (topbar + delete dialog + delete toasts),
runtime-detail-page (not-found state), shared.tsx (4-state HealthBadge
labels).

Hook conversion: shared `healthLabel(health)` was a pure module-level
function. Added `useHealthLabel` hook for translated call sites; kept
`healthLabel` as an EN-only fallback for non-component callers (column
factory in runtime-columns).

Deferred:
- runtime-list / runtime-columns (data table column headers + cell
  bodies) — large surface, not in the page-load critical path.
- connect-remote-dialog / update-section / usage-section — secondary
  flows, English remains acceptable until a focused pass.
- charts/* — primarily numeric tooltips and axes; minimal user-visible
  text.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate layout namespace (sidebar nav, help, loader)

Translates the cross-cutting layout chrome:
- 9 sidebar nav labels (inbox / my issues / issues / projects /
  autopilots / agents / runtimes / skills / settings) — driven by
  labelKey instead of inline strings, resolved via useT at render.
- HelpLauncher dropdown (trigger aria + 3 items: Docs / Change log
  / Feedback)
- WorkspaceLoader (named + unnamed loading states)
- SortablePinItem unpin tooltip

Pattern shift in app-sidebar.tsx: nav arrays carry `labelKey: NavLabelKey`
(typed against the layout JSON) instead of `label: string`. The string
comparison checks (`item.label === "Inbox"`) became cleaner ID-based
checks (`item.key === "inbox"`).

Deferred: deeper sidebar surfaces — workspace switcher dropdown,
"New Issue" CTA, "Pinned" / "Workspace" / "Configure" group labels —
remain English. The 9 nav labels are the ones that read in every
session.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate onboarding namespace (welcome + step header)

Translates the user-first-impression surfaces of the onboarding flow:

- step-welcome.tsx (the wordmark, headline, lede paragraphs, all CTAs:
  Download Desktop / Continue on web / Start exploring / I've done
  this before, illustration caption)
- step-header.tsx ("Step N of M" counter + matching aria-label)
- onboarding-flow.tsx (skip-onboarding error toast)

Test wrapper added to onboarding/components/step-header.test.tsx —
custom render() helper wraps with <I18nProvider> so the "Step 2 of 5"
assertions match the EN bundle.

Deferred (acceptable English fallback for now): step-questionnaire,
step-workspace, step-runtime-connect, step-platform-fork, step-agent,
step-first-issue, cli-install-instructions, option-card, runtime
aside panels, starter-content-prompt, cloud-waitlist-expand. These
are deeper steps with significant copy that would benefit from a
focused dedicated pass — voice on each is more nuanced (questionnaire
options, runtime install instructions, agent template recommendations).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* test(i18n): add EN/zh-Hans key parity guard

Schema-level vitest that walks RESOURCES.en and RESOURCES["zh-Hans"]
namespace by namespace and asserts both bundles cover the same key
set. i18next plural rule is normalized before compare (`_one` /
`_other` collapse to a single logical key) so EN's plural pair
matches zh's `_other`-only form.

Catches retrofit drift where a new EN key lands without zh —
previously this would silently fall back to the English string in
production. Cheap to keep green: 39 tests across 21 namespaces in
under a second.

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

* feat(i18n): translate issues namespace

Translates the entire issues surface — list / board / detail / comments /
sub-issues / activity feed / batch toolbar / pickers / context menu /
backlog-agent hint dialog / labels panel.

Component coverage:
- issues-page (page header, empty state, move-failed toast)
- issues-header (scope tabs, filter dropdowns w/ status/priority/
  assignee/creator/project/label, display settings, sort, view toggle)
- issue-detail (page header, breadcrumb, properties / parent issue /
  details / token usage sections, sub-issues, activity timeline,
  formatActivity for status/priority/assignee/title/due-date changes,
  subscribe/subscriber popover)
- comment-card + comment-input + reply-input (delete dialog, edit/save,
  copy/edit/delete row, reply count, placeholders, expand/collapse)
- agent-live-card (is-working banner, tool count, stop / transcript)
- execution-log-section (section header, show/hide past runs, trigger
  text builder, status labels, cancel-task)
- batch-action-toolbar (selected count, delete dialog with plurals)
- backlog-agent-hint-dialog (full dialog content)
- labels-panel (intro, create form, list, delete dialog)
- pickers (status / priority / assignee / due-date / label / property
  search placeholder + no-results)
- issue-actions-menu-items (all dropdown / context menu items)
- use-issue-actions / use-issue-timeline (toast strings)

STATUS_CONFIG / PRIORITY_CONFIG label rendering routed through
$.status[enum] / $.priority[enum] at every call site; the core config
keeps its English fallback for non-i18n consumers but UI never reads
.label directly anymore.

Tests retrofitted: issues-page, issue-detail, and issue-actions-menu
RTL specs now wrap renders in <I18nProvider> with the EN bundle, so
their string assertions match the bundle (not hardcoded literals).

ESLint i18next allow-list extended to 24 issues files. Verified:
pnpm --filter @multica/views typecheck + test (277/277) all green.

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

* feat(i18n): translate agents namespace

Translates the agents listing + detail surface and the create/duplicate
flow. Covers the high-frequency surfaces; deeper sub-tab editors
(activity / instructions / skills / env / custom-args bodies, and the
hooks-buggy runtime/model/concurrency pickers) are deferred — they
have their own pre-existing react-hooks rule violations and benefit
from a focused dedicated pass.

Component coverage:
- agents-page (page header w/ tagline + new button, scope segment,
  search, sort dropdown, availability chips, archived toolbar, empty
  state, no-matches messaging w/ search interpolation, list-load
  error)
- agent-detail-page (back link, archived banner, archive dialog,
  not-found state, all 4 toast strings)
- agent-detail-inspector (avatar editor, name + description popover,
  description dialog, every PropRow label, validation message,
  presence badge label sourced from $.availability[enum])
- agent-overview-pane (tab labels, discard-unsaved-changes dialog)
- create-agent-dialog (title / description / labels / placeholders /
  duplicate-suffix / runtime filter buttons / runtime status copy)
- agent-row-actions (full dropdown items + cancel-tasks dialog with
  pluralized "N running + M queued" summary + archive dialog + 6 toasts)
- agent-columns (every header cell, You / Archived chips, runtime
  fallback labels, availability + workload labels via $.availability /
  $.workload, activity tooltip body w/ created_today / created_days_ago
  / runs / failed-percent interpolation)
- inspector/skill-attach (Attach trigger label + aria)

availabilityConfig and workloadConfig now keep colors only — the
display label lives in the bundle, sourced via $.availability[enum]
and $.workload[enum] at every call site. Same pattern as
STATUS_CONFIG/PRIORITY_CONFIG in the issues namespace.

ESLint i18next allow-list extended to 8 agents files.
Verified: pnpm --filter @multica/views typecheck + test (277/277)
all green.

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

* fix(i18n): clear 30 stray EN strings in translated files

Tail of literal strings missed in earlier passes — the ESLint i18next
allow-list flagged them but they slipped through review. Files touched:

- layout/app-sidebar.tsx (10 keys: Workspaces / Pending invitations /
  Create workspace / Join / Decline / Log out / New Issue + shortcut /
  Pinned / Workspace / Configure)
- runtimes/components/runtime-detail.tsx (Serving header + serving_count
  pluralization, no_agents copy, running/queued chips with count
  interpolation, Diagnostics header, CLI label, Delete runtime button,
  Technical details toggle, last seen interpolation)
- onboarding/steps/step-welcome.tsx (entire WelcomeIllustration mock —
  5 cards × actor names + body copy + 3 mention chips + 2 timestamps;
  zh translation reads naturally instead of leaving the demo English)
- settings/components/labs-tab.tsx (`Co-authored-by: ...` git trailer
  wrapped in {} so linter sees a JS string, not JSX text — magic
  identifier git relies on, must not translate)
- settings/components/members-tab.tsx (✓ glyph wrapped in {})
- modals/feedback.tsx (⌘↵ shortcut wrapped in {})

ServingAgentsCard now reads availability/workload labels from
`agents` namespace (cross-namespace useT) so the bundle-truth pattern
holds: presenceConfig keeps colours only, label text comes from the
shared bundle.

Verified: typecheck + 277/277 tests + lint (only the pre-existing
react-hooks rule-of-hooks errors remain, which task #6 addresses).

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

* fix(agents): rules-of-hooks + translate 4 model/runtime pickers

Three pre-existing react-hooks/rules-of-hooks violations + one missing
useMemo dep cleared, then the four pickers wired through useT.

Hook order fixes:
- concurrency-picker: useEffect now runs before the !canEdit early
  return. Stale-draft reset still works the same way.
- runtime-picker: useMemo for the filtered list moved above the
  !canEdit branch.
- model-dropdown: `models = data?.models ?? []` was minting a fresh
  array each render and tripping the deps lint of the downstream
  useMemo. Wrap in useMemo so the reference is stable.

Translation coverage:
- concurrency-picker: tooltip ("Concurrency · N max..."), range
  helper text, Save button.
- runtime-picker: trigger label fallback ("No runtime"), tooltip
  text composed from {{name}} + status, Mine/All filter buttons,
  empty-list copy, "owned by {{name}}" + status fragments in row
  tooltip, Cloud badge, online/offline aria.
- model-picker: trigger label, tooltip, "Managed by runtime"
  fallback, search placeholder, "Discovering models…", default
  badge, "No models available", "Use \"X\"" custom-id flow, Clear
  button + its title.
- model-dropdown: every label string including the "Select a runtime
  first" / "Default (provider)" / "Runtime offline — enter manually"
  trigger fallbacks, the supported=false explanation block, discovery
  failed badge, all popover items.

ESLint allow-list extended to 4 picker files. Verified: typecheck +
277/277 tests + lint (0 errors, only pre-existing react-hooks warnings
in unrelated files).

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

* feat(i18n): translate runtimes list + connect dialog + CLI updater

Three deep runtime surfaces wired through useT, with the agents
namespace doing double duty for shared availability/workload labels.

runtime-columns:
- 7 column headers via t-augmented createRuntimeColumns({ t }).
- HealthCell now reads from useHealthLabel() (already translation-aware)
  instead of the EN-only healthLabel() helper.
- WorkloadCell sources the label from $.workload[enum] (cross-namespace
  to agents) — colour stays via workloadConfig.
- CostCell delta "flat" copy + CLI cell "Desktop" badge + update-
  available aria/tooltip + RowMenu's full delete dialog (title /
  description with {{name}} interpolation / cancel / confirm /
  deleting state) plus its admin-permission hint.

connect-remote-dialog:
- Three steps fully translated: instructions (header + 4 numbered
  steps + security warning + troubleshooting list with mono code
  snippets escaped as JS strings), waiting (loader + hint), success
  (CTA pair).
- Mono CLI commands wrapped in {} so linter sees JS strings — those
  are literal commands that must stay untranslated for the user to
  paste into a terminal.

update-section:
- statusConfig collapsed to icon+colour only; labels move to
  $.update.status[enum] for proper translation per-state.
- "CLI Version:" / "Latest" / "available" / "Update" / "Retry"
  copy + the "Managed by Desktop" tooltip and disabled hint.

Layout helpers tagged: runtime-list passes `t` through to the column
factory the same way agent-columns does.

ESLint allow-list extended with the 4 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. usage-section.tsx
(KPI cards / WhenChart / TopUsageBreakdown / receipt table) is the
remaining runtimes surface — chart-heavy and benefits from a focused
pass next.

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

* feat(i18n): translate 5 agent detail tabs + skill-add dialog

The 5 tabs that fill the agent detail right pane plus the shared
skill picker dialog. Agents bundle gains a `tab_body` block with
sub-namespaces per tab + a `common` slot for save/add/unsaved.

Tab coverage:
- instructions-tab: intro paragraph, multi-line example placeholder
  (full 18-line zh translation), Save / Unsaved.
- env-tab: read-only intro / empty state, editable intro with two
  inline `<code>` env-var examples kept English (mono terminal
  payloads), KEY / value placeholders, Show/Hide value aria, Add /
  Remove aria, all 3 toasts (duplicate keys / saved / save failed).
- custom-args-tab: intro about whitespace splitting, launch-mode
  prefix line + `<your args>` placeholder, --flag value placeholder,
  Add, Remove aria, both toasts.
- skills-tab: intro, Add skill button, import-hint callout, empty
  state title + hint + add-CTA, remove-failed toast.
- activity-tab: 3 section titles (Now / Last 30 days / Recent work),
  active-task pluralization, performance subtitle, all 3 empty
  states, runs/success%/avg-duration/failed pluralization with
  interpolation, source labels (Issue / Chat / Autopilot / Untracked),
  source fallbacks (Quick create / Creating issue / Chat session /
  Autopilot run), issue-short fallback, "Triggered by" tooltip
  header, open-issue / transcript / cancel-task tooltips and ARIAs,
  cancelling state, started/dispatched/queued time prefixes, show
  more.
- skill-add-dialog: dialog title + description, empty list copy,
  Cancel button, add-failed toast.

skills-tab.test.tsx wrapped in <I18nProvider> with the EN bundle so
its `Local runtime skills are always available` assertion still
matches the resolved translation instead of the raw key path.

ESLint allow-list extended with the 6 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. Only the per-test
mock for skills-tab needed wrapping; the other 4 tabs ship without
test files of their own and inherit the I18nProvider chain via
agent-overview-pane / agent-detail-page test renders (when those
exist later).

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

* feat(i18n): translate onboarding step-questionnaire + option-card

The user-profile step (3 questions) is the first deferred onboarding
deep step now wired through useT.

step-questionnaire:
- Eyebrow + headline + answered-progress counter with {{count}}
  interpolation
- All 3 questions and their option labels (team size / role / use case)
- All 3 "Other" placeholders for free-text fallback
- Right-rail "Why three questions" / "What you get" panel: 2 eyebrow
  rows, 2 unlock-item title+body pairs, learn-more link
- Back / Continue buttons via shared `common` block

option-card: shared "Other" radio label and aria.

Test wrapped in <I18nProvider>. EN value of `other_label` kept as
"Other" so the existing /^other$/i regex in step-questionnaire.test
keeps matching after the rendering pipeline switched from a hardcoded
literal to a bundle lookup.

ESLint allow-list extended with these 2 files. The remaining 4 deep
steps (workspace / runtime-connect / platform-fork / agent), the
2 ancillary surfaces (cli-install-instructions / starter-content-
prompt), and the 3 side panels (runtime-aside-panel / cloud-waitlist-
expand / compact-runtime-row) will be surfaced + swept by the global
ESLint switch (next commit).

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

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

* feat(i18n): flip ESLint to glob + drain remaining hardcoded EN

ESLint i18next/no-literal-string now applies to **/*.tsx by default
instead of an explicit allow-list. Files that genuinely still need
hardcoded EN are listed in STILL_HARDCODED — concrete, finite, and
the goal is to drain that list to zero.

Tail strings translated in this commit (surfaced by the global flip):

- common/task-transcript/agent-transcript-dialog.tsx — full dialog:
  status badge (Running / Completed / Failed), sr-only DialogTitle,
  Filter dropdown trigger + Clear filters, Copy all / Copy filtered /
  Copied, tool-calls + events metadata chips with pluralization,
  events-filtered "{{shown}} of {{total}}" interpolation, "Waiting
  for events..." live state, "No execution data recorded." past
  state. New `transcript` block in agents namespace.
- runtimes/components/charts/activity-heatmap.tsx — Less / More
  legend labels around the contribution-style heat squares.
- search/search-trigger.tsx — sidebar Search... button label.
  ⌘ glyph wrapped in {} to satisfy the linter (mono shortcut symbol,
  not translatable).

Holdouts (STILL_HARDCODED, ~14 files): the deep onboarding steps
(workspace / runtime-connect / platform-fork / agent / first-issue /
cli-install-instructions, plus 4 ancillary panels), the runtimes
usage-section + KPI cards, and 5 minor agent visual primitives
(sparkline / agent-presence-indicator / agent-profile-card /
visibility-badge / char-counter). Each one gets a dedicated future
pass; the global rule prevents new hardcoded strings from landing
elsewhere.

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

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

* feat(i18n): drain agent visual primitives + onboarding small components

8 files removed from STILL_HARDCODED:

agents/components/:
- char-counter — over-limit text with {{count}} interpolation
- visibility-badge — uses new agents.visibility.{private,workspace}.
  {label,tooltip} block; drops VISIBILITY_LABEL/TOOLTIP imports from
  core in favour of bundle-driven copy
- agent-presence-indicator — availability + workload labels via
  $.availability[enum] / $.workload[enum] (cross-namespace),
  queue-badge "+N queued" with pluralization
- agent-profile-card — Agent unavailable / Detail link / Owner /
  Skills / Runtime / Unknown runtime / Archived chip / availability
  line via cross-namespace lookup

agents.json: new presence + visibility + profile_card + char_counter
blocks.

onboarding/components/:
- compact-runtime-row — online/offline aria via agents.availability
- runtime-aside-panel — full content (What's a runtime / Good to
  know / Swap anytime / Add more later / docs link)
- starter-content-prompt — full dialog (title / description with
  inline emphasis / both buttons / 3 toasts)
- cloud-waitlist-expand — intro paragraph + warning span / email
  + reason labels + placeholders + Optional badge / Join + on-list
  states / both toasts

onboarding/steps/:
- cli-install-instructions — copy aria + intro + 2 step labels

onboarding.json: new runtime_aside / cli_install / starter_content /
cloud_waitlist blocks.

Tests for step-platform-fork + step-runtime-connect wrapped in
<I18nProvider> with EN bundle so /you're on the list/i etc. still
matches the resolved translations.

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

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

* feat(i18n): translate onboarding deep steps

The 5 large onboarding steps that were deferred from earlier passes,
plus their support helpers, all wired through useT.

step-first-issue (final beat — flips onboarded_at):
- error_title / Retry / retry_failed toast / finishing / opening
  states.

step-agent (creates the user's first agent):
- Templates moved from a module-level const to a useT-driven
  useAgentTemplates() hook. Names + emoji stay constant (visual
  identity), labels + blurbs + instructions resolve from the
  bundle. coding / planning / writing / assistant — all four
  templates ship a full zh translation that reads naturally.
- Recommended badge, eyebrow + headline + lede, footer hint,
  Create {{name}} CTA, create_failed toast.
- Right-rail "About agents" panel (4 way-items + headline +
  add-more hint + docs link).

step-workspace (create or pick existing):
- 5 footer states (open / creating / creating-pending / name-first
  / pick), all hint + CTA strings via interpolation.
- Name + URL + slug placeholders, issue-prefix preview spans,
  Create-new card title + subtitle.
- 8-row WorkspacePreviewCard sidebar (Inbox / Issues / Agents /
  Projects / Autopilot / Runtimes / Skills / And more) — every
  label + meta strapped to bundle keys.
- 4 perks (assign / chat / invite / switch) + 3 next-steps
  (runtime / agent / starter), 2 toasts (slug-conflict / failed).
- `multica.ai/${slug}` mono URL escaped via template-literal
  expression so the linter sees a JS string.

step-runtime-connect (desktop scan flow):
- 3 phase headlines + ledes (scanning / found / empty), trust-strip
  status (all online / N online / none online) with pluralization,
  online/offline labels, Skip / Continue / Selected hint.
- Empty-view 2 cards (skip + waitlist) and the cloud waitlist
  dialog wrapper.

step-platform-fork (web fan-out):
- Eyebrow + headline + lede, footer hint with 3 phase variants.
- Primary download card (before/after click) + 2 alt cards (CLI /
  cloud) + CLI dialog with 4 elapsed-time stages (normal / midway /
  slow / stalled), live-listening header, runtime-connected
  pluralization, cloud waitlist dialog.

ESLint: STILL_HARDCODED list shrunk from 14 entries to 1 — only
runtimes/components/usage-section.tsx (chart-heavy KPI panel)
remains.

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

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

* feat(i18n): translate runtimes usage panel + drop STILL_HARDCODED

Final i18n holdout: the runtimes usage panel (KPI hero, WHEN chart
tabs, cost-by breakdowns, daily breakdown table) is wired through
useT("runtimes"). With this drained, the eslint scaffolding for
explicit holdouts is removed — every JSX text node in @multica/views
now flows through i18n.

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

* fix(i18n): drain rollout gaps + add cross-device sync

Lands the post-review punch list for the i18n rollout: closes correctness
gaps that would have shipped silently, and adds the missing cross-device
locale sync the rollout's docs already promised.

Coverage:
- Register issues + agents namespaces in RESOURCES (90 useT call sites
  were rendering keys-as-text in production)
- Harden parity test to compare RESOURCES keys against on-disk JSON
  files, so a future missing namespace registration fails loudly
- Server-side language whitelist in UpdateMe + reject-unsupported test
- Safe SupportedLocale resolution in appearance-tab (no more `as` cast
  on a region-tagged BCP-47 string)
- HTML lang attribute uses zh-CN (not zh-Hans) for screen reader / CJK
  font-stack compatibility
- Cookie Secure flag on https
- Pulled createBrowserCookieLocaleAdapter out of the server-safe entry
  into a new @multica/core/i18n/browser subpath; document.cookie access
  can no longer leak into Edge middleware imports

Cross-device sync:
- New UserLocaleSync component mounted in CoreProvider; on login, if
  user.language differs from the active i18n.language, persist via the
  adapter and reload. Both apps benefit
- Desktop main process tracks system locale and emits IPC on focus when
  it changes; renderer reloads only when the user has no explicit
  Settings choice (their preference still wins)

Tests:
- pickLocale / matchLocale (11 cases incl. region-tagged BCP-47, malformed
  tags, zh-Hant collapse-to-zh-Hans semantics)
- browser-cookie-adapter (6 cases under jsdom)
- Shared renderWithI18n helper at packages/views/test/i18n.tsx that wraps
  the real RESOURCES map; future tests opt in instead of inlining a
  per-file TEST_RESOURCES slice that goes stale silently

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

* docs(conventions): consolidate naming + i18n glossary into docs site

Single source of truth for code naming, i18n translation glossary, and
Chinese voice rules. Previously split between packages/views/locales/glossary.md
and scattered comments — now lives at apps/docs/content/docs/developers/conventions.{mdx,zh.mdx}
with both English and Chinese versions kept in sync.

Three sections per page:
1. Code naming — routes, packages, files, DB, Go, TS, commits
2. i18n translation glossary — entity vs concept rule, what to translate,
   word combination, plurals, interpolation, key naming
3. Chinese voice + style — punctuation, principles, where to look in doubt

Side effects:
- packages/views/locales/glossary.md collapses to a stub redirecting to
  the docs page; do not edit it
- CLAUDE.md gets a new top-level "Conventions reference" section so any
  Claude session sees the pointer before any other rule
- apps/docs/content/docs/developers/ gets a stub English meta.json so the
  conventions page is reachable on the EN side (contributing.zh.mdx /
  architecture.zh.mdx remain ZH-only — separate work)
- Both root sidebars get a new "Developers" group

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

* fix(i18n): apply zh voice rules + translate project/autopilot

Two-part cleanup driven by the conventions doc landed last commit:

Voice violations (mechanical sweep across 10 zh-Hans namespaces):
- 「」 (Japanese-style brackets) → \" to match the EN source's straight
  double quotes (~13 sites)
- … (single-char ellipsis) → ... three dots (~43 sites)
- Drop translation-ese pronoun "我们" where it's a pure narrator
  ("我们已发送" → "已发送", "我们替你托管" → "由 Multica 托管"); keep
  "告诉我们" where "we" is the legitimate brand recipient
- "作为父级 / 作为子级" → "设为父级 / 设为子级"
- "任务" mistranslated as the task entity → `task` (lowercase EN entity)
- Dialog title "Autopilot" → "autopilot"

Translate project / autopilot per industry consensus:
- `project` → 「项目」 (~42 value sites). Feishu / Tower / Teambition /
  PingCode / GitHub Projects all translate; no Chinese product keeps
  `project`.
- `autopilot` → 「自动化」 (~34 value sites). Avoids the Tesla-style
  「自动驾驶」 association; matches Notion / Feishu's industry term.
- Issue / skill / task remain lowercase EN per dev-team familiarity.
- Sidebar nav-label entities get Title Case ("Issue" / "Skill" / "我的
  Issue") so the entry-point label reads as a proper UI signal; body
  prose stays lowercase.

Conventions doc (EN + ZH) reflects the decision and adds a "why these
translate but issue/skill/task don't" rationale block.

Verification: parity test 45/45, full monorepo typecheck green.

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

* feat(i18n): translate chat session delete + project resources section

Two features main shipped while this branch was idle never went through
the i18n pass:

- Chat session delete confirmation dialog (#2115) and history toggle
  tooltip (#2117): adds session_history.delete_dialog.* and
  session_history.row_delete_*, plus window.history_show_tooltip /
  history_back_tooltip.
- Project resources sidebar (#1926/#2080/#2111): entire component
  including toasts, popover form, attach/remove tooltips. New
  projects.resources subtree.

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-05-06 16:16:12 +08:00
Bohan Jiang
ae985ae2a3 fix(daemon): tighten 404 task-not-found semantics — server + final guard (#2127)
* fix(server): return 500 for transient DB errors in daemon task lookup

requireDaemonTaskAccess used to turn any GetAgentTask error into
404 "task not found", including transient DB connection / pool errors.
Combined with PR #2107 — which added 404+"task not found" as a daemon
cancellation trigger — that means a single DB hiccup could kill an
in-flight agent run.

Distinguish pgx.ErrNoRows (real "task gone", 404) from other errors
(transient, 500 + warn log) using the existing isNotFound helper.

Tests cover both paths via the mockDB pattern already used by
TestFindOrCreateUserGating.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): honor task-deleted signal in post-runTask completion guard

The final pre-completion check in handleTask only looked for
status == "cancelled" and ignored errors. After PR #2107 added a 404
task-deleted cancellation path to the in-flight watcher, this trailing
guard fell out of sync — if the task was deleted between the watcher's
last poll and runTask returning, handleTask would still try to call
CompleteTask and only learn about the deletion via the 404 from that
callback.

Reuse shouldInterruptAgent so the same truth table (cancelled OR
404 task-not-found, but NOT transient errors) drives both polling and
the final guard.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:02:59 +08:00
DimaS
b1be9ed27f fix(daemon): cancel running agent when task is deleted server-side (#2107)
When the server deletes a task while the daemon's agent is still running
(issue removed, agent reassigned, workspace cleanup), GetTaskStatus
starts returning 404 "task not found". The previous polling loop only
checked for status == "cancelled" and silently swallowed the error, so
the local agent kept emitting tool calls against a dead task until its
own timeout fired — minutes of wasted model spend and patch_apply
operations against a workdir nobody would consume.

Changes:

- Add isTaskNotFoundError next to isWorkspaceNotFoundError so the daemon
  can distinguish "task gone" 404 from "workspace gone" 404 (already
  handled separately) and from generic network errors.
- Extract the cancellation polling goroutine in handleTask into
  watchTaskCancellation, plus a pure shouldInterruptAgent decision
  helper. The pure helper makes both signals (cancelled status and 404
  task) easy to unit-test without spinning up a real backend.
- Trigger interruption on the new 404 path. Transient errors (5xx,
  network) intentionally still don't cancel — the next poll will retry
  and a flaky link should not kill an in-flight agent.

Tests cover the helper truth table, the existing "status cancelled"
path, the new "task deleted (404)" path, and a negative case ensuring a
running task is not interrupted.

Co-authored-by: “646826” <“646826@gmail.com”>
2026-05-06 15:45:03 +08:00
Bohan Jiang
144661e68f fix(daemon/execenv): refresh stale Codex auth.json across env reuse (#2126)
`ensureSymlink` previously short-circuited whenever `dst` already existed
as a regular file ("Regular file exists — don't overwrite"). On Windows
that branch is reachable via the createFileLink copy fallback that fires
when `os.Symlink` is unavailable, so once a per-task `codex-home/auth.json`
was written as a copy it would never be refreshed by subsequent
Prepare/Reuse calls. If the shared `~/.codex/auth.json` rotated (e.g.
Codex Desktop refreshed the token in the background), the daemon kept
handing Codex a now-revoked refresh_token, which the OAuth server
rejected with `refresh_token_reused` / `token_expired`. Renaming the
workspace directory was the only recovery path.

Treat any non-matching dst — wrong-target symlink, broken symlink, or
stale regular file — as something to delete and re-create via
createFileLink, so each Prepare/Reuse mirrors the current shared source.
Add a `logCodexAuthState` info log (file kind, link target, size, mtime —
never contents) so operators chasing the same symptom can see at a glance
whether the per-task home is tracking the shared auth or has drifted.

Tests cover: stale regular-file dst is replaced, copy-fallback dst is
refreshed when the shared source rotates, and a high-level
prepareCodexHome regression simulating the Windows + token-rotation
scenario from issue #2081.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 15:18:04 +08:00
Matt Van Horn
0dbfbfed2e fix(daemon/execenv): refuse to write .gc_meta.json when issue_id is empty (#2077)
A non-trivial fraction of completed task workdirs (~28% in field reports)
end up with .gc_meta.json files containing issue_id: "". Empty issue_id
defeats the daemon's own GC loop (gc.go:139 calls
GetIssueGCCheck(meta.IssueID)) and external retention scripts that
cross-reference issue status before deleting orphaned workdirs.

Refuse to write the file when issueID is empty, logging a Warn so
operators have a starting point for debugging the upstream race
condition. Skip is preferred over a sentinel-marker file: it keeps the
data invariant clean (a .gc_meta.json file always carries a valid
issue_id) and matches the repo CLAUDE.md preference for not preserving
dual-state behavior.

WriteGCMeta now takes a *slog.Logger so it can emit the warning. The
package already uses log/slog (Prepare/reuseEnv), and daemon.go:884 has
taskLog in scope at the only call site.

Closes #1913

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-05-06 15:02:16 +08:00
furtherref
1b3c78e4b5 fix(pins): unpin missing sidebar rows (#2062)
* fix(pins): unpin missing sidebar rows

* fix(pins): guard missing pin auto-unpin
2026-05-06 14:43:47 +08:00
Bohan Jiang
09f04847d3 feat(server): redis-backed runtime liveness with DB fallback (#2121) 2026-05-06 14:31:33 +08:00
prellr
ee10c508fb fix(daemon): trust the agent's session id from session/resume across ACP backends (#2070)
When the local state.db of an ACP backend (hermes, kimi, kiro) is wiped
— crash, config change, manual kill, container reset — the backend's
session/resume (or session/load, in kiro's case) silently creates a
brand-new session rather than failing, and returns the new id in the
response. Today the daemon ignores the response and stamps
sessionID = opts.ResumeSessionID across all three backends, so every
subsequent session/prompt is addressed to a session id the backend has
no record of. The task fails with JSON-RPC -32603 (Internal error) on
the very first turn, with no operator-visible signal that the problem
is a session-id mismatch one layer down.

The behavior is invisible: agent shows "started", then "failed" with a
generic Internal error. Reproducing in production took repeated runs
because nothing in the logs pointed at the silent reset.

Fix: route all three ACP backends through a small `resolveResumedSessionID`
helper that:

- prefers the id the backend returned in its response (the canonical
  id; the one the backend will accept on the next call)
- falls back to the requested id when the response is malformed,
  empty, or omits sessionId — defensive fallback so older / non-
  conforming backends (notably kiro's current session/load shape)
  behave identically to today
- signals (via a bool) when the id changed, so the caller logs a Warn
  with `backend=<hermes|kimi|kiro>` and operators can grep for silent
  state resets to correlate them with task failures

Why this is at the backend layer rather than the daemon's existing
session-resume fallback: server/internal/daemon/daemon.go:1554-1566
already retries with a fresh session when resume fails, but it gates
on `result.Status == "failed" && result.SessionID == ""`. The backend
WILL hand back a result.SessionID — just the new one it silently
committed to — so the daemon-level fallback never fires for this
failure mode.

The helper is also what session/new already uses (extractACPSessionID,
documented in code as "Shared by all ACP backends"). session/new
extracts the canonical id from the response; session/resume just
didn't, until now.

Coverage:
- hermes.go: confirmed bug, root cause of -32603 in production
- kimi.go: same code shape, same protocol method, same response
  schema as hermes (per extractACPSessionID's comment) — same bug
- kiro.go: same code shape, different method (session/load). Current
  observed response doesn't include sessionId, so the defensive
  fallback means today's behavior is preserved. Routing through the
  same helper means a future kiro release that DOES return a sessionId
  on silent reset works the same way as hermes/kimi without another
  diff.

Tests (server/pkg/agent/hermes_test.go — helper covers all three
backends, no per-backend duplication):
- TestResolveResumedSessionIDMatching — backend confirms requested id
- TestResolveResumedSessionIDDifferent — backend returned a new id;
  caller is told to switch
- TestResolveResumedSessionIDEmptyResponse — older / malformed body;
  defensive fallback to requested id (covers kiro's current shape)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:15:40 +08:00
Naiyuan Qing
140678c4b3 fix(web): redesign 404 + break NoAccessPage redirect loop (#2122)
* refactor(web): rewrite 404 page using design tokens

Replace editorial-style 404 (hardcoded cream/ink/terracotta colors,
Instrument Serif font, fluid clamp() typography) with a minimal version
using semantic tokens and the project's buttonVariants helper.

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

* fix(workspace): break NoAccessPage redirect loop by clearing stale cookie

The web proxy redirects / to /<lastSlug>/issues based on the
last_workspace_slug cookie alone, with no access check. When a user
gets evicted from a workspace, the cookie still points at it; clicking
"Go to my workspaces" then loops: NoAccessPage -> / -> proxy ->
same bad slug -> NoAccessPage.

Clear the cookie on mount so the proxy falls through to the landing
page, which resolves the correct destination via the workspace list.

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

* fix(web): mark not-found as client to allow buttonVariants import

buttonVariants is exported from a "use client" module, so calling it
from a server component is rejected by Next 16's directive checks.
Production build of /workspaces/new prerender failed because of this.

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-05-06 14:15:13 +08:00
Bohan Jiang
b08594f2f6 fix(daemon): isolate runtime poll & heartbeat schedules per runtime (#2116)
* fix(daemon): isolate runtime poll & heartbeat schedules per runtime

A daemon serving multiple workspaces ran a single round-robin poll loop
and a single HTTP heartbeat loop across every registered runtime. A 30s
HTTP timeout for any one runtime serialized that delay across all the
others — observed in production as one workspace's runtimes wedging
every other workspace's runtimes on the same daemon.

This change:

- Replaces the shared runtime-set channel with a multi-subscriber
  watcher so taskWakeupLoop, heartbeatLoop, and pollLoop can each
  react to runtime-set changes independently.
- Splits heartbeatLoop and pollLoop into supervisor + per-runtime
  worker goroutines. Each runtime owns its claim cadence and its
  heartbeat ticker, so a slow request on one runtime no longer blocks
  any other.
- Stagers the per-runtime heartbeat first tick by a jittered delay up
  to one full interval to avoid a thundering herd at startup.
- Sizes the WS writer channel to scale with the runtime count
  (max(16, 2*N)) so a full per-runtime heartbeat batch always fits;
  the previous fixed 8-slot buffer dropped heartbeats whenever a
  daemon watched more than ~8 runtimes.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): acquire execution slot only after ClaimTask, drain pollers before taskWG

Two issues from review on the previous commit:

1. Acquiring the shared task slot before ClaimTask reintroduced the very
   head-of-line blocking the refactor was meant to remove. With
   MaxConcurrentTasks=1, a slow claim on one runtime parked the only slot
   for the duration of the HTTP timeout (up to 30s), starving every other
   runtime's claim attempts. Slots are now acquired after the claim
   returns a task; other runtimes' pollers stay free to claim. The
   already-dispatched task waits for a slot under MaxConcurrentTasks
   bounds, which is the same backpressure shape we had before.

2. pollLoop's shutdown path called taskWG.Wait immediately after
   cancelling pollers, but a poller could still be between ClaimTask
   returning a task and taskWG.Add(1). When taskWG's counter is zero
   that races with Wait — undefined sync.WaitGroup misuse, sometimes
   panic. Added a pollerWG so the supervisor blocks until every poller
   goroutine has actually returned before reaching taskWG.Wait.

Tests:
- TestRunRuntimePollerIsolatesSlowRuntime now uses MaxConcurrentTasks=1
  (was 4) so it would have failed under the old slot-before-claim path.
- New TestPollLoopShutdownWaitsForPollersBeforeTaskWG drives the exact
  race window — claim returns a task at the same moment shutdown fires —
  under -race.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): acquire slot before ClaimTask so capacity-waiters never enter dispatched

The previous commit moved slot acquisition AFTER ClaimTask to address a
review concern about head-of-line blocking with MaxConcurrentTasks=1.
That introduced a strictly worse failure mode: server-side ClaimTask
flips the task to `dispatched` immediately (agent.sql:174-176), and the
runtime sweeper fails any task in `dispatched` for >300s with
`failed/timeout` (runtime_sweeper.go:25-28). When local execution
capacity is full and the next claimed task can't acquire a slot within
5 minutes, the user sees the exact failure this issue is fixing —
`dispatched_at` set, `started_at` NULL, `failure_reason=timeout`.

Reverted to slot-before-claim. The trade-off is the original review
concern: with MaxConcurrentTasks=1 and a slow ClaimTask, other
runtimes' claims are delayed by up to client.Timeout=30s. That's a
30s polling delay, not a failure — server-side those tasks remain
`queued` (no timeout in that state) until a slot frees. 30s ≪ 300s,
so other runtimes' tasks cannot get sweeper-failed because of this.

The pollerWG fix from the previous commit (avoiding sync.WaitGroup
misuse on shutdown) is preserved.

Tests:
- TestRunRuntimePollerIsolatesSlowRuntime: MaxConcurrentTasks back to
  4 (the pre-issue baseline) — the headroom case where slot-before-
  claim still gives full per-runtime isolation.
- New TestRunRuntimePollerSkipsClaimWhenAtCapacity: holds the only
  slot and verifies the poller never calls ClaimTask while sem is
  empty. The previous "claim first" path would have failed this.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 14:13:27 +08:00
Bohan Jiang
a4fac51cf5 fix(projects): add resource_count breadcrumb instead of inlining resources (#2118)
* fix(projects): add resource_count breadcrumb to project responses

Closes #2087

`multica project get` previously returned project metadata with no signal
that resources existed. Agents that fetched a project this way had no way
to discover its attached resources without already knowing about
`/api/projects/{id}/resources` or the on-disk `.multica/project/resources.json`.

Rather than inline the full resource list into the parent payload (which
conflates parent metadata with a child sub-collection and locks the
resource_ref shape into the project endpoint's contract), this adds a
scalar `resource_count` breadcrumb to ProjectResponse. The actual list
stays at the dedicated sub-collection endpoint.

Changes:
- GetProjectResourceCounts :many — new batched sqlc query
- ProjectResponse.ResourceCount populated in GetProject, ListProjects,
  SearchProjects, and the with-resources CreateProject echo
- multica project get prints a stderr hint pointing at
  multica project resource list <id> when count > 0; the JSON on stdout
  stays parseable
- Meta-skill (runtime_config.go) lists multica project get and
  multica project resource list in Available Commands so agents that
  read CLAUDE.md / AGENTS.md know about both paths

Co-authored-by: multica-agent <github@multica.ai>

* fix(projects): wire ResourceCount through Update + Create event payload

Review feedback on #2118.

- UpdateProject now reloads ResourceCount before responding/publishing.
  Previously a title- or status-only PUT served (and broadcast over WS)
  resource_count: 0 even when resources existed.
- The with-resources CreateProject path sets resp.ResourceCount before
  the project:created publish, so the WS event payload matches the HTTP
  echo. The hand-rolled response map collapses to an embedded
  ProjectResponse + resources array — one source of truth for the
  serialized shape.
- packages/core/types/project.ts: Project gains resource_count: number
  to keep the TS contract aligned with the server response.

Tests:
- TestProjectResourceCountBreadcrumb extends to assert UpdateProject
  preserves the breadcrumb.
- TestCreateProjectWithResourcesEchoesCount asserts the create echo
  carries resource_count matching the attached resources.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 14:09:35 +08:00
Multica Eve
2b967338a8 fix(runtimes): narrow CostCell usage window from 180d to 14d (#2119)
The runtimes list page renders a CostCell per row that only displays a
7d cost total plus a 7d-vs-prior-7d delta. Until now each cell still
fetched a 180d usage window so the cache key matched the runtime-detail
page (clicking a row would pre-warm detail). The side effect was N
parallel 180d in-line aggregations against task_usage on every list
visit, one per runtime, which dominated DB load for this view.

Switch the cell to a 14d window — exactly the data it actually needs
for cost7d + costPrev7d. Detail still owns its own 180d query; the
worst case after this change is one extra request on first navigation
into detail, in exchange for a large steady-state reduction on the
list page (down to 14d × N instead of 180d × N, ~13× fewer rows
scanned per request).

This is the frontend half of the runtime-usage perf work tracked in
MUL-1748. The backend index + daily rollup changes will land
separately.

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:56:58 +08:00
Bohan Jiang
6ef9be10d6 fix(chat): expose History panel + delete affordance from chat header (#2117)
ChatSessionHistory was already implemented but unreachable: nothing in the
app rendered it and there was no UI to toggle showHistory. The trash icon
on each session row was therefore invisible.

Adds a History icon button to the chat-window header that toggles the
panel; when on, it renders ChatSessionHistory in place of the message
list and input. Per-row delete (hover trash + AlertDialog) works as
designed.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:36:06 +08:00
Bohan Jiang
60b215f44f feat(chat): support deleting chat sessions (#2115)
* feat(chat): support deleting chat sessions

Replaces the unreachable archive endpoint with a real hard delete and
exposes it from the chat history panel.

- DELETE /api/chat/sessions/{id} now hard-deletes the session and its
  messages (CASCADE), cancels any in-flight tasks before removal so the
  daemon doesn't keep running work whose result has nowhere to land,
  and broadcasts chat:session_deleted.
- Frontend adds a per-row delete button with a confirmation dialog,
  optimistically drops the session from both list caches, and clears the
  active session pointer locally + on other tabs via the WS handler.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): make session delete atomic and keep archived sessions read-only

Address review feedback on #2115.

- DeleteChatSession now runs lock + cancel + delete in a single tx and
  only broadcasts events post-commit. The new LockChatSessionForDelete
  query takes FOR UPDATE on chat_session, which blocks the FK validation
  of any concurrent SendChatMessage trying to enqueue a task for this
  session — that insert fails after we commit, so it can no longer
  produce an orphaned task whose chat_session_id is nulled by
  ON DELETE SET NULL. Cancel failure now aborts the delete instead of
  warn-and-continue.
- SendChatMessage refuses non-active sessions again. The archive code
  path is gone, but legacy rows with status='archived' may still exist
  in the DB; keep the guard until we explicitly migrate them.
- Frontend re-reads allChatSessionsOptions to disable ChatInput on
  legacy archived sessions so the UX matches the server-side guard.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:22:53 +08:00
Bohan Jiang
f1082b10a4 feat(cli): add --assignee-id / --to-id / --user-id for unambiguous targeting (#2114)
* feat(cli): add --assignee-id / --to-id / --user-id for unambiguous targeting

`multica issue {create,update,list}`, `issue assign`, and `issue subscriber
{add,remove}` accepted only fuzzy name matching, which fails in workspaces
where one user's name is a substring of another (e.g. agent "J" vs
"Cursor - J" / member "Jiayuan"). #1642 added UUID acceptance through the
existing flags, but there was still no explicit path that signals "this is a
UUID, not a name" — important for scripts that read IDs from
`multica workspace members --output json`.

Adds an `-id`-suffixed counterpart for every assignee-taking flag:

- `issue list`     : --assignee-id
- `issue create`   : --assignee-id
- `issue update`   : --assignee-id
- `issue assign`   : --to-id
- `issue subscriber {add,remove}` : --user-id

The new flags route through `resolveAssigneeByID`, a strict resolver that
requires a canonical UUID and fails with a clear error when the entity is
not in the workspace (no name fallback). A shared `pickAssigneeFromFlags`
helper enforces mutual exclusion between the name and id flags so a script
that accidentally sets both never silently applies one over the other.

Refs MUL-1254.

Co-authored-by: multica-agent <github@multica.ai>

* fix(cli): detect assignee flag presence via Changed, not value-emptiness

`pickAssigneeFromFlags` previously branched on `flag value != ""`, so
explicitly passing an empty UUID silently routed through the "no flag set"
path:

  multica issue list --assignee-id ""        # listed every issue
  multica issue create --assignee-id ""      # created an unassigned issue
  multica issue subscriber add --user-id ""  # subscribed the caller

This is exactly the failure mode the strict-UUID flag was added to prevent —
a script interpolating `--assignee-id "$MAYBE_UUID"` against a missing env
var should fail loudly, not silently degrade to a different operation.

Switch the picker (and the assign-command top-level guard) to use
`Flags().Changed`, so an explicit empty value reaches `resolveAssigneeByID`
/ `resolveAssignee` and surfaces a clear "expected a canonical UUID" /
"no member or agent found matching" error.

Co-authored-by: multica-agent <github@multica.ai>

* docs(cli): cover --assignee-id / --to-id in user docs and quick-create prompt

Follow-up to the --*-id flag rollout: surface the new flags everywhere the
old ones are documented so users (and agents) can discover them.

- assigning-issues.{mdx,zh.mdx}: the page explicitly calls out the
  duplicate-name footgun ("first one listed wins, so rename before
  assigning") — replace that workaround with a --to-id <uuid> example
- cloud-quickstart.{mdx,zh.mdx}: add a --to-id hint after the substring-
  match callout so first-time users learn about the strict path
- internal/daemon/prompt.go (quick-create injected prompt):
  - default-to-self: pass --assignee-id <task.Agent.ID> instead of
    --assignee <name>; the picker agent's UUID is already in scope and
    UUID matching is unambiguous in workspaces with overlapping agent
    names (J / Cursor - J / Pi - J etc.)
  - user-named: tell the agent to prefer --assignee-id <uuid> using the
    user_id/id from the JSON it already fetched; --assignee <name> stays
    a fallback for unambiguous workspaces

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:13:36 +08:00
LinYushen
44a0ced558 fix(runtime): persist CLI update requests in Redis (#2113)
Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:00:11 +08:00
Bohan Jiang
89b939b07c fix(storage): build region-qualified S3 public URLs (#2051) (#2065)
* fix(storage): build region-qualified S3 public URLs (#2051)

The uploadedURL fallback (no CloudFront, no custom endpoint) wrote
"https://<bucket>/<key>" — missing the ".s3.<region>.amazonaws.com"
suffix — so any deployment that pointed S3_BUCKET at a real AWS bucket
without a CDN got broken image URLs back to the client. Avatar URLs
were persisted in this broken form on the user/agent rows, so profile
pictures uploaded via the SDK never rendered.

- Track S3_REGION on S3Storage and emit
  https://<bucket>.s3.<region>.amazonaws.com/<key> by default;
  fall back to path-style https://s3.<region>.amazonaws.com/<bucket>/<key>
  when the bucket name contains dots, since the AWS wildcard cert
  can't validate dotted virtual-hosted hosts.
- Teach KeyFromURL to recognise the new region-qualified hosts (both
  styles) and keep recognising the legacy bucket-only host so historical
  records can still be deleted/migrated.
- Document that S3_BUCKET is the bucket name only, not a hostname,
  in env-vars docs (en+zh), self-hosting guides, and .env.example.

Co-authored-by: multica-agent <github@multica.ai>

* feat(storage): warn at startup when S3_BUCKET looks like a hostname

Catches the most common misconfiguration shape (S3_BUCKET set to
"<bucket>.s3.<region>.amazonaws.com") with a startup log line so
operators don't silently end up with a config that signs uploads
against an invalid bucket name.

A real bucket name can never legitimately contain "amazonaws.com",
so the check is a single substring match — no false positives
worth carving out.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 12:45:55 +08:00
Bohan Jiang
8b0eeb0615 fix(projects): show URL tooltip on already-attached repos in Add Resource list (#2111)
The repo button in the Add Resource popover used the native `disabled`
attribute when a repo was already attached. Browsers suppress pointer
events on disabled form controls, so the tooltip on the URL text never
fired for attached rows — the issue spec calls out "hovering over any
URL should also show the complete URL in a tooltip".

Switch to `aria-disabled` plus a click guard so the row still announces
as disabled to assistive tech, looks the same visually, and is no
longer click-able, but hover still reaches the tooltip trigger.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 12:00:27 +08:00
Ákos Seres
64c605e227 fix(execenv): write OpenCode skills to .opencode/skills/ for native discovery (#2016)
* fix(execenv): write OpenCode skills to .opencode/skills/ for native discovery

* fix(repocache): exclude OpenCode skill directory
2026-05-06 11:48:06 +08:00
Cong Vu Chi
820d57535e feat(desktop): load runtime self-host config (#2012)
* feat(desktop): load runtime self-host config

Co-authored-by: multica-agent <github@multica.ai>

* docs: document desktop runtime self-host config

Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): address runtime config review feedback

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Cheese <congvc@congvc-c00.taila6fa8a.ts.net>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: congvc <congvc-dev@gmail.com>
2026-05-06 11:39:36 +08:00
Bohan Jiang
a7299bf857 refactor(projects): pass projectId prop to ProjectIssuesContent (#2110)
Replace `scope.replace("project:", "")` with the `projectId` already
held by `ProjectDetail`, so the create-issue handler in the empty
state no longer depends on the `project:<id>` scope-string format.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 11:36:45 +08:00
Yash Soni
baac4080e9 fix(installer): correct Windows version parsing and checksum decode (#2093)
Closes #2092
2026-05-06 11:36:25 +08:00
Kagura
99f6cb8130 fix(projects): add New Issue button to empty project state and URL tooltips to resources (#2080)
When a project has no issues, show a [+ New Issue] button that opens
the create-issue dialog with the project pre-selected. Previously
users had to navigate to the issues page and manually assign the
project.

Also add tooltips to repository URLs in the Resources section so
truncated URLs can be read in full on hover.

Fixes #2078
2026-05-06 11:33:26 +08:00
ASDFGHoney
b5f1e506e5 fix(views): split desktop/mobile sidebar state in project-detail (#2067)
Mobile project-detail mounted its <Sheet> with open=true for one render —
useIsMobile() reports false on first render and flips to true on the next,
so the mobile branch briefly mounted Base UI Dialog open, painted its
fixed inset-0 z-50 backdrop and locked scroll. The follow-up useEffect
toggled it closed within the same animation cycle, leaving Dialog's
pointer-events/inert/scroll-lock state stuck on mobile.

Mirror packages/views/issues/components/issue-detail.tsx by keeping
desktopSidebarOpen (default true) and mobileSidebarOpen (default false)
as separate states, binding the mobile <Sheet> to mobileSidebarOpen only.
The single-state pattern dates back to #1087, where issue-detail and
project-detail received mobile-Sheet support together but only
issue-detail used split state.
2026-05-06 11:27:45 +08:00
Thanh Minh
00cde21724 fix(views): hide archived agents from runtime detail (#2097) 2026-05-06 11:23:56 +08:00
Jiayuan Zhang
1476c268dd refactor(quick-create): exempt git-describe daemons from CLI gate (#2108)
* refactor(quick-create): remove daemon CLI version gate

Local-source daemons report dev-suffixed versions (e.g.
v0.2.15-235-gdaf0e935) that the picker pre-check and server gate both
treat as too old, blocking quick-create during local testing.

Drops the gate end-to-end: removes MinQuickCreateCLIVersion +
CheckMinCLIVersion in pkg/agent, the checkQuickCreateDaemonVersion
handler and readRuntimeCLIVersion helper in handler/issue.go, and the
mirrored cli-version.ts plus the modal's pre-check, blocked-state UI,
and daemon_version_unsupported error branch.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(quick-create): skip daemon CLI version gate in dev

Restores the gate (reverts the full-removal commit) and bypasses it in
non-production environments instead. The motivation for the original
removal — local source-built daemons report a `git describe` version
like v0.2.15-N-gHASH that parses below 0.2.20 and blocks dev testing —
is now handled by checking APP_ENV on the server and NODE_ENV on the
client. Production keeps the original "needs upgrade" UX.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(quick-create): exempt git-describe daemons instead of env bypass

Replaces the per-environment bypass added in the previous commit with a
shared daemon-version signal. CheckMinCLIVersion / checkQuickCreateCliVersion
now treat any daemon whose CLI version matches the
`vX.Y.Z-N-gHASH[-dirty]` git-describe shape as OK; tagged releases keep
going through the normal min-version comparison.

Why: Emacs flagged that (a) NODE_ENV !== "production" also disables the
gate on staging and other non-prod deployments, undoing the protection
for the case the gate was originally written for, and (b) NODE_ENV (web
client) and APP_ENV (server) are not equivalent, so the modal pre-check
and server gate could disagree on the same request. Both go away when
the signal is intrinsic to the daemon's version string.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 09:00:11 +08:00
Jiayuan Zhang
9a5f5ca498 fix(views): coalesce repeated task_completed/task_failed activity entries (#2044)
Consecutive "completed the task" entries from the same agent now merge
into a single line showing the count (e.g. "completed the task (7 times)")
regardless of time gap. Other activity types keep the existing 2-minute
coalescing window.

Closes MUL-1709
2026-05-06 02:11:43 +02:00
Bohan Jiang
daf0e935f6 fix(views): show Ctrl+K / Ctrl+Enter on non-Mac platforms (#2060)
The sidebar search trigger, quick-create-issue modal, and feedback modal
hardcoded the Mac glyphs (⌘, ↵) for their keyboard hints, so Windows and
Linux users always saw Mac shortcuts even though the underlying handlers
already accept metaKey || ctrlKey.

Extract a small platform helper (isMac, modKey, enterKey, formatShortcut)
in packages/core/platform/keyboard.ts and route all four affected sites
(plus the editor bubble menu, which had the same logic inlined) through
it, so non-Mac users see Ctrl+K, Ctrl+Enter, etc.

Closes multica-ai/multica#2056
2026-05-04 21:26:00 +08:00
Bohan Jiang
5c42ed1649 fix(server): allow re-inviting after invitation expires (#2059)
The uniqueness check on workspace invitations only filtered by
status='pending', not by expires_at. Combined with the partial unique
index idx_invitation_unique_pending (also keyed only on status), a
past-due pending row permanently blocked re-inviting the same email.

Now, before creating a new invitation, the handler flips any past-due
pending row for the same (workspace_id, invitee_email) to 'expired',
freeing the unique slot. Also tightens GetPendingInvitationByEmail to
require expires_at > now(), matching the existing list queries.

Closes multica-ai/multica#2055.
2026-05-04 21:24:56 +08:00
Dingyj3178
a57dd76faf fix(views): improve mobile responsiveness for agents and settings (#2036)
* feat(agents): make agent detail page mobile responsive (#1)

Stack the inspector + overview pane vertically below md, switch the
shell to page-level scroll so the inspector flows naturally, give the
overview pane a min-h-[60vh] floor so tabs stay usable, and let the
5-tab nav scroll horizontally on narrow viewports.

* fix(settings): make Repositories tab and Settings shell mobile-responsive (#2)

The Settings shell used a fixed w-52 sidebar with no responsive behavior,
leaving almost no room for tab content on phone-width viewports. Stack the
nav above the content on mobile, scale inner padding, and let the
Repositories tab's input/button rows wrap rather than overflow.
2026-05-04 21:24:07 +08:00
Bohan Jiang
c24191a884 fix(editor): keep blank-line paste inside the code block (#2058)
Pasting `line1\n\nline2` while the caret was inside a code block ran the
text through the Markdown parser, which split on the blank line and tore
the code block open, dropping the trailing content into a sibling
paragraph.

Detect the codeBlock parent on `handlePaste` and insert the clipboard
text verbatim instead. Code blocks have `code: true`, so newlines stay
literal — exactly what users expect when pasting code or logs.

Closes #1982
2026-05-04 21:12:14 +08:00
Kagura
629f4136ac fix(codex): handle MCP elicitation server requests correctly (#1944)
* fix(codex): handle MCP elicitation server requests correctly

Fixes #1942.

handleServerRequest responded with {} to unrecognized Codex server
requests including mcpServer/elicitation/request. Codex 0.125+ expects
{action, content, _meta} for elicitation — the empty object causes a
deserialization error and the MCP tool call is reported as user-rejected.

Changes:
- Add mcpServer/elicitation/request case with correct response schema
- Add respondError helper for JSON-RPC error responses
- Return proper JSON-RPC method-not-found error for unknown server
  requests instead of silent empty object
- Add tests for MCP elicitation and unknown method handling

* fix: use cfg.Logger instead of global slog in codex handleServerRequest

Switch the unhandled-server-request warning from global slog.Warn to
c.cfg.Logger.Warn for consistency with all other log calls in codex.go.
This ensures the warning appears in daemon run-logs and per-task
pipelines where operators look during triage.
2026-05-04 21:05:37 +08:00
ASDFGHoney
cb078c0f36 fix(core): patch byIssue label cache on WS label change (#2048)
`onIssueLabelsChanged` patched the embedded `labels` field in the
issue list and detail caches but never touched `labelKeys.byIssue`,
the cache backing the issue-detail Properties LabelPicker. Mutations
already covered all three caches; WS-driven changes (agents, other
tabs) left the picker stale until remount, since `staleTime: Infinity`
plus `refetchOnWindowFocus: false` prevent recovery on focus.
2026-05-04 20:51:02 +08:00
ayakabot
e13e5edc8e fix(issues): trimEnd comparison on blur to avoid unnecessary updates (#2054)
Fixed: #2053
2026-05-04 20:50:39 +08:00
Manu
fee393df1f fix(views): show full repo URLs in project creation (#2045) 2026-05-04 20:50:17 +08:00
ayakabot
1ff4e27e77 feat(quick-create): cache agent prompt draft across navigation (#2039)
When creating an issue with agent, the input content was lost when
navigating away (e.g., to view a ticket) and returning. Manual create
already persisted its draft - now agent create does too.

Changes:
- Add prompt field to useQuickCreateStore (persisted with workspace)
- AgentCreatePanel reads initial prompt from draft store if no transient
  data.prompt is provided
- onUpdate now saves prompt to draft store (not just hasContent)
- clearPrompt() called after successful submit

Fixes: #1957
2026-05-04 00:03:27 +02:00
Jiayuan Zhang
fbf9460d5e feat(chat): support fullscreen expand mode (#2043)
* feat(chat): support fullscreen mode similar to Linear

When the expand button is clicked, the chat window now fills the entire
content area (inset-0) instead of scaling to 90% of parent. Resize
handles are hidden in fullscreen mode.

* fix(chat): use stacked card layout for fullscreen mode

Fullscreen chat now uses inset-3 with rounded corners, ring, and shadow
to create a stacked card effect on top of the content area — matching
the Linear design — instead of a flush inset-0 fill.

* feat(chat): add motion.dev spring animations for expand/collapse

- Install `motion` in @multica/views
- Replace CSS transitions with motion.div layout animation for
  expand/collapse (spring-based FLIP), giving a natural bouncy feel
- Open/close uses spring scale + smooth opacity fade
- Layout animations are disabled during drag-to-resize (instant updates)

* fix(chat): remove spring bounce from expand/collapse animation

Use critically damped springs (bounce: 0) so the animation settles
directly at its target without overshooting.

* fix(chat): fix text distortion during expand/collapse animation

Use layout="position" instead of layout (full FLIP). Full FLIP uses
scale transforms to animate size changes, which distorts text and
child content. Position-only layout animates translate only — size
changes are instant, text stays crisp.

* fix: regenerate lockfile with pnpm@10.28.2

The lockfile was previously generated with pnpm 10.12.4, causing
unrelated churn (lost libc constraints, deprecated metadata). Reset
to main and regenerated with the repo's pinned pnpm@10.28.2 so
the diff is scoped to the new motion dependency only.
2026-05-03 22:56:22 +02:00
Jiayuan Zhang
d492b9d7a6 Revert "feat(quick-create): add preset issue fields (#2002)" (#2042)
This reverts commit a039c4d803.
2026-05-03 20:02:40 +02:00
Bohan Jiang
3dc3e49a47 fix(daemon): remove Co-authored-by hook when workspace setting is off (#2035)
* fix(daemon): remove Co-authored-by hook when workspace setting is off

The prepare-commit-msg hook is installed in the bare repo's shared
hooks dir, so once installed it persists across worktrees. CreateWorktree
only installed the hook when the setting was enabled, but never removed
it — so disabling the workspace toggle had no effect on subsequent
commits.

Add removeCoAuthoredByHook and call it in both CreateWorktree branches
when the setting is disabled. Use a marker comment in the hook script so
removal only deletes hooks the daemon owns; user-installed hooks at the
same path are left alone.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): recognize legacy Multica prepare-commit-msg hook on removal

The first cut of removeCoAuthoredByHook only recognized hooks installed
by the new code (containing the multicaHookMarker sentinel). Bare clones
already on disk from previous daemon releases carry the older script
without that line, so toggling the workspace setting off would have
treated them as user hooks and left the trailer in place — exactly the
state reported in MUL-1704.

Match against a list of known daemon signatures (current marker + the
legacy "Installed by the Multica daemon." comment), and add a test that
seeds the verbatim legacy hook before CreateWorktree(... disabled) to
keep recognition aligned with what production hosts actually have on
disk.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 21:09:16 +08:00
Bohan Jiang
ae9098637d feat(analytics): suppress PostHog $pageview on desktop tab/workspace switches (#2033)
* feat(analytics): suppress PostHog $pageview on desktop tab/workspace switches

Desktop tab switches were emitting a $pageview every time the user clicked
between already-open tabs (or workspaces), since the tracker fired on any
change to the resolved active path. Real-data audit showed this was the
single largest source of PostHog quota burn — desktop accounted for 51% of
all $pageviews at ~34 pv/user/30d vs web's ~10 — and the re-emitted paths
add no signal because the original navigation already fired.

Detect "tab switch" as `(workspace, tabId)` identity changing while the
surface stays `tab`, and skip the capture in that case while still updating
the ref so the next in-tab navigation compares against the right baseline.
Login transitions, overlay open/close, and intra-tab navigation continue
to fire as before.

Co-authored-by: multica-agent <github@multica.ai>

* fix(analytics): only suppress $pageview for re-activations of known tabs

Prior commit suppressed every (workspace, tabId) change while the surface
stayed `tab`, which also swallowed the first $pageview for newly opened
tabs (`openInNewTab` / `addTab`) and for cross-workspace `switchWorkspace`
into a not-yet-seen tab.

Track an observed `(workspace, tabId) → path` map seeded from the
persisted tab store on mount. Suppress only when the active key is
already in the map AND its recorded path matches the current path —
i.e. genuine re-activation of an already-known tab. New tabs and
cross-workspace navigation to a fresh tab now correctly emit one
pageview.

Adds a vitest covering the three behaviors GPT-Boy flagged plus the
intra-tab navigation, overlay/login transitions, and persistence-restored
mount paths. Wires the `@/` alias into `vitest.config.ts` so component
tests can resolve renderer-relative imports.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(analytics): reuse tab-store helpers and inline observed-tabs seed

Replace the two ad-hoc tab selectors with the existing
`useActiveTabIdentity()` + `getActiveTab()` helpers from tab-store, which
already provide the (slug, tabId) primitive pair and the active tab
lookup with the same stability guarantees.

Move the observed-tabs Map seeding from a useEffect into a synchronous
first-render initializer. The seed runs once per mount before any
state-driven effect, so the previous useEffect-then-defensive-fallback
pattern in the second effect was unreachable.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 20:54:29 +08:00
Kagura
cc94fbd305 fix: handle square brackets in agent names for mention parsing (#1992)
* fix: handle square brackets in agent names for mention parsing (#1991)

The mention regex used [^\]]* to match labels, which broke when agent
names contained square brackets (e.g. David[TF]). The ] inside the name
caused the regex to stop matching prematurely, silently dropping the
mention.

Changes:
- Backend (mention.go): Switch to .+? (non-greedy) anchored on
  ](mention:// to correctly match labels with brackets
- Frontend (mention-extension.ts): Same regex fix in tokenizer, plus
  escape [ and ] in renderMarkdown to prevent creating ambiguous
  markdown syntax
- Add comprehensive tests for ParseMentions covering bracket names

Fixes #1991

* fix: add optional chaining for match group access

Fixes TS2532: Object is possibly 'undefined' on match[1] when calling
.replace() in the mention tokenizer.

* fix: tighten mention tokenizer to reject ordinary Markdown links

- Replace .+? with (?:\\.|[^\]])+  in start() and tokenize() regexes
  so the label cannot cross a ]( Markdown link boundary
- Escaped brackets (\[ \]) from renderMarkdown() are still accepted
- Add frontend tokenizer/serializer round-trip tests:
  - Plain mention
  - Escaped brackets (David[TF]) round-trip
  - Normal Markdown link + mention on same line (regression)
  - Multiple links before mention
  - Nested brackets (Bot[v2][beta])
  - Issue mentions without @ prefix

Addresses review feedback on #1992.

* fix: add type assertions for tiptap MarkdownTokenizer interface in tests

The tiptap MarkdownTokenizer type allows start to be string | function
and tokenize to accept 3 arguments. Our extension always provides
single-arg functions, so cast them for TypeScript satisfaction.

Fixes CI typecheck failure in @multica/views package.

* fix: cast renderMarkdown to single-arg shape and reset file modes to 0644
2026-05-03 19:39:26 +08:00
ayakabot
a039c4d803 feat(quick-create): add preset issue fields (#2002)
Fixed: #2001
2026-05-03 19:37:12 +08:00
Bohan Jiang
cf0d58ab50 docs(changelog): add 0.2.24 entry covering 0.2.22 → 0.2.23 → today (#2028)
Folds together everything that landed since the last public changelog
entry (0.2.21) into one 0.2.24 release note: repo checkout --ref,
agent avatar CLI, Hermes per-turn gate, multi-replica model picker
on Redis, Inbox long-timeline perf, and the rest of the smaller fixes
queued for tonight's release.

en.ts and zh.ts both updated.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 12:39:00 +08:00
furtherref
3fe3b84981 fix: hydrate agent cache after create (#2027)
(cherry picked from commit 0ea425c6e4)
2026-05-03 12:25:05 +08:00
Bohan Jiang
c4352da126 fix(daemon): drain background repo syncs before test teardown (#2026)
TestRegisterTaskReposSurvivesWorkspaceRefresh started flaking on CI
after #1988 (`feat: support repo checkout ref selection`) extended the
bare-clone path to run an extra `git fetch` to backfill
refs/remotes/origin/* under the new refspec layout. The race was
already latent: registerTaskRepos kicks off `go syncWorkspaceRepos(...)`
to clone a repo into the cache root, which in tests is `t.TempDir()`.
Once the test waited on `repoCache.Lookup` to return a path it would
proceed and return — but the bg goroutine was still inside
`ensureRemoteTrackingLayout` running git operations on the clone dir.
`t.TempDir`'s cleanup then races with those git commands and surfaces
either as "directory not empty" or "fatal: cannot change to ... No such
file or directory", with no hint that the failure is unrelated to the
test's actual assertion.

Track the background goroutine on the Daemon via a sync.WaitGroup and
expose `waitBackgroundSyncs()` for tests. `newRepoReadyTestDaemon`
registers a t.Cleanup that calls it, so every test that uses the
helper now drains in-flight syncs before t.TempDir cleanup runs. No
production-behavior change — registerTaskRepos still fires-and-forgets
from the caller's perspective.

Verified with `go test ./internal/daemon -run
TestRegisterTaskReposSurvivesWorkspaceRefresh -count=30` (was failing
within ~10 iterations before, 30 green after) and the full
`go test ./internal/daemon/...` suite.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 12:24:56 +08:00
Bohan Jiang
d0c66f3173 perf(issue-detail): memoize timeline render to mitigate Inbox long-timeline freeze (#2025)
* perf(issue-detail): memoize timeline render to fix Inbox long-timeline freeze

On long-timeline issues (thousands of comments), opening from Inbox hard-freezes
the browser tab because every WS-driven parent re-render re-runs the full
react-markdown + rehype-* + lowlight pipeline for every comment. This is the
S3 mitigation for multica#1968:

- Wrap ReadonlyContent in React.memo so equal-content re-renders skip the
  markdown pipeline entirely (the dominant cost per comment).
- Wrap CommentCard in React.memo so unrelated parent state updates don't
  re-render every card.
- useMemo the timeline grouping in IssueDetail so the allReplies Map and
  groups array references are stable across re-renders that don't change
  timeline.
- Stabilize toggleReaction via a timelineRef so its identity doesn't change
  on every WS event, which previously defeated CommentCard memoization.

Virtualization (S2) is the root fix for first-paint cost and lands separately.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issue-detail): destructure mutate/mutateAsync so CommentCard memo holds

Per review on PR #2025: TanStack Query v5 returns a fresh result wrapper
from useMutation on every render, with only the inner mutate / mutateAsync
functions guaranteed stable. The previous useCallback dependencies listed
the whole mutation object, so on every parent re-render the callbacks
flipped identity — defeating React.memo on CommentCard and leaving the
long-timeline mitigation only half-effective.

Pull just the stable handles into deps. Add a renderHook-based regression
test that re-renders useIssueTimeline twice and asserts the four callbacks
passed to CommentCard keep the same identity.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:57:30 +08:00
Bohan Jiang
170fa2102b fix(agent/hermes): wire streamingCurrentTurn gate to drop history replay (#2024)
Hermes ACP can flush queued session updates from the previous turn
before the current turn actually starts — both as session/resume
history replay and as chunks queued before our session/prompt response
streams. Without a gate those updates were appended to output and
re-emitted to the UI, so the previous answer appeared duplicated next
to the new one. Closes #1997.

PR #1789 added the acceptNotification hook field to hermesClient and
the call site in handleNotification, but never assigned it for Hermes,
so the guard short-circuited and every notification was processed.
This change mirrors the working Kiro pattern (kiro.go:87/97/240):

  - declare a streamingCurrentTurn atomic.Bool in the backend.
  - assign acceptNotification, onMessage, onPromptDone gates that all
    return early when the flag is false.
  - flip the flag to true immediately before c.request("session/prompt").

Adds TestHermesClientAcceptNotificationGate as a regression test that
exercises the gate directly on hermesClient.

Verified with `go test ./pkg/agent`.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:43:36 +08:00
Bohan Jiang
a414a00b4a refactor(repocache): clarify resolveBaseRef comment and cover tag refs (#2023)
Follow-up nits from PR #1988 review:

- Move the comment that documents getRemoteDefaultBranch's resolution
  walk into the resolveBaseRef call site description, and rephrase the
  "" branch so it's clear that path only fires for the default-branch
  case (the requested-ref path returns an explicit error before
  reaching it).
- Add TestCreateWorktreeWithRequestedTagRef to lock in the
  refs/tags/<ref> candidate. The test tags the initial commit, advances
  the default branch past it, then asserts the worktree HEAD matches
  the tagged commit (so the tag must have been resolved, not the
  default branch).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:30:25 +08:00
Prince Pal
862b0509df feat: support repo checkout ref selection (#1988) 2026-05-03 11:27:16 +08:00
Bohan Jiang
ba5b7db78e fix(server): persist ModelListStore across replicas via Redis (#2022)
* fix(server): persist ModelListStore across replicas via Redis

The model picker uses a pending-request pattern: the frontend POSTs to
create a request, the daemon pops it on its next heartbeat, runs
agent.ListModels locally, and reports back. Until now the store was a
plain in-memory map per Handler instance.

That works for self-hosted single-instance deploys but fails in any
multi-replica environment (Multica Cloud). Each replica has its own
map, so:

  POST /runtimes/:id/models               → request stored in replica A
  GET  /runtimes/:id/models/<requestId>   → polls land on B/C → 404
  daemon heartbeat                        → only A sees PendingModelList
  POST .../<requestId>/result             → daemon's report has to land on A

Success probability ~1/N². The visible symptom is "No models available"
in the picker for every provider, even those (Claude/Codex) whose
catalog is statically populated end-to-end.

Same shape of bug, same Redis-backed fix as multica-ai/multica#1557 did
for LocalSkillListStore / LocalSkillImportStore. Reuse the operational
playbook (namespaced keys, ZSET-backed pending queue, atomic
ZREM+SET-running via the shared Lua script) so we don't introduce a
second concurrency model for the same primitive.

Changes:
- Convert ModelListStore from struct to interface with context-aware
  methods. Add HasPending for cheap heartbeat-side probing.
- InMemoryModelListStore — single-node fallback, used when REDIS_URL
  is unset (self-hosted dev / tests).
- RedisModelListStore — multi-node implementation using the same key
  layout and Lua atomic claim as RedisLocalSkillListStore.
- Use RunStartedAt (not UpdatedAt) as the running-timeout reference
  point, matching the local-skill stores so subsequent UpdatedAt
  bumps don't reset the running clock.
- Heartbeat now uses the probe-then-pop pattern for the model queue
  (matching local-skills) so a slow Redis can't stall every connected
  daemon. Extends heartbeatMetrics + slow-log with probe_model_ms /
  pop_model_ms / probe_model_timed_out for parity.
- Wire the Redis backend in NewRouterWithOptions when rdb != nil.
- Tests for both backends. Redis tests gate on REDIS_TEST_URL so
  laptop runs without Redis still pass; CI provides it.

Co-authored-by: multica-agent <github@multica.ai>

* fix(server): persist RunStartedAt + retry model report on transient failures

Two follow-ups from PR #2022 review:

1. RedisModelListStore was dropping ModelListRequest.RunStartedAt on
   persistence — the field is tagged json:"-" so it doesn't leak into
   the HTTP response, which made plain json.Marshal(req) silently
   discard it. Across-node readers saw RunStartedAt=nil and
   applyModelListTimeout's running branch became a no-op, so the 60s
   running-timeout escape hatch never fired. CI's
   TestRedisModelListStore_RunningTimeout was failing on this exact
   case. Fix mirrors RedisLocalSkillImportStore's envelope pattern —
   wrap in an internal struct that re-promotes the field. HTTP shape
   stays clean. Adds a no-Redis unit test that pins the round trip.

2. Daemon's handleModelList called d.client.ReportModelListResult
   directly and swallowed any 5xx, leaving the pending request
   stranded in "running" until its 60s server-side timeout — exactly
   the failure mode the multi-node store fix was meant to eliminate.
   Generalize the existing local-skill retry helper into
   reportRuntimeResultWithRetry (kind: model_list / local_skill_list /
   local_skill_import) and wire handleModelList through a new
   reportModelListResult helper. Renames the test-overridable
   var localSkillReportBackoffs → runtimeReportBackoffs to match.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:13:34 +08:00
Bohan Jiang
3f046d03f7 fix(agent): expose GPT-5.5 family in Codex runtime model picker (#2020)
Latest Codex CLI ships with GPT-5.5 / GPT-5.5 mini, but the static
catalog still topped out at GPT-5.4 so users couldn't pick the new
model from the agent picker.

Add gpt-5.5 + gpt-5.5-mini to codexStaticModels and promote 5.5 as
the default badge. Keep the older 5.4 / 5.3-codex / gpt-5 / o3
entries for users on older Codex CLI builds. Add a regression test
mirroring TestGeminiStaticModelsExposesAliasesAndGemini3 so the
next OpenAI release isn't a silent miss.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:12:51 +08:00
Bohan Jiang
e665b597b3 refactor(server): polish runtime-guard nits from PR #1905 review (#2021)
- Expand chat-resume comment in ClaimTaskByRuntime to spell out *why* the
  task-row fallback exists (single failed turn must not drop chat memory)
  and that it covers more than just legacy NULL rows.
- Replace the sessionRuntimeID := t.RuntimeID; sessionRuntimeID.Valid = ...
  pattern in CompleteTask/FailTask with a clearer var-then-assign that makes
  the "no session_id, leave runtime_id alone" coupling obvious.
- Add TestClaimTask_ChatLegacyNullRuntimeFallsBackToTaskRow covering the
  case the prior PR's tests didn't reach: chat_session.runtime_id IS NULL
  (legacy / unbackfilled) plus a matching-runtime task row, fallback
  should resume. This is the dominant post-migration shape and was
  previously only covered transitively.

No behavior change beyond the new test; runtime-guard semantics stay
identical to PR #1905.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:00:32 +08:00
matthewcorven
075a845d9a docs: include GitHub Copilot CLI in root agent listings (#1983)
Copilot's backend (server/pkg/agent/copilot.go) and the public docs
site (apps/docs/) already treat it as one of the 11 supported agents,
but the root README, CLI guide, and self-host docs still listed only
10. Bring those to parity. Also brings README.zh-CN.md up to current
English content (was missing Copilot, Kimi, and Kiro CLI).
2026-05-03 10:59:09 +08:00
Bohan Jiang
972c65dbc1 fix(cli): make multica login --token accept the PAT as a value (#2017)
* fix(cli): make `multica login --token` accept the PAT as a value

The flag was registered as a Bool, so `multica login --token <PAT>` parsed
`--token` as `true` and dropped the supplied value as an unused positional
argument, then unconditionally prompted "Enter your personal access token:".
This contradicted the user-facing docs (`cli.mdx`, `CLI_AND_DAEMON.md`,
the in-app `connect-remote-dialog`) which show `--token <mul_...>`.

Switch `--token` to a String flag. Both `--token mul_...` and
`--token=mul_...` now bind the value and skip the prompt. Passing
`--token=` with an empty value (or `multica login --token=""`) still
falls through to the interactive prompt for users who don't want the
token in shell history. Updates the few internal docs that showed the
no-value form.

Fixes #1994

Co-authored-by: multica-agent <github@multica.ai>

* fix(cli): preserve `multica login --token` (no value) prompt path and tighten regression test

Addresses review feedback on #2017:

1. Restore the legacy no-value form. After the prior commit, `multica
   login --token` (no value) errored with `flag needs an argument:
   --token`, which broke the CLI_INSTALL.md / CLI_AND_DAEMON.md flow for
   headless users. Set `NoOptDefVal` on the `--token` flag to a sentinel
   that runAuthLoginToken treats as "prompt me," so:
     - `--token mul_xxx` and `--token=mul_xxx` consume the value (the
       #1994 fix is preserved),
     - `--token` alone falls through to the interactive prompt,
     - `--token=""` (explicit empty) also prompts.
   pflag with `NoOptDefVal` won't bind the next positional as the flag's
   value, so runAuthLogin recovers `--token mul_xxx` (the form from
   #1994) by promoting a single positional arg into the token. loginCmd
   gains `Args: cobra.MaximumNArgs(1)` so multi-positional typos still
   error fast.

2. Tighten regression coverage. Split into TestLoginTokenFlagWiring
   (asserts the production loginCmd.Flags().Lookup("token") is a String
   flag with the prompt-mode NoOptDefVal — would fail if anyone reverts
   the flag to Bool) and TestLoginTokenFlagParsing (drives all five
   documented invocation forms through the same flag wiring + the
   runAuthLogin space-form recovery). The synthetic-only test that the
   reviewer flagged is gone.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 10:53:06 +08:00
Multica Eve
f85b7cce91 fix: make CLI update completion status reliable (#2018)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 10:52:14 +08:00
Bright Zheng
cf47d9b702 fix: guard session resume by runtime (#1905) 2026-05-03 10:51:31 +08:00
Bright Zheng
c2f199650a feat(cli): add agent avatar upload command (#1760)
* feat(cli): add UploadFileWithURL and AttachmentResponse to APIClient

* feat(cli): add agent avatar command and show avatar_url in agent get output

* fix(server): include id and url in no-workspace file upload response

* fix(cli): remove dead HTTPClient timeout swap, extend ctx to 60s for avatar upload

The 30s context deadline was tighter than the 60s HTTPClient timeout
swap, so the swap was dead code and did nothing for slow connections.
Both Neo and Omni Mentor flagged this in review.

Fix: extend the command context to 60s and remove the HTTPClient
mutation. This is simpler, thread-safe, and actually works for slow
uploads.

* fix: align fallback upload response shape and honor context deadline

- file.go: fallback returns {id, url, filename} instead of {filename, link},
  matching the no-workspace path response shape.
- client.go UploadFileWithURL: tolerate empty attachment ID (S3 succeeded
  but DB record failed — the file is still usable via its URL).
- client.go UploadFileWithURL: use a context-deadline-aware HTTP client so
  that the 60s upload timeout set by the avatar command actually takes
  effect instead of being shadowed by the default 15s client timeout.
- client_test.go: update 'missing id' test to verify empty-id success
  (fallback tolerance).

* fix(cli): shallow-copy HTTP client to preserve Transport on upload timeout

When the context deadline exceeds the default 15s HTTP client timeout,
UploadFileWithURL was creating a bare &http.Client{Timeout: remaining},
silently dropping any custom Transport, Jar, or CheckRedirect configured
on the original client. This causes obscure connection failures when the
CLI uses an authenticated proxy, custom TLS, or mock transport in tests.

Fix: perform a shallow copy of the original client struct and only
mutate the Timeout field on the copy.
2026-05-03 10:49:02 +08:00
Jiayuan Zhang
3df95c84b8 fix(daemon): add safe.directory=* to gitEnv to fix CI dubious ownership errors (#1980)
* fix(daemon): add safe.directory=* to gitEnv to fix CI dubious ownership errors

TestRegisterTaskReposAllowsProjectOnlyURL and
TestRegisterTaskReposSurvivesWorkspaceRefresh fail on GitHub Actions CI
because git clone --bare from local temp directories triggers git's
safe.directory ownership check when the runner UID differs from the
directory owner.

Set safe.directory=* via GIT_CONFIG env vars in gitEnv() so all daemon
git subprocesses trust any directory. The daemon manages its own bare
caches and worktrees, so the ownership check provides no security value.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): preserve existing GIT_CONFIG_* entries in gitEnv

Instead of resetting GIT_CONFIG_COUNT to 1, read the existing count
from the environment and append safe.directory at the next available
index. This preserves any env-scoped git config (auth, URL rewrites,
extra headers) injected into the daemon process.

Adds TestGitEnvPreservesExistingConfig to verify the append behavior.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 16:18:58 +02:00
Jiayuan Zhang
050a2f0a5b fix(views): preserve kanban display settings when dragging issues (#1971)
Dragging an issue between kanban columns was forcefully switching the
sort mode to "position" (manual), resetting any user-chosen display
settings like sorting by title. Remove the auto-switch so the sort
preference is preserved across drag operations.

Fixes multica-ai/multica#1960

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 15:55:01 +02:00
Jiayuan Zhang
374f62be13 feat(inbox): remove redundant mark-as-done hover button, add archive button for done tasks (#1970)
Remove the "mark as done" hover button from inbox list items since it
duplicates the one in the issue detail header. For done tasks, show an
archive button in the issue detail header instead.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 09:19:15 +02:00
Jiayuan Zhang
d9e5cf87dd fix(views): responsive Autopilot list for mobile viewports (#1961)
Switch Autopilot list rows to a stacked layout below the sm breakpoint,
hide desktop column headers on mobile, and match loading skeletons to
the mobile row shape. Desktop table layout is preserved at sm and above.

Closes MUL-1653

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 08:19:19 +02:00
Jiayuan Zhang
13fe614903 fix(daemon): optimize quick-create prompt for high-fidelity descriptions (#1969)
The previous description rule ("stay faithful + keep it concise") caused
agents to over-compress user input into vague single-sentence summaries,
losing context that the executing agent needs.

Key changes:
- Replace "keep it concise" with structured two-section format:
  User request (faithful restate) + Context (verifiable external facts)
- Add hard rules against information compression and semantic downgrading
- Remove "one-line description" phrasing (UI supports richer input)
- Strip redundant behavioral rules from issue_context.md (already
  covered by AGENTS.md guardrails and per-turn prompt)

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 08:14:55 +02:00
wucm667
2305f7d180 fix(skill): sanitize null bytes in all skill update/upsert paths to prevent PostgreSQL UTF8 error (#1959) 2026-04-30 22:34:24 +02:00
Jay.TL
befde379b5 fix(runtimes): correct install script URL in connect remote dialog (#1949) 2026-04-30 14:57:33 +02:00
LinYushen
51fdc5aec3 Increase empty claim cache TTL (#1938) 2026-04-30 17:13:56 +08:00
Bohan Jiang
32d61d018e docs(changelog): publish v0.2.21 release notes (#1937)
* docs(changelog): publish v0.2.21 release notes

Adds the v0.2.21 entry to en.ts and zh.ts landing changelogs.
Highlights: Quick Capture overhaul, Mermaid diagrams in markdown,
typed project resources injected into agent runtime, permission-aware
UI, Presence v4, remote runtime wizard, and Inbox quality-of-life
improvements.

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): trim v0.2.21 entry to match prior release density

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): reword v0.2.21 project-repo feature

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 16:15:14 +08:00
Naiyuan Qing
51bc5a818f fix(onboarding): decouple from workspace state and route invitees correctly (#1936)
PR #1868 conflated "has workspace" with "completed onboarding" —
restore `onboarded_at` as the single signal, and route invited users
through a dedicated /invitations page before they ever see onboarding.

- Backend: CreateWorkspace + AcceptInvitation atomically set
  onboarded_at alongside the member insert, establishing the
  invariant "member row exists ↔ onboarded_at != null" at the DB
  layer.
- Migration 065: one-shot backfill closes the dirty rows produced
  by PR #1868 (users with a workspace but onboarded_at == null).
- Entry points (web callback, login, desktop App): if onboarded_at
  is null, look up pending invitations by email and route to the
  new batch /invitations page; otherwise the resolver picks
  workspace / new-workspace as before.
- OnboardingPage: stops bouncing on hasWorkspaces; only
  hasOnboarded bounces. Unblocks the user from completing
  Step 3 (workspace creation) → Steps 4 / 5.
- StarterContentPrompt: only shows when the user is the solo
  member of the workspace, so invited users never get prompted to
  import starter content into someone else's workspace.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:05:53 +08:00
Bohan Jiang
2dddfaa196 feat(daemon): Redis empty-claim fast path for /tasks/claim polling (#1860)
* feat(daemon): Redis empty-claim fast path for /tasks/claim polling

Daemons poll /tasks/claim every 30s per runtime; the steady-state
warm-empty case currently runs ListPendingTasksByRuntime against
Postgres on every poll. This collapses that path:

- New ListQueuedClaimCandidatesByRuntime query restricts to status =
  'queued' (the old query also returned 'dispatched' rows that can
  never be reclaimed) and is backed by a partial index keyed on
  (runtime_id, priority DESC, created_at ASC).
- New EmptyClaimCache caches the negative verdict in Redis with a
  30s TTL. ClaimTaskForRuntime checks the cache before SELECT and
  populates it on confirmed-empty results.
- notifyTaskAvailable now invalidates the runtime's empty key before
  kicking the daemon WS, so newly enqueued tasks become claimable
  immediately rather than waiting out the TTL.
- AutopilotService.dispatchRunOnly now goes through
  TaskService.NotifyTaskEnqueued so run_only tasks get the same
  invalidate-then-wakeup contract as every other enqueue path.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): close MarkEmpty/Bump race in empty-claim fast path

GPT-Boy's review on PR #1860 caught a real concurrency bug. Under the
prior implementation it was possible for a slow claim to write an
empty verdict AFTER a concurrent enqueue had already invalidated it:

  T1 claim:   SELECT -> empty
  T2 enqueue: INSERT row, DEL empty key (no-op, key not set yet),
              wakeup
  T1 claim:   SET empty (writes a stale "empty" verdict)
  T3 wakeup:  IsEmpty -> hit -> returns null

The just-queued task would then sit idle until the empty key's TTL
expired (up to 30s).

Replace the DEL-based invalidation with a per-runtime version
counter:

- CurrentVersion(rt) is a Redis INCR counter at
  mul:claim:runtime:version:<rt> with a 24h sliding TTL.
- Claim samples version BEFORE the SELECT and passes it to MarkEmpty,
  which stores the verdict's value as the observed-version string.
- IsEmpty MGETs both keys and trusts the verdict only when the
  empty-key value equals the current version.
- Enqueue Bumps the version (INCR + EXPIRE) before the wakeup,
  causing any verdict written under a prior version to be rejected
  on the next read.

Also bound every Redis call from this cache with a 250ms timeout —
notifyTaskAvailable uses a background context so a wedged Redis
must not block enqueue.

Tests against a real Redis (REDIS_TEST_URL) cover:
- MarkEmpty + IsEmpty under matching version returns hit
- Bump invalidates a prior empty verdict (race-fix pin)
- A MarkEmpty written under a stale pre-Bump version is rejected
- TTL clamping, per-runtime isolation, nil-cache safety
- notifyTaskAvailable Bumps before the wakeup fires

Co-authored-by: multica-agent <github@multica.ai>

* chore(daemon): renumber claim-candidate index migration to 067

Slot 064 was taken on main by 064_notification_preference. The
migration runner tracks per-version in schema_migrations and would
silently skip the second 064_*, leaving the index uncreated.
Rename to 067 (next free slot).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 15:50:05 +08:00
Ayman Alkurdi
cbe7f2c886 fix(api): batch-update no-op responses report updated=0 (#1660) (#1759)
The `POST /api/issues/batch-update` handler walked every issue ID and
incremented `updated` regardless of whether the iteration carried any
mutation. When the caller's payload had no recognized field in
`updates` — e.g. status placed at the top level instead of nested,
"update" misspelled as singular, or "updates" missing entirely —
the loop ran N no-op UPDATEs (each if-guard skipped, each COALESCE
preserved the existing value) and the response cheerfully reported
`{"updated": N}` while nothing changed. Reporters mistook the
positive count for success and chased a phantom persistence bug.

Detect at the top of the handler whether any known mutation field is
present in the parsed `updates` payload; if none is, short-circuit
with `{"updated": 0}`. The wire shape stays 200 + `{updated}`
so existing callers don't break — only the count becomes truthful.

Tests cover the three caller shapes that hit this path (status at top
level, empty `updates: {}`, misspelled "update") plus a positive
case that locks in happy-path persistence and counting.

Closes #1660.
2026-04-30 15:35:12 +08:00
Bohan Jiang
1d1dedbf6e fix(daemon): reclaim disk on long-open issues + correct cancelled-status check (#1931)
* fix(daemon): reclaim disk on long-open issues + correct cancelled-status check

Two related fixes for GitHub #1890 (self-hosted disk space growth):

- The GC's done/cancelled branch compared `status.Status` against `"canceled"`
  (single l), but the issue schema and the rest of the daemon use `"cancelled"`
  (double l). Cancelled issues therefore never matched and only fell out via the
  72h orphan TTL, which itself doesn't fire because cancelled issues are still
  reachable. Aligning the spelling lets cancelled-issue task dirs be reclaimed
  on the normal TTL path.

- Add a third GC mode, artifact-only cleanup, for the common case the report
  flagged: an issue stays open for days while many tasks complete on it, so
  per-task `node_modules`, `.next` and `.turbo` directories accumulate without
  ever becoming GC-eligible. The new branch fires when `.gc_meta.completed_at`
  is older than `MULTICA_GC_ARTIFACT_TTL` (default 12h), the env root is not
  currently in use by an active task, and the issue is still alive. It removes
  only directories whose basename matches `MULTICA_GC_ARTIFACT_PATTERNS`
  (default narrow: `node_modules,.next,.turbo`); source, `.git`, `output/`,
  `logs/` and the meta file are preserved so subsequent tasks can still resume
  the workdir. Patterns containing path separators are dropped, `.git` subtrees
  are never descended into, symlinked matches are not followed, and every
  removal target is verified to live inside the task dir.

Bookkeeping: `Daemon` now tracks active env roots with a refcounted set so the
GC loop never reclaims a directory that is mid-execution; `runTask` claims the
predicted root early plus the prior workdir on reuse paths. The cycle log is
extended with bytes reclaimed and per-pattern counts so self-hosted operators
can see what was freed.

Docs: extend the daemon configuration table in CLI_AND_DAEMON.md with the new
GC env vars and add a Workspace garbage collection section explaining the
three modes and the artifact-pattern contract.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): protect active env root from full GC removal too

Address GPT-Boy's PR #1931 review: the active-root guard only fired in the
artifact-cleanup branch, leaving a real race on the full-removal paths. A
follow-up comment on a long-done issue dispatches a task that reuses the prior
workdir, but `CreateComment` does not bump issue.updated_at — so the issue
still satisfies the done+stale GCTTL window and `gcActionClean` would
`RemoveAll` the directory mid-execution. The orphan-404 path is similarly
exposed when a token's workspace access is in flux.

Move the `isActiveEnvRoot` check to the top of `shouldCleanTaskDir` so all
three delete actions (clean, orphan, artifact) skip an in-use env root in one
place, and drop the now-redundant guard from the artifact branch.

Add tests covering the three at-risk paths: active root + done/stale issue,
active root + 404 issue past orphan TTL, active root + no-meta orphan past
TTL.

Also align two stale comments noted in the same review: cleanTaskArtifacts now
documents that symlinks are skipped entirely (the previous note implied the
link itself was removed), and GCOrphanTTL no longer claims that 404s are
cleaned immediately — the implementation gates them on the same TTL.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 15:34:16 +08:00
Jiayuan Zhang
298ed75b1d fix(views): only show "Mark as Done" button on Inbox page (#1934)
The toolbar button was previously visible on all issue detail views.
Gate it on the `onDone` prop, which is only passed from InboxPage.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 09:31:45 +02:00
Jiayuan Zhang
47b5e38dc6 docs: add Multica name origin section to README (#1933)
Sync the "Why Multica?" content from the landing page About section
into both README.md and README.zh-CN.md, explaining the name's
connection to Multics and the multiplexing philosophy.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 09:30:54 +02:00
Bohan Jiang
da5dbc6224 refactor(repos): drop unused description + tighten create-project layout (#1930)
* refactor(repos): drop unused description + tighten create-project layout

Two related changes that touch the workspace-repos surface together.

1. Remove the per-repo `description` field everywhere it was threaded.
   The only place it ever surfaced was a markdown table column the daemon
   wrote into the agent runtime config, where most rows just read "—"
   anyway. Agents already discover project structure by running
   `multica project` / `multica issue` against the CLI, so the human-
   readable description string carried no real value while taking up an
   extra Settings input row and propagating through six layers (settings
   UI → workspace.repos jsonb → handler RepoData → daemon RepoData →
   repocache.RepoInfo → execenv.RepoContextForEnv).

   - Settings → Repositories drops the description input; the URL field
     now spans the whole row.
   - WorkspaceRepo TS type loses `description`; backend RepoData /
     RepoInfo / RepoContextForEnv all collapse to URL only.
   - Daemon's runtime_config Repositories block changes from a
     `| URL | Description |` markdown table to a simple bullet list.
   - Tests updated; jsonb residue in existing workspaces is dropped at
     normalize time, so no migration needed.

2. Tighten the Create Project modal footer: pull the Status / Priority /
   Lead / Repos pills onto the same row as the Create Project button
   (Linear-style single-row footer) instead of stacking them above it,
   and swap the Repos pill icon from `FolderGit` to a real GitHub mark
   (lucide-react v1 dropped brand icons, so the mark lives inline as a
   small SVG component in this file).

   I tried promoting Repos to its own "Resources" strip above the footer
   to separate the resources abstraction from project metadata, but with
   a single pill it looked too sparse — leaving a TODO comment in the
   footer to revisit once we add Linear / Notion / Figma / Slack
   resource types.

* fix(daemon test): drop residual Description field on RepoData literals

* fix(repos): drop Description residue surfaced after rebase on #1929

Project-resource github_repo lift path (#1929) and registerTaskRepos
both still constructed RepoData{...Description: ...} after the rebase.
Two test sites in daemon_test.go and execenv_test.go also reintroduced
the field. Strip them so the Description-removal change builds and
tests pass with the latest main.
2026-04-30 14:55:03 +08:00
Bohan Jiang
2129aa3dee feat(projects): project github_repo resources override workspace repos (#1929)
* feat(projects): project github_repo resources override workspace repos

When an issue's project has at least one github_repo resource, the daemon
claim handler now sends only those as resp.Repos — workspace-level repos
are hidden to avoid mixing two repo lists in the agent prompt. With no
project github_repos (or no project), behavior is unchanged: workspace
repos are surfaced as before.

Lifts each project github_repo's url (and label, when present) into a
RepoData entry so `multica repo checkout` and the meta-skill render the
same URLs. The full structured list still ships at
.multica/project/resources.json for skills that want everything.

Adds TestProjectReposReplaceWorkspaceReposInMetaSkill covering the
rendering side. Docs updated to spell out the new precedence.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): allow project repo URLs through the checkout allowlist

When ClaimTaskByRuntime narrows resp.Repos to project github_repo URLs,
the daemon receives URLs that may not exist in the workspace's
GetWorkspaceRepos response. The existing checkout flow rejected those
with ErrRepoNotConfigured because the allowlist (and cache) was built
only from workspace-bound repos.

Adds registerTaskRepos in daemon.runTask: before agent spawn, merge
task.Repos into a new task-scoped allowlist (separate from the
workspace-scoped one so a workspace refresh doesn't wipe project URLs)
and kick off a background cache sync. ensureRepoReady now treats either
allowlist as valid.

Tests:
- TestRegisterTaskReposAllowsProjectOnlyURL — project-only URL is
  checkout-able and does not trigger a workspace-repos refresh
- TestRegisterTaskReposSurvivesWorkspaceRefresh — task URLs persist
  across refreshWorkspaceRepos
- TestClaimTask_ProjectGithubReposOverrideWorkspaceRepos — claim
  handler returns only project repos when present, no workspace leakage
- TestClaimTask_ProjectWithoutRepos_FallsBackToWorkspaceRepos — fall
  back to workspace repos when project has no github_repo resources

Docs updated to spell out the daemon-side allowlist behavior.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:37:51 +08:00
Multica Eve
2fd388da08 fix: stabilize mobile issue detail layout (#1912)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-30 08:32:51 +02:00
Bohan Jiang
cba3db0d7f feat(markdown): add fullscreen lightbox for mermaid diagrams (#1927)
A sandbox="" iframe cannot run scripts, so users had no way to zoom or
pan rendered Mermaid diagrams beyond browser scrolling. Add a hover
toolbar with a fullscreen button that opens a portal-based lightbox
showing the same diagram scaled to 90vw x 90vh, while preserving the
sandbox isolation (the lightbox iframe is also sandbox=""). ESC or
clicking the backdrop closes the lightbox.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:20:41 +08:00
Bohan Jiang
b1345685a3 fix(task): rerun starts a fresh session, skip poisoned resume (#1928)
* fix(task): rerun starts a fresh session, skip poisoned resume

When a task ended in a known agent fallback ("I reached the iteration
limit and couldn't generate a summary.", "Put your final update inside
the content string. Keep it concise.") the (agent_id, issue_id) resume
lookup would still pick that session, so a manual rerun inherited the
poisoned state and reproduced the same bad output.

Two complementary guards:

1. Daemon classifies poisoned terminal output and routes it through the
   blocked path with failure_reason set ('iteration_limit' /
   'agent_fallback_message'). GetLastTaskSession excludes failed tasks
   with those reasons, so even comment-triggered tasks no longer resume
   them. Tasks that failed mid-flight (timeout, runtime_recovery, etc.)
   are still resumable, preserving MUL-1128's auto-retry contract.

2. Manual rerun marks the new task force_fresh_session=true. The daemon
   claim handler skips the resume lookup entirely when the flag is set,
   capturing the user-intent signal that "the prior output was bad" even
   when poisoned classification misses a future fallback wording.

Auto-retry of orphaned mid-flight failures (MaybeRetryFailedTask →
CreateRetryTask) does not take this path, so it keeps resuming.

Tests: classifyPoisonedOutput unit test; integration tests assert the
SQL filter excludes poisoned classifiers, RerunIssue flips the flag,
and the normal enqueue path leaves it false.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): cap poisoned-output matcher to short trimmed text

GPT-Boy review on MUL-1630: the previous strings.Contains match would
classify any output that quoted the marker substring — including a
review/analysis that simply discussed the marker itself. Real fallback
messages are short single-sentence affairs, so cap the candidate at
~one paragraph and trim whitespace before matching. Adds regression
tests covering a long quoting review and a marker buried in a long
real conclusion; both must stay classified as completed.

Co-authored-by: multica-agent <github@multica.ai>

* fix(migrations): rename 065 force_fresh_session → 066 to clear collision

main introduced 065_project_resources after this branch was cut, so
both files shared the 065_ prefix. The readiness check
(server/cmd/server/health.go → migrations.LatestVersion) takes the
last entry by lexical order, which is 065_project_resources, leaving
this branch's 065_force_fresh_session unguarded — a deploy that
applied project_resources but not force_fresh_session would still
report ready, and the next enqueue / rerun / claim would crash on
"column force_fresh_session does not exist".

Renaming to 066_force_fresh_session puts it strictly after
project_resources so readiness blocks until it's applied.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:17:53 +08:00
Bohan Jiang
44608713bb feat(projects): typed project resources + agent runtime injection (#1926)
* feat(projects): typed project resources + agent runtime injection

Adds a `project_resource` table that lets a project carry typed pointers
(github_repo today, more later) and surfaces them at agent runtime.

Server
- migration 065: project_resource (resource_type TEXT + resource_ref JSONB)
- sqlc CRUD + handler at /api/projects/{id}/resources
- claim handler attaches project_id/title + resources to issue tasks

Daemon
- TaskContextForEnv carries project context
- writes .multica/project/resources.json into workdir
- adds "## Project Context" block to CLAUDE.md / AGENTS.md / GEMINI.md
  via type-dispatched formatter so new resource types just add a case

CLI
- multica project create --repo <url> attaches repos in one step
- multica project resource add/list/remove

Frontend
- Project create modal: Repos pill (workspace repos + ad-hoc URL)
- Project detail sidebar: collapsible Resources section with attach/remove

Docs
- New "Project Resources" chapter explaining the abstraction and
  exactly what code to touch when adding a new resource type

Co-authored-by: multica-agent <github@multica.ai>

* fix(projects): transactional resources[] on create + generic CLI ref + test fix

Addresses review feedback on PR #1926:

1. CI red: TestProjectResourceLifecycle delete step called withURLParam
   twice, which replaced the chi route context and dropped the project id.
   Switched to the existing withURLParams helper from daemon_test.go.

2. POST /api/projects now accepts resources[] and attaches them in the
   same transaction as the project. Invalid refs roll back the whole
   create — no more half-attached projects on failure. Web modal + CLI
   `project create --repo` both use the new bundled payload.

3. CLI `project resource add` now accepts a generic --ref '<json>' flag
   so a new resource_type works without a CLI change. Per-type
   shortcuts (--url for github_repo) remain as a convenience but are no
   longer the only way in. Docs updated to drop the CLI from the
   "files you must touch" list.

Adds two new server handler tests:
- TestCreateProjectAttachesResources (resources[] happy path)
- TestCreateProjectRollsBackOnInvalidResource (transactional rollback)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:00:43 +08:00
Prince Pal
a28312c0b4 feat(markdown): render mermaid diagrams (#1888)
* feat(markdown): render mermaid diagrams

* fix(markdown): harden mermaid diagram rendering

* fix(markdown): address mermaid review feedback

* fix(markdown): strengthen mermaid theme handling

* fix(markdown): rasterize mermaid theme colors
2026-04-30 13:27:01 +08:00
Bohan Jiang
72d5135bf0 fix(quick-create): subscribe requester to issues created via quick-create (#1924)
The agent runs the daemon CLI, so issue.creator_type is `agent` and the
issue:created event listener only auto-subscribes the agent — not the
human requester. Result: the requester gets a single completion inbox
item but never sees follow-up comments or updates on their own issue.

Subscribe the requester (reason=`creator`, the only matching value
allowed by issue_subscriber's CHECK constraint without a migration)
inside notifyQuickCreateCompleted, after the issue lookup succeeds and
before the inbox write. Best-effort: log on failure, don't block the
inbox. On success, publish subscriber:added so the UI stays in sync
with manual subscribe and the listener-driven path.

Adds two integration tests in cmd/server: success path subscribes the
requester; failure path (agent finished without creating an issue)
leaves no subscriber rows.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 13:19:34 +08:00
Prince Pal
924c69114d feat(daemon): expose concurrent task slot env (#1889)
* feat(daemon): expose concurrent task slot env

* fix(daemon): address task slot review nits
2026-04-30 12:56:40 +08:00
Multica Eve
700e6f3f24 fix: prevent mobile input focus zoom
Add a shared mobile/coarse-pointer CSS guard that keeps focused text-editing controls at 16px to avoid iOS Safari page zoom.
2026-04-30 12:28:22 +08:00
Naiyuan Qing
d68f1f4bf1 fix(issues): wrap Details and Token usage sections in grid (#1921)
PropRow switched to CSS subgrid in #1919, which requires its parent to
declare grid columns. The Properties section's wrapper was updated, but
Details and Token usage in the same file were missed — their PropRows
collapsed to a single column, stacking label and value vertically.

Add the same `grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5` wrapper used
by Properties so all three sections render consistently.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:59:14 +08:00
Naiyuan Qing
281779330e feat(chat): no-agent disabled state with onboarding fix and editor cleanup (#1919)
* fix(onboarding): refresh agent cache after import and agent creation

Two paths could leave the workspace agent-list query cache stale by the
time the dashboard rendered the welcome issue, causing the issue's
agent assignee to resolve to "Unknown Agent":

1. StarterContentPrompt.onImport invalidated pins/projects/issues but
   not agents, and didn't await any of them before navigating — so the
   issue-detail page could mount and read the cache before TanStack
   Query had marked the relevant queries stale.
2. OnboardingFlow.handleAgentCreated created the agent without
   invalidating the agent list, so the dashboard's first mount would
   read whatever was already cached from earlier in onboarding.

Both now invalidate workspaceKeys.agents, and the import flow awaits
all invalidations via Promise.all before pushing the navigation, so
the next page mount always refetches.

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

* refactor(editor): drop editable prop, ContentEditor is editing-only

ContentEditor's `editable` prop had zero true callsites left in the
codebase — every read-only surface had migrated to ReadonlyContent
(react-markdown), and the prop only invited misuse: Tiptap's
`useEditor` reads `editable` at mount, so callers that toggled it
post-mount (like a chat input that needs to disable on no-agent)
silently got stuck in whichever mode the editor first created.

Changes:
- Remove `editable` prop and default; useEditor and createEditorExtensions
  no longer take it.
- Remove the `"readonly"` className branch and the readonly content sync
  useEffect (only the editing path remains).
- Remove the BubbleMenu and mouseDown editable guards.
- Drop LinkReadonly; rename LinkEditable to LinkExtension and use it
  unconditionally.
- Update the docstring to point readers at ReadonlyContent for display
  surfaces.

ReadonlyContent's `.readonly` CSS class stays in content-editor.css —
that file's selectors are still used by react-markdown's wrapper.

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

* feat(chat): empty-state by session history, no-agent disabled state

Three independent improvements to the chat window's pre-conversation
states, sharing a new three-state availability primitive:

1. New `useWorkspaceAgentAvailability()` hook (`"loading" | "none" |
   "available"`) so callers don't have to reinvent the loading-vs-empty
   distinction. Treating loading as "no agent" — the easy mistake —
   caused the chat input to flash a fake disabled state for the few
   hundred ms after mount, even when the workspace had agents.
2. EmptyState now branches on session history, not agent presence:
   never-chatted users get a short pitch ("They know your workspace —
   issues, projects, skills"), returning users get the existing
   starter prompts. Missing-agent feedback moved to the banner above
   the input, keeping this surface focused on "what is chat for".
3. No-agent disabled state: when availability resolves to "none",
   ChatInput dims and stops responding to clicks/keys, with cursor
   `not-allowed` on hover. The disable lives at the wrapper level
   (`pointer-events-none` on the inner card, `cursor-not-allowed` on
   the outer one — splitting layers so hover bubbles to where the
   browser reads cursor) — we no longer reach into the editor's
   editable mode, which never switched cleanly post-mount anyway.
   A `<NoAgentBanner>` (sibling of OfflineBanner, mutually exclusive)
   states the prerequisite without linking out — no one should be
   pulled out of chat mid-thought to a settings page.

Also: default chat width 420 → 380, since the chat docks at the
bottom-right and 420 was crowding everything else.

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

* refactor(views): align PropRow labels using CSS subgrid

The fixed `w-16` (64px) label column on PropRow broke whenever a label
rendered wider than 64px (e.g. "Concurrency" in the agent inspector) —
the label would overflow into the gap and collide with the value.

Switch to subgrid: the parent declares `grid grid-cols-[auto_1fr]` and
each PropRow becomes `col-span-2 grid grid-cols-subgrid`. The `auto`
track sizes to the widest label across all rows in that parent, so
labels always fit and value columns stay aligned across rows without
picking a magic pixel width.

Updated parents:
- agent-detail-inspector Section wrapper
- issue-detail Properties group

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-30 11:32:35 +08:00
Naiyuan Qing
949dffdf7e feat: permission-aware UI across agent/comment/runtime/skill surfaces (#1915)
* feat(permissions): add core permission module and shared UI primitives

Foundation for permission-aware UI: pure rules that mirror the Go backend
permission gates, lightweight per-resource hooks, and two reusable display
components used across agent/skill/runtime detail pages.

- packages/core/permissions: types, rules, hooks (Decision-shaped — carries
  reason + message so UI can render disabled state, tooltip, and banner
  copy from one source)
- packages/core/agents/visibility-label: VISIBILITY_LABEL/DESCRIPTION/TOOLTIP
  constants ("Personal" / "Workspace") to replace scattered hard-coded copy
- packages/views/agents/visibility-badge: read-only visibility chip used on
  hover cards, list rows, and inspector when not editable
- packages/ui/components/common/capability-banner: "View only — only X and
  admins can edit Y" banner shown on agent / skill detail when current user
  lacks edit permission

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

* feat(views): permission-aware UI across agent/comment/runtime/skill surfaces

Apply the new permission rules to every surface where the UI was either
lying about who can do what or letting users hit 403s by clicking buttons
the backend would reject.

Agent detail
- Hide archive/restore actions for non-owner non-admin
- Replace inline editors (avatar, name, description, runtime/model/visibility/
  concurrency picker, skill-attach) with read-only display when canEdit is
  false — value is information, the editor is the action
- Show CapabilityBanner under the header explaining who can edit

Visibility surfaces
- visibility-picker / create-agent-dialog: replace "only you can assign"
  (false) with "Only you and workspace admins can assign" via shared
  VISIBILITY_DESCRIPTION constants
- agent-columns: truthful tooltip + "You" badge on agents the current user
  owns

Comments
- Restore admin override on comment edit/delete (backend already permits
  it via comment.go:507-512; the frontend was incorrectly hiding the menu).
  canModerate is computed once in issue-detail and threaded down.

Other
- Members tab: disable "demote" options for the last owner with tooltip
- Assignee picker: tooltip on disabled personal agents the user can't assign
- Runtime delete: tooltip and dialog explain the gate; owner column gains
  a name label next to the avatar in All scope
- Skill detail: page-level CapabilityBanner alongside the existing lock chip
- Issue delete (single + batch): note that any workspace member can delete
  issues — by-design semantics, made transparent

Backend is unchanged.

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

* feat(agents): hide personal agents from list and @mention for non-owners

Until now an agent's "Personal" visibility only narrowed the assign-to-issue
gate — every workspace member still saw every personal agent in the list
and the @mention dropdown. Members would see, click, and fail.

This filters those surfaces with the canonical canAssignAgentToIssue rule:
regular members only see workspace-visibility agents and the personal
agents they own; workspace owners and admins continue to see everything
(admin override path is intact).

- agents-page: visibleInView layer between active/archived and Mine/All
  scope so segment counts also reflect the filter
- mention-suggestion: filter agentItems before they enter the recency-
  ranked list; expand the test mock to cover the auth + visibility paths
  and add two assertions (member hides others' personal agents; admin
  still sees them)

Backend keeps returning every agent — admin tools and direct API access
are unaffected. This is a UI-only filter.

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-30 09:31:19 +08:00
Naiyuan Qing
e6e9c64484 refactor(chat): simplify task-status-pill (#1914)
Three signal axes (color / label tiers / per-tool spinner) collapsed
into one (label only):

- Drop 60s amber warning color and 300s cancel-button threshold. The
  cancel button duplicated ChatInput's Stop button (both call the same
  handleStop) — single entry point is enough; users can judge from the
  elapsed seconds whether to stop.
- Drop tiered thinking labels (Thinking / Reasoning / Working through
  it / Taking a closer look) — collapse to a single "Thinking".
- Unify all spinners to `breathe` (was: helix / scan / cascade / orbit
  / breathe / pulse / braille mix). Tool-specific spinner choices were
  cosmetic noise; one consistent spinner reads cleaner.
- Remove `onCancel` prop chain through ChatMessageList → TaskStatusPill.

Net: 209 → 152 lines in task-status-pill.tsx; no API/contract changes
beyond removing a now-unused prop.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:15:34 +08:00
Prince Pal
c6a26facd3 fix(inbox): jump instantly to targeted comments (#1887) 2026-04-29 23:25:01 +02:00
Jiayuan Zhang
b6a3f8ed58 feat(daemon): add Co-authored-by trailer for Multica Agent to git commits (#1907)
* feat(daemon): add Co-authored-by trailer for Multica Agent to git commits

Install a prepare-commit-msg hook in worktree bare repos that appends
"Co-authored-by: multica-agent <github@multica.ai>" to every commit
made by agents. Uses git interpret-trailers for proper formatting and
skips duplicates.

* feat(settings): add Co-authored-by toggle in workspace Labs settings

Add a workspace-level toggle to enable/disable the Co-authored-by
trailer for agent commits. Default is enabled (on).

Backend:
- Include workspace settings in daemon register response
- Store settings in daemon workspaceState
- Thread CoAuthoredByEnabled through WorktreeParams to conditionally
  install the prepare-commit-msg hook
- Parse co_authored_by_enabled from workspace settings JSONB

Frontend:
- Replace empty Labs tab placeholder with a Git section containing
  a Switch toggle for the Co-authored-by trailer setting
- Optimistically update the workspace query cache on toggle

* chore(daemon): skip squash commits in Co-authored-by hook

Test commit to verify the prepare-commit-msg hook appends the
Co-authored-by trailer automatically.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-29 23:02:50 +02:00
Jiayuan Zhang
8c9c52b023 feat(inbox): add notification preferences to control inbox noise by event type (#1906)
Users can now mute specific notification categories (assignments, status
changes, comments & mentions, priority/due-date updates, agent activity)
from Settings > Notifications. Muted event types are silently filtered at
notification creation time — no inbox items are created for muted groups.

- Add notification_preference table (migration 064)
- Add GET/PUT /api/notification-preferences endpoints
- Filter notifications in notifyIssueSubscribers, notifyDirect, and
  notifyMentionedMembers based on user preferences
- Add Notifications tab in Settings with per-group toggle switches
2026-04-29 22:51:29 +02:00
Jiayuan Zhang
562949e1cb fix(daemon): prevent Quick Create from inventing requirements beyond user input (#1903)
The description rule in buildQuickCreatePrompt() instructed the agent to
"always provide a rich, self-contained description" and "spell out what
needs to be done", which caused the agent to fabricate detailed product
specs, implementation phases, and design decisions from a one-line input.

Replace with a faithfulness-first rule: enrich with factual context
(fetched PR details, linked resources) but never invent requirements,
design decisions, or constraints the user did not express.

Fixes MUL-1605
2026-04-29 21:12:17 +02:00
Jiayuan Zhang
65f6e9c9f2 feat(autopilots): show execution log button for run-only autopilot runs (#1901)
In run-only mode, autopilot runs don't create issues, so there was no
way to view the agent's execution transcript from the UI. Add a
TranscriptButton to each run row that has a task_id but no linked
issue, allowing users to lazy-load and inspect the full execution log
directly from the autopilot detail page.
2026-04-29 19:10:49 +02:00
Jiayuan Zhang
79d28b0da6 fix(agents): navigate to detail page before invalidating list query (#1897)
After creating an agent from the empty state, the query invalidation
triggered a refetch that re-rendered the agents list page (empty → list)
before navigation to the detail page completed, causing a visible flash.

Move navigation.push() before qc.invalidateQueries() so the user lands
on the detail page immediately; the list refetch happens in the
background after we've already left.
2026-04-29 18:22:56 +02:00
Jiayuan Zhang
aeccd4f26e feat(quick-create): enrich issue title and description with URL context (#1892)
* feat(quick-create): enrich issue title and description with URL context

Update the quick-create agent prompt to fetch context from URLs in user
input (GitHub PRs, issues, web pages) before creating the issue. The
agent now produces semantically rich titles (e.g. "Review PR #123:
Refactor auth to OAuth2" instead of "review PR #123") and includes
summarized link content in the description so issues are self-contained.

* refactor(quick-create): let agent decide when to fetch URL context

Replace prescriptive URL enrichment instructions (hardcoded gh/WebFetch
commands) with goal-oriented guidance. The agent now uses its own
judgment to decide whether fetching referenced URLs would produce a
meaningfully better title/description, rather than being told exactly
which tools to use.

* fix(quick-create): always generate rich description for agent execution

The description was previously optional ("omit if simple request"). Since
quick-create issues are executed by agents, richer context leads to
better execution — update the prompt to always produce a substantive
description with actionable context.

* fix(quick-create): remove Chinese text from prompt, use English only

Replace Chinese examples in priority mapping and assignee matching with
language-agnostic English equivalents, per project coding rules.

* fix(quick-create): remove language-related hints from prompt

Agent doesn't need to be told about language handling — remove
"(in any language)" and "or equivalent in any language" qualifiers.
Keep prompt purely in English with no language-related content.
2026-04-29 18:19:11 +02:00
Jiayuan Zhang
68ed2a32d9 fix(desktop): prevent Cmd+R / Ctrl+R / F5 from reloading the page (#1896)
In a desktop app an accidental page reload destroys in-memory state
(tabs, drafts, WS connections) with no URL bar to navigate back.

Add a before-input-event listener on the main BrowserWindow that
intercepts Cmd+R / Ctrl+R (with or without Shift) and F5, calling
preventDefault() to block the reload. DevTools refresh still works.
2026-04-29 18:18:01 +02:00
Jiayuan Zhang
f508190065 feat(modals): persist drafts for create-project and feedback modals (#1894)
Add Zustand persisted draft stores for the create-project and feedback
modals, following the same pattern as the existing issue draft store.
Drafts are saved to localStorage on every field change and restored
when the modal reopens, preventing accidental data loss on close.
Draft is cleared on successful submit.
2026-04-29 17:58:19 +02:00
Jiayuan Zhang
d5611d550a fix(inbox): auto-archive inbox item when marking done from issue detail (#1893)
When viewing an inbox notification's issue detail and clicking the "Mark
as done" toolbar button, the inbox item was not archived — only the issue
status changed. Add an onDone callback to IssueDetail so the inbox page
can archive the notification alongside the status update, matching the
behavior of the list-item Done button.

Closes MUL-1594
2026-04-29 17:57:00 +02:00
Jiayuan Zhang
28b29ec5ee feat(views): add remote machine / AWS EC2 connection wizard to Runtimes page (#1886)
* feat(views): add remote machine / AWS EC2 connection wizard to Runtimes page

Add a "Connect remote machine" CTA to the Runtimes page header and
empty state that opens a 3-step wizard dialog guiding users through:

1. Installing the Multica CLI on a remote machine
2. Configuring, logging in with a PAT, and starting the daemon
3. Monitoring for runtime registration via WebSocket

Includes security tips (IAM roles, no root keys), troubleshooting
guidance (daemon status/logs, CLI version check), and post-connection
flow to create an agent on the newly registered runtime.

Closes MUL-1588

* fix(views): improve connect-remote dialog layout and usability

- Widen dialog from sm:max-w-lg to sm:max-w-xl for longer commands
- Add max-h-[85vh] + overflow-y-auto so content scrolls on small screens
- Split monolithic code block into 4 separate labeled steps (install,
  configure, login, start daemon) — each with its own copy button
- Make copy buttons always visible instead of hover-only
- Condense security tips into a single compact paragraph
- Tighten vertical spacing throughout
2026-04-29 17:35:45 +02:00
Jiayuan Zhang
b98c2a5a0f feat(inbox): add one-click Done button to inbox items (#1885)
* feat(inbox): add one-click Done button to inbox items

Add a hover-visible "Mark as done" button (CircleCheck icon) to each
inbox item that has an associated issue not yet in done/cancelled status.
Clicking it sets the issue status to "done" and archives the inbox item
in one action, replacing the previous multi-step flow of opening the
issue detail sidebar to change status.

* feat(issues): add Mark Done button to issue detail toolbar

Add a "Mark as done" button (CircleCheck icon) to the issue detail
header toolbar, positioned to the left of the Pin button. The button
is only visible when the issue status is not already done or cancelled.
Clicking it sets the issue status to "done" via the existing
handleUpdateField action.
2026-04-29 16:07:34 +02:00
Multica Eve
b9118ae9b8 Refine Quick Create agent modal (#1879)
* fix: refine quick create agent modal

* fix: align quick create toolbar feedback

* fix: sync create mode toolbar options

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 15:55:00 +02:00
Multica Eve
06880d6ba2 fix: make workspace table columns resizable (#1881)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Jiayuan Zhang <forrestchang7@gmail.com>
2026-04-29 15:23:12 +02:00
Multica Eve
472e78022e fix: improve quick create inbox previews (#1883)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Jiayuan Zhang <forrestchang7@gmail.com>
2026-04-29 20:56:27 +08:00
elrrrrrrr
5bf0e7022d fix(auth): route invitees to their workspace instead of forcing /onboarding (#1868)
* fix(auth): route invitees to their workspace instead of forcing /onboarding

Workspace presence now wins over `onboarded_at` across every post-auth
entry point, so a user invited into an existing workspace lands inside
that workspace instead of being trapped in the new-workspace wizard.

The redesigned onboarding flow (#1411) intentionally flipped the
priority during frontend development so every login re-entered
/onboarding; the backend `onboarded_at` field shipped but the flipped
priority was never restored. Closes #1837.

- packages/core/paths/resolve.ts: has-workspace beats !hasOnboarded.
  Onboarding is reachable only when the user has zero workspaces.
- apps/web/app/auth/callback/page.tsx: drop the early-return on
  !onboarded so a `next=/invite/<id>` survives Google OAuth round-trips.
- apps/web/app/(auth)/login/page.tsx: same removal in both the
  already-authenticated effect and the post-login handler.
- packages/views/layout/use-dashboard-guard.ts: stop bouncing in-workspace
  users to /onboarding; rely on the resolver for zero-workspace cases.
- apps/desktop/src/renderer/src/App.tsx: window-overlay now opens
  onboarding only when wsCount === 0 AND !hasOnboarded.
- apps/web/app/(auth)/onboarding/page.tsx: defense-in-depth — bounce
  away if the visitor already has a workspace, even on direct URL access.

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

* test(auth): fix URLSearchParams leaking state across callback tests

The previous cleanup `mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k))`
silently skipped entries because forEach advances its index while the
underlying URLSearchParams shrinks, so a `state=next:/invite/...` set
in one test bled into the next. Snapshot keys via Array.from before
deleting. Also rewrites the assertions to match the new policy: an
unonboarded user with a safe `next=` honors it, with a workspace lands
in that workspace, and only with zero workspaces falls back to
/onboarding.

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-29 20:53:58 +08:00
Multica Eve
665ac39730 fix(ci): restore frontend checks (#1878)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 14:49:42 +02:00
Jiayuan Zhang
55b7e2e93a fix(views): stop showing hardcoded model name in default model display (#1875)
When no model is explicitly selected, the model dropdown and inspector
picker no longer show "Default — Claude Sonnet 4.6". Instead they show
"Default (provider)" / "Default", avoiding confusion when the actual
CLI default differs from the hardcoded catalog entry.
2026-04-29 14:18:01 +02:00
Jiayuan Zhang
80c5bb9e9e feat(views): quick capture continuous creation mode (#1863)
* feat(views): keep quick capture open after submit for continuous creation

After successfully sending a prompt to the agent, the dialog now clears
the editor and stays open instead of closing. This lets users create
multiple issues in quick succession without reopening the dialog each
time. The user can still close manually via X or Escape.

* feat(views): add success feedback for quick capture continuous mode

After each successful submit, the Create button briefly flashes green
with a checkmark "✓ Sent" for 1.5s, then reverts. A persistent counter
("N sent") appears in the footer so the user knows how many prompts
they've dispatched in this session. No explicit mode toggle needed —
the counter implicitly signals continuous mode is active.

* feat(views): add "Create another" toggle to quick capture (Linear-style)

Replace always-on continuous mode with an opt-in toggle switch in the
footer, matching Linear's "Create more" pattern. The preference is
persisted per-workspace via the quick-create store so it remembers
across sessions.

- Toggle OFF (default): submit closes the dialog (original behavior)
- Toggle ON: submit clears the editor and stays open; button flashes
  green "✓ Sent" and a counter shows how many have been dispatched

* fix(views): remove stale breadcrumb identifier test

PR #1872 removed the issue identifier from the breadcrumb but the
corresponding test was not updated, causing CI to fail.
2026-04-29 14:15:14 +02:00
Jiayuan Zhang
6a665c68a3 fix(inbox): improve quick-create notification to show issue title prominently (#1873)
The inbox notification for quick-create showed "Created MUL-1577: <title>"
which truncated the actual issue title. Now the title field shows just the
issue title (the most useful info), and the detail label shows "Created
MUL-XXXX" as context.
2026-04-29 13:54:08 +02:00
Jiayuan Zhang
174b8c62a6 fix(views): remove redundant issue identifier from breadcrumb navigation (#1872)
The issue detail page breadcrumb showed both the issue identifier and
title (e.g. "MUL-1567 Title"), making the ID appear twice. Remove the
standalone identifier span so only the title is displayed.
2026-04-29 13:50:43 +02:00
Jiayuan Zhang
768d3f8b0c feat(ui): make New Issue button open Quick Capture instead of manual form (#1862)
* feat(ui): make New Issue button open Quick Capture instead of manual form

The sidebar "New Issue" button and the search command's "New Issue" action
now open the agent-based Quick Capture dialog directly, matching the
platform's agent-first workflow.

Contextual issue creation (board columns, list view status groups, sub-issues)
still opens the manual form since those pass pre-filled data.

Closes MUL-1558

* test(search): update search-command test to expect quick-create-issue

Aligns the test assertion with the behavior change in the previous
commit where "New Issue" now opens Quick Capture.
2026-04-29 13:48:50 +02:00
Jiayuan Zhang
7dfa72465c feat(quick-create): add file upload button to Quick Capture dialog (#1866)
The agent-mode Quick Capture dialog already supported image paste and
drag-drop through the ContentEditor, but lacked a visible file
attachment button. This made the feature undiscoverable.

Add a FileUploadButton (paperclip icon) to the footer, matching the
pattern already used by the manual create panel and comment input.
2026-04-29 13:48:44 +02:00
Jiayuan Zhang
0b969483a6 fix(quick-create): block submit while image uploads are in progress (#1864)
Without this guard, submitting during an active upload causes
stripBlobUrls to silently remove the in-flight blob image from the
markdown, so the agent never sees the pasted screenshot. Now the Create
button disables and shows "Uploading…" until all file uploads resolve.
2026-04-29 13:48:35 +02:00
Bohan Jiang
e024ab1232 fix(desktop): show git-described version in dev instead of stale 0.1.0 (#1867)
Packaged builds are unaffected: scripts/package.mjs already injects the
git tag into electron-builder's extraMetadata.version, so the .app users
download from GitHub Release reports the right version through
app.getVersion() and the auto-updater's latest.yml comparison works
correctly.

Dev mode (`pnpm dev:desktop`) didn't go through that path though, so
app.getVersion() returned the static "0.1.0" from package.json — the
new Settings → Updates panel surfaced this and made it look like the
dev build was ancient. Add a tiny getAppVersion() helper that falls
back to `git describe --tags --always --dirty` only when !app.isPackaged,
and use it for the app-info IPC. No change to packaged behavior; if git
is unavailable for any reason, we silently fall back to app.getVersion().
2026-04-29 19:18:41 +08:00
Bohan Jiang
f4eb83bd41 feat(desktop): show current version in Updates settings (#1861)
Surface the running app version (from app.getVersion via preload's
appInfo) at the top of Settings → Updates so users have a clear place
to check which build they're on, instead of only seeing it inline after
clicking "Check now".
2026-04-29 19:07:39 +08:00
Jiayuan Zhang
dde42ba84a fix(views): remove Sparkles icon before "Created by" in quick capture dialog (#1859)
Removes the Sparkles icon from the agent picker trigger in the
quick-create-issue dialog, keeping only the "Created by" text label.
2026-04-29 12:51:08 +02:00
Naiyuan Qing
9467a8c616 feat(editor): preserve Markdown source on copy/cut (#1858)
ProseMirror's default clipboardTextSerializer uses Slice.textBetween,
which flattens every node to its inner text. Copying `## 你好` from the
editor only put `你好` on the clipboard's text/plain channel, so pasting
into VS Code, terminals, or messaging apps lost all Markdown markers.

Add a markdown-copy extension symmetric to the existing markdown-paste:
on copy/cut/drag, route the selected Slice through editor.markdown.serialize
to write the Markdown source. The text/html channel is left at ProseMirror's
default so pasting back into another ProseMirror editor still preserves
exact node structure via data-pm-slice.

Registered for both editable and readonly modes — users frequently copy
from rendered comments/issue descriptions.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:47:20 +08:00
Bohan Jiang
cfa38df97b feat(quick-create): gate on daemon CLI version with pre-check + server enforcement (#1857)
* fix(quick-create): bound dialog height + scroll editor when content overflows

Pasting a screenshot into the agent-create prompt expanded the editor
unbounded, which dragged DialogContent past the viewport since the agent
mode className had no max-height. Manual mode was unaffected because
manualDialogContentClass pins `!h-96`.

- Cap agent-mode DialogContent at `!max-h-[80vh]` (width stays
  `!max-w-xl`); short prompts still render compact, tall content stops
  at 80% of the viewport.
- Switch the editor wrapper to `flex-1 min-h-[140px] overflow-y-auto`
  so it absorbs the remaining vertical space inside the now-bounded
  DialogContent and scrolls internally instead of pushing the dialog.

* feat(quick-create): gate on daemon CLI version with pre-check + server enforcement

The agent-create flow depends on multica CLI behavior introduced in
v0.2.20 (URL attachment handling, no-retry semantics on
`multica issue create` failure — see PR #1851 / MUL-1496). Older
daemons either double-create issues on partial CLI failures or
mishandle pasted screenshot URLs. Per J's review on MUL-1496, gate
the flow at two layers — frontend pre-check for fast feedback,
server re-check as the trust boundary, both fail-closed on
missing/unparsable versions.

Server:
- New MinQuickCreateCLIVersion + CheckMinCLIVersion helper in
  pkg/agent (with sentinel errors for missing vs too-old).
- QuickCreateIssue handler reads runtime metadata.cli_version and
  returns a stable 422 { code: "daemon_version_unsupported",
  current_version, min_version, runtime_id } before enqueuing.
- The check runs after the existing online + ownership validation,
  so all rejections surface uniformly through the modal's existing
  error path.

Frontend:
- New @multica/core/runtimes/cli-version with the min version
  constant, parser, and runtime-metadata reader (tiny semver, no
  new lib dep).
- AgentCreatePanel resolves the selected agent's runtime, runs the
  same check, shows an inline amber notice below the agent picker
  when missing/too old, and disables the Create button.
- Submit handler also catches the server's 422 (defensive race —
  runtime can re-register between pre-check and submit) and
  surfaces the same wording in the error row.

Switching to manual create remains a clean escape hatch — manual
mode doesn't talk to a daemon at all, so an outdated CLI doesn't
block the user from filing the issue.
2026-04-29 18:44:19 +08:00
Naiyuan Qing
4ad0a0b847 feat(chat): presence v4 — status pill, failure bubble, elapsed timing (#1856)
A complete UX upgrade for chat sending → receiving → recovering.

* StatusPill replaces the orphan spinner — stage-aware copy
  ("Reading files · 12s", "Searching the web · 14s", "Typing · 24s"),
  shimmer text, monotonic timer, derived effective status, > 60s
  warning tone, > 5min cancel button.

* WS writethrough on task:queued / task:dispatch / task:cancelled so
  pendingTask cache stays in sync with the daemon state machine without
  invalidate-refetch latency. broadcastTaskDispatch now includes
  chat_session_id when the task is for a chat session — the existing
  payload only carried it on the generic task: events, leaving the pill
  stuck at "Queued" until completion.

* Failure fallback — FailTask writes a chat_message tagged with
  failure_reason (mirrors the issue path's system comment, gated on
  retried==nil). Front-end renders an inline note ("Connection failed",
  with a Show details collapsible) instead of the previous black hole.

* Elapsed timing — chat_message.elapsed_ms persists task.completed_at -
  task.created_at on success/failure rows. UI shows "Replied in 38s" /
  "Failed after 12s" beneath assistant bubbles. Format helper shared
  between StatusPill and the persisted caption so the live timer and
  final reading never disagree.

* Optimistic burst rebalanced — pendingTask seed + created_at moved
  before the HTTP roundtrip so the pill appears the instant the user
  hits send; handleStop is fire-and-forget so cancel feels immediate
  (server confirmation arrives via task:cancelled WS).

* Presence integration — chat avatars use ActorAvatar (status dot +
  hover card); OfflineBanner above the input on offline/unstable;
  SessionDropdown shows per-row in-flight/unread pip plus a
  cross-session aggregate pip on the closed trigger.

* Editor blur on send so the caret stops competing with the StatusPill
  / streaming reply for the user's attention.

* Chat panel isOpen now persists globally; defaults to OPEN for new
  users (storage key absence) so the feature is discoverable. Existing
  users' prior choice is respected.

* DB: migrations 062 (failure_reason) + 063 (elapsed_ms), both
  ADD COLUMN NULL — fast, non-blocking, backwards compatible.

* WS: task:failed chat path now invalidates chatKeys.messages — fixes
  a pre-existing bug where the failure bubble required a page refresh
  to appear.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:29:46 +08:00
Bohan Jiang
1fd583ef65 docs(changelog): publish v0.2.20 release notes (#1855)
* docs(changelog): publish v0.2.20 release notes

* docs(changelog): trim v0.2.20 entry and rename headline feature
2026-04-29 18:29:30 +08:00
Bohan Jiang
286ecf04b1 feat(daemon): add WebSocket heartbeat with HTTP fallback
Adds daemon WebSocket heartbeat acknowledgements while preserving HTTP heartbeat fallback and HTTP task claim/result paths. Keeps old daemon compatibility and task wakeup behavior intact.
2026-04-29 17:17:55 +08:00
Bohan Jiang
bd82607645 fix(execenv): default-disable Codex native multi-agent in per-task config (#1845)
* fix(execenv): default-disable Codex native multi-agent in per-task config

Recent Codex app-server releases enable features.multi_agent by default,
exposing spawn_agent / wait / close_agent tools that let a parent thread
spawn nested subagents. The daemon currently models only the parent thread,
so the parent's turn/completed is treated as task completion even when
spawned children are still running — leading to premature task completion
and dropped child output.

Disable features.multi_agent by default in the per-task CODEX_HOME/config.toml
so Multica's task lifecycle is the only orchestration layer in play. Strip
both the dotted-key form (features.multi_agent) at TOML root and the
multi_agent key inside a [features] table; siblings and unrelated tables
are preserved. Honor MULTICA_CODEX_MULTI_AGENT=1 as an opt-out for users
who explicitly want Codex native subagents inside a Multica task.

The user's global ~/.codex/config.toml is never modified — only the daemon's
isolated per-task copy.

Also widen managedBlockRe to consume `\n*` rather than `\n?` so reruns
don't accumulate blank lines when both the sandbox and multi-agent managed
blocks coexist.

* fix(execenv): inject managed multi_agent inside existing [features] table

Per PR review (codex_multi_agent.go:77-83 vs :112-115): when the user's
config.toml already has a top-level `[features]` table, writing
`features.multi_agent = false` at the TOML root implicitly redefines the
same `features` table. The strict TOML parser used by Codex (`toml-rs`)
rejects that with `table 'features' already exists`, so Codex would fail
to load the per-task config and refuse to start the thread. Verified the
strict-parser failure with pelletier/go-toml/v2; the previous
BurntSushi/toml-based regression test was permissive enough to miss it.

Detect a root-level `[features]` header and place the managed block
inside that table (`multi_agent = false` with marker comments). When no
such header exists, keep the existing root-level dotted-key form. The
managed-block regex matches both layouts so reruns and layout
transitions stay idempotent. A `[features.experimental]` sub-table
without a bare `[features]` header still uses the root dotted-key form,
which is spec-valid (no explicit redefinition).

Tests now use pelletier/go-toml/v2 to actually parse the output and
assert features.multi_agent decodes to false; the regression case from
the PR review is covered explicitly.

* fix(execenv): recognize feature table header variants

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 17:17:09 +08:00
devv-eve
365e84b920 fix(execenv): prefer stdin for formatted comment replies (#1851)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 17:12:04 +08:00
Bohan Jiang
86e7de3e41 feat(server/auth): cache auth token lookups in Redis with 10m TTL
* feat(server/auth): cache PAT lookups in Redis with 60s TTL

Personal access tokens used to hit Postgres on every request: a SELECT
to resolve token_hash → user_id, plus a fire-and-forget UPDATE of
last_used_at. For a CLI / daemon making many requests per second this
is wasted DB load — the token is the same and the answer hasn't changed.

Add a Redis-backed cache (auth.PATCache) keyed by token hash, TTL 60s:

- On cache hit, the auth middleware skips both the SELECT and the
  last_used_at UPDATE. last_used_at is now refreshed at most once per
  TTL window per token, not per request.
- On cache miss the middleware falls back to today's behavior: query
  Postgres, populate the cache, async-update last_used_at.
- On revoke, the handler invalidates the cache entry so revocation
  takes effect immediately rather than waiting for the TTL to expire.
  This required changing RevokePersonalAccessToken from :exec to :one
  RETURNING token_hash.

The cache is nil-safe: when REDIS_URL isn't configured, NewPATCache
returns nil and the middleware degrades to today's always-hit-DB
behavior. JWT validation is untouched (already DB-free).

Tested with REDIS_TEST_URL — same gating pattern the rest of the
suite uses for Redis-backed tests. New tests cover nil-safety, set/
get/invalidate, TTL, and the middleware short-circuit on cache hit.

* fix(server/auth): clamp PAT cache TTL to token's remaining lifetime

GPT-Boy review caught: a PAT expiring in <60s would still be cached
for the full PATCacheTTL window, so the token could continue passing
auth on cache hit for up to ~60s after its expires_at. The DB query
filters expired tokens (revoked = FALSE AND expires_at > now()), but
that filter never ran on a cache hit.

Make Set take an explicit ttl, and add TTLForExpiry to compute it:
  - no expires_at      → full PATCacheTTL
  - expires_at far     → full PATCacheTTL
  - expires_at <60s    → time until expiry
  - already expired    → 0, Set skips caching (TOCTOU defense between
                         the SELECT and the Set, since the SELECT
                         already filters expired rows)

Regression test pins the clamp behavior end-to-end against Redis.

* feat(server/auth): cache daemon-token + PAT lookups in DaemonAuth, bump TTL to 10m

Daemon /api/daemon/* requests (heartbeat, claim task) hit DaemonAuth
which previously did its own GetDaemonTokenByHash on every request and
*also* duplicated the PAT lookup on the mul_ fallback — bypassing the
cache added in 1cdd674c. Today's daemons authenticate via mul_ PATs
(mdt_ minting isn't wired up yet), so the duplicate PAT path is the one
that actually matters for hot-path DB load.

Three changes:

1. New auth.DaemonTokenCache mirrors PATCache for the mdt_ path
   (key = mul:auth:daemon:<sha256>, JSON value = {workspace_id, daemon_id}).
   Forward-looking infrastructure for when daemon tokens get minted; the
   middleware short-circuits the DB SELECT on cache hit. TTL clamped to
   the token's expires_at via the shared TTLForExpiry helper.

2. DaemonAuth now also consults PATCache on its mul_ fallback, sharing
   the same cache as the regular Auth middleware. A daemon making 4 hb/min
   collapses from 4 GetPersonalAccessTokenByHash + 4 last_used_at writes
   per minute to ~1 of each per AuthCacheTTL window (~10 minutes).

3. Rename PATCacheTTL → AuthCacheTTL and bump from 60s to 10 minutes.
   The constant is now shared between PAT and daemon caches; 10m matches
   the user-requested longer TTL for further DB write reduction. Revoke
   latency on the happy path is still instant via active invalidation;
   the worst-case (Redis Del miss / direct-DB revoke) grows from ~60s to
   ~10m.

Tests cover nil-safety, set/get/invalidate, TTL, clamped TTL on near-
expiry tokens, and the middleware short-circuit for both cache paths
(mdt_ via DaemonTokenCache, mul_ fallback via PATCache).

* feat(server/auth): cache PAT lookups on the WebSocket auth path

The third place a PAT is resolved — patResolver.ResolveToken used by
realtime.HandleWebSocket — was still hitting Postgres on every /ws
auth and firing an unconditional last_used_at UPDATE, bypassing the
cache added in 1cdd674c. Wire it through the same shared PATCache so
revoking a token through any path (Auth middleware, DaemonAuth PAT
fallback, or WS auth) hits all three caches with one Invalidate.

Also leaves a comment on DeleteDaemonTokensByWorkspaceAndDaemon —
the query has no caller today, but a future deregister/rotate flow
must remember to call DaemonTokenCache.Invalidate(hash) for each
deleted row, otherwise deleted daemon tokens stay valid until TTL.
2026-04-29 17:07:54 +08:00
Bohan Jiang
936ccce8fa fix(comments): unescape \n in agent task-completion output (#1850)
PR #1744 fixed literal `\n\n` rendering for the CLI surfaces (`issue
create / update --description`, `issue comment add --content`) but the
agent-completion path bypasses the CLI entirely: the daemon POSTs the
agent's stdout to `/api/daemon/tasks/:id/complete`, and `TaskService.
CompleteTask` writes `payload.Output` straight into `createAgentComment`
and `CreateChatMessage` without decoding. Models (e.g. Codex) routinely
emit Python/JSON-style `\n` literals in their final output, which then
land in the DB as the 4-char escape sequence and render as one wall of
text in the issue/chat panel — exactly the bug report in #1820.

- Move `unescapeFlagText` from `server/cmd/multica/cmd_issue.go` to
  `server/internal/util/text.go` as `UnescapeBackslashEscapes` so the
  CLI and the service layer share one implementation. The full
  contract-boundary test suite moves with it.
- Apply `UnescapeBackslashEscapes` to `payload.Output` before it
  reaches `createAgentComment` and `CreateChatMessage` in
  `TaskService.CompleteTask`. Same `\n / \r / \t / \\` decoding as the
  CLI; other escape sequences (`\d`, `\w`, `\u`, etc.) pass through
  verbatim so regex/format strings in agent output survive.

Closes #1820
2026-04-29 17:05:17 +08:00
Bohan Jiang
49ccd22027 fix(cli,quick-create): no duplicate issue when --attachment fails post-create (#1849)
Two coordinated fixes for a quick-create case where the agent ended up
creating duplicate issues. Repro: user pasted an image into the
quick-create prompt; the front-end uploaded it and embedded the URL as
markdown in the user input; the agent saw the URL, assumed it was an
attachment, and ran `multica issue create … --attachment "https://…"`.
The CLI POSTed the issue first, then failed to read the URL as a file
(`os.ReadFile("https://…")`) and exited 1. The agent treated exit 1 as
"create failed" and retried — but the first issue already existed, so
the workspace ended up with two of them.

CLI (`server/cmd/multica/cmd_issue.go`):
- `runIssueCreate` pre-validates `--attachment` BEFORE POSTing. URLs are
  warned about and skipped (they are never local files); local-path
  read errors fail before the issue is created so no half-baked issue
  lands. Once the POST succeeds, post-create upload failures only
  print a stderr warning and the issue metadata is still emitted —
  never a non-zero exit, so callers cannot mistake "attachment upload
  hiccup" for "create failed" and retry.
- `runIssueCommentAdd` already uploads attachments BEFORE the comment
  is created, so its failure mode is fine; it just gets the same
  URL-skip behaviour for consistency.

Quick-create prompt (`buildQuickCreatePrompt`):
- Tells the agent NOT to pass `--attachment` for prompt-embedded image
  URLs (they are already part of the description as markdown).
- Hardens the "no retry" rule: even on a non-zero exit, do not retry
  `issue create` — the issue may already exist.
2026-04-29 17:00:41 +08:00
Naiyuan Qing
e66bd593ea feat(web): add editorial 404 page (#1844)
Custom Next.js root not-found.tsx with cream/ink/terracotta editorial
palette and Instrument Serif hero. Replaces the bare default 404 on any
unmatched URL. Single CTA back to /, which routes appropriately based
on auth state.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:53:01 +08:00
Bohan Jiang
7528022355 fix(quick-create): bound dialog height + scroll editor when content overflows (#1847)
Pasting a screenshot into the agent-create prompt expanded the editor
unbounded, which dragged DialogContent past the viewport since the agent
mode className had no max-height. Manual mode was unaffected because
manualDialogContentClass pins `!h-96`.

- Cap agent-mode DialogContent at `!max-h-[80vh]` (width stays
  `!max-w-xl`); short prompts still render compact, tall content stops
  at 80% of the viewport.
- Switch the editor wrapper to `flex-1 min-h-[140px] overflow-y-auto`
  so it absorbs the remaining vertical space inside the now-bounded
  DialogContent and scrolls internally instead of pushing the dialog.
2026-04-29 16:49:56 +08:00
Prince Pal
391a4ecd09 feat: add backend default agent args env vars (#1807)
* feat: add backend default agent args env vars

* docs: document default agent args env vars
2026-04-29 16:49:48 +08:00
Bohan Jiang
54d895a210 fix(execenv): mandate comment-history read on assignment-triggered runs (#1843)
GitHub #1839: when an issue is reassigned from agent A to agent B, B
often only reads the issue body and misses context A added in comments
(e.g. which repo to clone). The assignment-triggered workflow injected
into CLAUDE.md / AGENTS.md said "Read comments for additional context
or human instructions" — vague enough that agents routinely skipped
it. The comment-triggered branch already gives an explicit
`multica issue comment list` invocation, so behavior diverged.

Promote step 3 to a concrete CLI call, mark it mandatory, and surface
the most common failure mode (stale instructions on reassignment) so
the agent recognizes when it matters. Reorder so comments are read
*before* flipping status to `in_progress`, matching how a human would
catch up on a thread before claiming work.
2026-04-29 16:38:27 +08:00
Bohan Jiang
40a984c997 feat(quick-create): default assignee to picker agent when user didn't name one (#1836)
* feat(quick-create): default assignee to picker agent when user didn't name one

The quick-create prompt previously told the agent to OMIT --assignee
when the user's input didn't name a person. That left almost every
quick-created issue unassigned, which doesn't match user intent — the
user opened quick-create with a specific agent picked, so that agent
is the obvious owner.

Both prompt surfaces (BuildPrompt for the dispatched message, plus
the workflow block in injected CLAUDE.md / AGENTS.md) now instruct
the agent: if the input doesn't name an assignee, pass
`--assignee "<your name>"`. The picker agent's name is interpolated
into the prompt at task-build time so the agent has a literal value
to use rather than guessing its own name. The "explicitly named
assignee → resolve via members" branch is unchanged.

* refactor(execenv): drop duplicated quick-create field rules from CLAUDE.md/AGENTS.md

The quick-create field rules (title / description / priority / assignee
fallback / project / status) lived in two places — the per-turn user
message built by BuildPrompt, and the workflow block injected into
CLAUDE.md / AGENTS.md by buildMetaSkillContent. Same content, two
sources, easy to update one and forget the other (the assignee-default
change in this PR had to touch both).

Quick-create is one-shot, so the per-turn user message is always
present and is the natural single source of truth. The injected
file's quick-create section now keeps only the hard guardrails:
"do exactly one issue create, no issue get / status / comment add,
exit on CLI error". Those guardrails stay in BOTH surfaces because
they're the safety net for providers that don't propagate the user
message into resumed-session context.

renderQuickCreateContext (issue_context.md) was already
guardrails-only — no change needed there.
2026-04-29 16:08:08 +08:00
Bohan Jiang
9ccaf18479 fix(comment): don't inherit parent @mentions from agent-authored roots (#1833)
* fix(comment): don't inherit parent @mentions when parent author is an agent

When an agent posts a comment that @mentions another agent (typically a
one-shot delegation, e.g. a PR-completion comment that asks a reviewer
agent to review), member follow-up replies in the same thread were
auto-inheriting that mention and re-triggering the reviewer on every
plain question. Same root cause: the inheritance branch only required
the reply to have no mentions, not that the parent was member-authored.

Tighten the guard: only inherit when the parent (thread root) is
authored by a member. Member-rooted threads still inherit so a member
who started by @mentioning an agent can keep replying without re-typing.
Agent-authored roots are treated as one-shot — explicit @mentions in
later comments still trigger normally.

Extracted the decision into shouldInheritParentMentions for direct unit
testing, and added an end-to-end regression
(TestMemberReplyToAgentRootDoesNotInheritParentMentions) that reproduces
MUL-1535: J posts a PR completion @mentioning Reviewer; a member's
plain follow-up must not re-enqueue Reviewer.

* chore(comment): gofmt trigger_test.go
2026-04-29 15:54:24 +08:00
Bohan Jiang
866b901943 fix(desktop): use themed Toaster wrapper instead of bare sonner (#1835)
#1831 fixed the Toaster wrapper to follow next-themes' resolvedTheme,
but the desktop renderer was importing `Toaster` directly from `sonner`
and never going through the wrapper. So the success toast still rendered
light on a dark UI. Switch the import to `@multica/ui/components/ui/sonner`
to match the web app and pick up the theme + icon overrides.
2026-04-29 15:53:51 +08:00
Bohan Jiang
9baa72cc68 fix: polish quick-create UX (kind labeling, dark toast, placeholder) (#1831)
* fix: polish quick-create UX (kind labeling, dark toast, placeholder)

Three small fixes shaken out from using the agent-create flow:

- AgentTaskResponse now carries a `kind` discriminator
  ("comment" | "autopilot" | "chat" | "quick_create" | "direct"), computed
  from the existing FK shape with no extra DB access. The Activity row
  uses it to label quick-create tasks as "Creating issue" instead of
  falling through to the generic "Untracked" — once the agent finishes
  and the new issue is linked, the row transitions to the normal
  identifier+title display.

- Sonner Toaster reads `resolvedTheme` instead of `theme`, so toasts
  follow the actual dark/light state. Forwarding "system" let sonner
  pick its own answer from `prefers-color-scheme`, which in the Electron
  renderer can disagree with next-themes' `html.dark` class — the toast
  rendered light on a dark UI.

- Agent-create placeholder rephrased to a more conversational example
  with a project reference: "let Bohan fix the inbox loading slowness
  in the Web project". Drops the priority hint (priority isn't widely
  used) and matches how people actually instruct the agent.

* fix(quick-create): link new issue back to task on completion

Addresses the review on PR #1831: completed quick-create tasks were
left with issue_id=NULL forever, so the activity row stayed on
"Creating issue" instead of transitioning to the normal MUL-XXX +
title rendering once the agent finished.

- Server: notifyQuickCreateCompleted now writes the resolved issue id
  back to agent_task_queue.issue_id via a new LinkTaskToIssue query
  (guarded by `issue_id IS NULL` so it only ever fills the unset
  quick-create case). Best-effort: a write failure logs but doesn't
  block the inbox notification.
- Frontend: defensive wording fallback — kind=quick_create rows in
  terminal status (completed/failed/cancelled) now render as
  "Quick create" instead of the active "Creating issue" label,
  covering rows whose link write failed or whose agent never
  produced an issue at all.
2026-04-29 15:40:59 +08:00
Bohan Jiang
576304519b docs(execenv): expose label/subscriber CLI + complete create/update flag list (#1830)
The agent-facing CLAUDE.md/AGENTS.md injected by InjectRuntimeConfig was
missing every doorway to non-core issue properties:

- `multica issue label list/add/remove` — the only way to label a newly
  created issue from the agent. Without it, agents either give up
  ("no command for that, please add it manually") or hallucinate flag
  names like `multica issue create --label foo` and fail.
- `multica issue subscriber list/add/remove` — same story for the
  subscribe-on-behalf flow.
- `multica label list/create` — agents need to discover existing label
  ids before they can attach one (we don't auto-create labels here).
- `issue create` flag list dropped `--project`, `--due-date`,
  `--attachment` even though the CLI has supported them for a while.
- `issue update` flag list dropped `--status`, `--assignee`,
  `--project`, `--due-date`, `--parent`, leaving agents thinking they
  could only edit title/description/priority via update.

Also splits `issue status` from `issue update` in the doc so the agent
sees the shortcut, and notes the `issue create` body intentionally
does NOT accept labels/subscribers (use the post-create commands).
2026-04-29 15:29:03 +08:00
Naiyuan Qing
f0a3f5ddeb chore(docs): remove shipped agent-runtime redesign + workspace audit docs (#1829)
These were transitional handoff/design docs that fulfilled their purpose:

- docs/agent-runtime-status-redesign.md (802 lines) — design + plan for
  PR #1794 (presence v3, availability + last-task split). Shipped.
- docs/agent-runtime-ui-design-brief.md (530 lines) — paired designer
  brief for the same redesign. Shipped.
- HANDOFF_ARCHITECTURE_AUDIT.md (383 lines) — 4-task audit packaged for
  the workspace URL refactor (PR #1138/#1141). The URL refactor itself
  shipped; the other tasks are either resolved or live in code as the
  source of truth. File:line snapshots inside have rotted.

Follows the precedent set by #1504 (chore(docs): remove shipped plan and
proposal docs). Code is the source of truth once the work is in.
2026-04-29 15:16:54 +08:00
Bohan Jiang
22136a55fc fix(server/heartbeat): split auth_ms into decode/runtime_lookup/workspace_check + auth_path (#1822)
Prod slow-log on the deployed v0.2.17 fix shows total_ms=4012,
auth_ms=4010, update_ms=1, all skill stages = 0 — meaning the bottleneck
on /api/daemon/heartbeat is now the auth section, not the Redis claim
path. To pinpoint which sub-stage dominates, decompose auth_ms into:

- decode_ms        — JSON body decode
- runtime_lookup_ms — Queries.GetAgentRuntime (PG PK select)
- workspace_check_ms — requireDaemonWorkspaceAccess (string compare for
                       daemon-token, requireWorkspaceMember for PAT/JWT)

Also add auth_path ("daemon_token" | "pat" | "jwt") set by DaemonAuth
middleware so slow-logs disambiguate which token kind was used. PAT/JWT
takes an extra DB round-trip via requireWorkspaceMember and is a
candidate cause of long auth tails on daemons that haven't migrated to
mdt_ tokens.

The handler keeps the same external behavior; the change inlines and
instruments requireDaemonRuntimeAccess in DaemonHeartbeat only — other
callers of the helper are untouched. logHeartbeatEndpointSlow gains the
new fields.

Existing heartbeat tests pass; the slow-probe test output now shows the
new auth_path / decode_ms / runtime_lookup_ms / workspace_check_ms
fields populated.
2026-04-29 15:00:00 +08:00
Bohan Jiang
375534573c feat(editor): rank mention dropdown by per-device recency (#1825)
Members and agents previously appeared in fixed buckets (members first,
then agents) following raw cache order. Replace that with a single ranked
list driven by the user's most recent mentions on this device, with an
alphabetical fallback for never-mentioned targets. Recency is stored in
localStorage per workspace and lazy-pruned at 200 entries.
2026-04-29 14:58:47 +08:00
Bohan Jiang
2a59236575 refactor(create-issue): unify agent/manual modes under one Dialog shell (#1826)
Recasts Quick/Advanced as Agent/Manual and lets users flip between modes
in-place from a footer switch button instead of a separate Advanced
shortcut. The two old modal types now route through one CreateIssueDialog
shell that owns the single <Dialog> and <DialogContent> — only the inner
panel body swaps on mode change, so the Portal/Backdrop/Popup stay
mounted and the switch is instant (no close→open animation flash).

Mode preference is persisted globally in localStorage via a small
useCreateModeStore, so the `c` shortcut always opens whichever mode the
user last used (or switched to). Carry payload (description / agent /
prompt) hands off through the shell's local state plus the existing
issue-draft store, so nothing the user typed is lost across switches.

Also drops the Shift+C → manual branch — `c` is now mode-agnostic and
the in-modal switch covers the same intent without users having to
remember a second shortcut.

Visible labels: "Quick create" → "Create with agent",
"New issue" → "Create manually".
2026-04-29 14:57:36 +08:00
Bohan Jiang
415060e6be fix(quick-create): unstick queued tasks (workspace resolution + WS wakeup) (#1827)
Two related bugs that combined to leave every quick-create task in
'queued' from the user's POV:

1. ResolveTaskWorkspaceID returned "" for any task whose
   issue_id / chat_session_id / autopilot_run_id were all NULL —
   exactly the shape of a quick-create task. That made
   requireDaemonTaskAccess 404 on the daemon's /start, /progress,
   /complete, /fail endpoints, and silently dropped task:dispatch /
   task:completed broadcasts. Even when the claim itself succeeded,
   the daemon couldn't drive the task forward, so it stalled and
   eventually got swept back. Read the workspace from the
   QuickCreateContext JSONB so every downstream lookup works.

2. EnqueueQuickCreateTask never called notifyTaskAvailable, so the
   daemon WS wakeup never fired for quick-create. The 30 s poll
   fallback would eventually pick the task up, but combined with #1
   that meant the task spent the bulk of its life looking like
   "queued, never triggered". Match the chat / issue / autopilot
   enqueue paths and signal the wakeup.
2026-04-29 14:57:08 +08:00
Naiyuan Qing
f745a3bbbe feat(agent): presence v3 + execution log + trigger summary (#1823)
* refactor(views): migrate agent/runtime/skill lists to TanStack DataTable

Replace the per-page CSS Grid + minmax(min, fr) + sticky-first-col + truncate
implementation with a TanStack Table backend rendered through a Dice UI-style
DataTable shell. Column widths are now px-based via column.size, so cells
no longer shrink or auto-truncate as the viewport narrows; when the sum of
columns exceeds the viewport, the container scrolls horizontally instead.

- Add @tanstack/react-table to the catalog (8.21.3) and wire it into
  packages/ui (dep) and packages/views (peerDep).
- packages/ui: new DataTable + DataTableColumnHeader + lib/data-table.ts
  (getColumnPinningStyle), adapted from Dice UI's registry. The shell
  renders <table> directly (skipping shadcn's <Table> wrapper) so its own
  outer overflow controls both axes — no nested overflow conflicts.
- packages/views: each list now declares ColumnDef[] with explicit
  cell renderers. Row click navigates to detail via onRowClick (instead of
  wrapping <tr> in <a>, which is invalid HTML); kebab dropdowns
  stopPropagation so they don't trigger the row navigation.
- Drop the previous AGENT_LIST_GRID / GRID_WITH_OWNER / ROW_GRID
  templates and the sticky-first-col / subgrid mechanics that came with
  them. agent-list-item.tsx is removed; runtime-list.tsx and
  skills-page.tsx are trimmed to thin wrappers.

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

* feat(agent): cap description at 255 chars (db + api + ui)

Symmetric enforcement across DB, server, and UI:

- Migration 060: pre-flight truncate of any oversize rows, then ADD
  CONSTRAINT NOT VALID + VALIDATE CONSTRAINT so the new check doesn't
  block writes during validation.
- Server handler validates utf8.RuneCountInString on Create/Update and
  rejects over-limit input with 400.
- Front-end gets AGENT_DESCRIPTION_MAX_LENGTH in core/agents/constants
  (single source of truth shared by the create dialog + edit modal +
  test suite) and a CharCounter component that warns at 90% and errors
  past the cap.
- Description editor moves from a 288px popover to a roomy modal.
  Editor body is mounted only while the dialog is open, so the local
  draft state is locked in at mount time and never reset by an external
  WS update — the React-recommended replacement for the
  useEffect(reset, [value]) anti-pattern.

Counted in code points everywhere (rune count / spread length /
char_length) so multibyte input agrees across all three layers.

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

* refactor(views): data-table polish across runtime + skill lists

Builds on the DataTable migration in 2be0f287:

- Add ColumnMeta.grow flag — declared via TanStack module augmentation
  in ui/lib/data-table.ts. Columns marked meta.grow skip their inline
  width so fixed table-layout assigns them the leftover container space
  (no spacer column). The Title-grows / others-fixed pattern from
  Linear / GitHub PR rows.
- Authoritative table min-width = sum of column.size, applied to the
  <table> itself (fixed-layout ignores cell-level min-width per spec,
  so the floor has to live on the table).
- Header tightens to h-8 + uppercase + tracking-wider; pinned cells
  switch to opaque bg + group-hover so they cover content scrolling
  beneath them and follow row hover state.
- Toolbar slot removed from DataTable (callers wrap the toolbar
  themselves now — keeps DataTable single-purpose).

Also: hover-card popup stops contextmenu / auxclick / dblclick from
bubbling out (in addition to click). Stops the popup from triggering
ancestor handlers (e.g. issue list rows) on right-click / middle-click
without breaking Base UI's outside-click dismiss, which listens to
pointerdown — pointerdown is deliberately NOT stopped.

Runtime + skill list pages updated to use the new sizing model.

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

* refactor(agent): drop LastTaskState, introduce 3-state Workload

Continues the presence-model rework started in #1794 / #1798.

The previous LastTaskState union (running / completed / failed /
cancelled / idle) carried historical outcome at the list level — a
runtime-healthy agent whose last task failed showed a sticky red dot
indistinguishable from a daemon-dead agent.

New model: presence is two orthogonal "right-now" dimensions:

  AgentAvailability — runtime reachability only (online / unstable /
                      offline). Drives the dot colour everywhere.
  Workload          — current load (working / queued / idle). Three
                      states, never historical. Failure / completion /
                      cancellation are surfaced via Recent Work + Inbox,
                      not list-level state.

`queued` (= nothing running, ≥1 queued) is an honest "stuck on offline
runtime" signal. To avoid amber flashes during the brief enqueue→claim
race on healthy runtimes, the queued chip composes with availability:
muted on online, warning amber otherwise.

Activity tab cleanup that follows from the new model:
  - failureReasonLabel relocated from agents/presence.ts to
    tabs/task-failure.ts (presence no longer owns historical state).
  - Recent Work paginates (5 initial, +20 per "Show more"); chat-session
    tasks are filtered out of every Agent-scoped surface to keep
    "team work" separate from private chat.
  - Agents page drops the lastTaskFilter chip group; users find broken
    agents via Inbox / Recent Work, not a list-level filter.

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

* feat(task): trigger summary snapshot + task:queued lifecycle event

Two task-lifecycle improvements that ship together because they share
the same enqueue/retry hot paths and changes interleave inside task.go:

1. trigger_summary snapshot (migration 061)

   New nullable column on agent_task_queue. Comment-triggered tasks
   snapshot the comment content; autopilot tasks snapshot the run title.
   Truncated to 200 runes via strings.Builder so multibyte input counts
   correctly without O(N²) concatenation. Snapshot survives source
   edits/deletes — every task row self-describes across surfaces (issue
   detail Execution log, agent activity tooltip, inbox) without joining
   back to the originating row.

   Retry rows inherit the parent's snapshot (CreateRetryTask SELECT) so
   the description stays meaningful across attempts. The UI is
   responsible for stacking "Retry #N" context on top.

2. task:queued WS event

   New protocol event covering the ∅ → queued transition. Front-end
   types/events.ts registers it; use-realtime-sync's task: prefix path
   already invalidates task caches via onAny, so old clients without
   this exact-match subscription still refresh correctly. Specific
   subscribers (sticky banner) get sub-second updates instead of
   waiting for daemon claim.

   Retry path now broadcasts task:queued (not task:dispatch) — same
   status transition shape as enqueue, so all "new task created" paths
   agree on one event type.

   Ordering: broadcastTaskEvent runs *before* notifyTaskAvailable so
   the queued event is published into the WS bus before the daemon is
   poked. Without this, a fast daemon could claim and emit task:dispatch
   over the wire before the in-process queued broadcast fan-out reached
   clients — race window is tiny but unsafe-by-construction.

   Per-agent task list (agentTasksKeys.all) and per-issue task list
   (["issues","tasks"]) added to the task: invalidation set so Activity
   tab Recent Work and the Execution log section stay fresh.

Type contracts: AgentTask gains parent_task_id / attempt /
trigger_comment_id (already returned by the API, just missing from TS)
plus the new trigger_summary field.

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

* feat(issue): ExecutionLogSection — unified active+past runs panel

Replaces two pieces:
  - the click-to-expand timeline that lived inside AgentLiveCard
  - the standalone TaskRunHistory below the main content

with a single right-panel section that lists every agent run for the
issue. Active runs sit at the top (always visible when present); past
runs collapse behind a "Show past runs (N)" toggle, sorted failed →
cancelled → completed within group.

Active rows show the trigger summary, status + relative time, and
Cancel / Transcript actions on hover (gradient backdrop fades the
status text rather than hard-clipping). Past rows show the same
shape minus Cancel.

Retry tasks prepend "Retry #N · " to the inherited summary so they're
distinguishable from their parent (which would otherwise share the
exact same trigger text).

Cache key registered as issueKeys.tasks(issueId); the global
useRealtimeSync task: prefix path already invalidates ["issues","tasks"]
on every task lifecycle event, so the section stays fresh without
local WS subscriptions.

AgentLiveCard slims down to a header-only "agent is working" sticky
banner — keeps the at-a-glance "is anyone working on this right now"
signal and the Stop / Transcript actions, drops the inline timeline
that ExecutionLogSection now owns. Subscribes to both task:queued and
task:dispatch so retries (which only emit queued) land in the banner
without waiting for daemon claim.

issue-detail mounts ExecutionLogSection in the right panel and removes
the now-defunct TaskRunHistory call site.

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-29 14:50:58 +08:00
Bohan Jiang
a475c17283 fix(views): drop disableHoverCard from QuickCreate modal ActorAvatars (#1818)
The ActorAvatar prop was renamed in #1794 (split presence into
availability + last-task) — `disableHoverCard` is now `enableHoverCard`
with inverted semantics. The QuickCreate modal landed against the old
API and broke main's frontend typecheck. The two avatars in the modal
already want the default (no hover card), so just drop the prop
instead of opting in.
2026-04-29 14:16:34 +08:00
Bohan Jiang
e4103f6ad7 fix(execenv): strip [[skills.config]] from per-task codex config.toml (#1816)
Codex Desktop writes one [[skills.config]] entry per known skill into
~/.codex/config.toml. File-backed entries get path = "...", but
plugin-backed entries (e.g. name = "superpowers:brainstorming") only get
a name. Codex CLI 0.114's TOML deserializer treats path as required, so
it rejects the plugin entries with "missing field path" and fails
thread/start.

The daemon copies ~/.codex/config.toml verbatim into each task's
isolated codex-home, which propagated those broken entries into the
per-task config and blocked every Codex agent run for affected users.

Strip the whole [[skills.config]] array on copy. Multica writes the
agent's currently assigned skills directly to codex-home/skills/ and
Codex auto-discovers them from there, so the user-level skill registry
is redundant for a per-task run.

Closes #1753
2026-04-29 14:06:29 +08:00
Bohan Jiang
2d9c153695 feat: quick-create issue (async agent + inbox completion) (#1786)
* feat(server): add quick-create issue async task path

Adds POST /api/issues/quick-create which validates the picked agent's
reachability up front (not archived, has runtime, runtime online) then
queues an issue-less agent task whose context JSONB carries the user's
natural-language prompt + requester + workspace. Daemon claim resolves
the workspace from the context, and the prompt builder switches to a
quick-create template instructing the agent to translate the prompt
into a single multica issue create call.

Task completion writes a success inbox item to the requester pointing at
the newly-created issue (located by querying the agent's most recent
issue in the workspace since task start, so we don't depend on agent
stdout shape). Failures write an action_required inbox item carrying the
original prompt + agent id so the frontend can offer "Edit as advanced
form" without losing input.

* feat(views): quick-create issue modal + inbox failure CTA

Adds a streamlined create-issue UI bound to the c shortcut: pick an
agent, type one line, submit. The modal closes immediately and the
agent translates the prompt into a multica issue create call in the
background. Shift+c keeps the legacy advanced form for users who want
every field. The "Advanced" button inside the new modal seeds the
shared issue-draft store with the prompt + picked agent so switching
mid-flow doesn't lose input.

Last-used agent persists per (user, workspace) via a workspace-aware
zustand store so frequent users skip the picker on every open.

Inbox renders quick_create_done items with a status pin to the new
issue and quick_create_failed items with an "Edit as advanced form"
CTA that re-seeds the legacy modal with the original prompt.

ApiError now carries the parsed JSON body so the modal can branch on
the structured agent_unavailable code without parsing the error
message.

* fix(quick-create): execenv injection, claim race, private-agent permission

Addresses GPT-Boy review on #1786:

1. execenv was rendering the assignment-task issue_context.md / runtime
   workflow even for quick-create, telling the agent to call
   `multica issue get/status/comment add` against an empty IssueID.
   Adds QuickCreatePrompt to TaskContextForEnv, plus a quick-create
   branch in renderIssueContext + the runtime_config workflow that
   instructs the agent to run a single `multica issue create` and
   exit, with explicit "do NOT call issue get/status/comment add"
   guards.

2. ClaimAgentTask serialized only on issue_id / chat_session_id, so
   concurrent quick-creates on the same agent (both NULL on those
   columns) ran in parallel — making the success-inbox lookup race
   over "most recent issue by this agent". Adds a third OR clause
   that treats "all four FKs NULL" as a serialization key for the
   same agent, so quick-create tasks on a given agent run one at a
   time.

3. QuickCreateIssue handler bypassed the private-agent ownership rule
   that validateAssigneePair enforces elsewhere — a user could POST a
   private agent_id they didn't own and trigger it. Now routes the
   picked agent through validateAssigneePair before the runtime
   liveness check.

4. Clarifies the quick-create-store namespacing comment to match the
   actual workspace-aware StateStorage convention used by the other
   issue stores (per-user is browser-profile-local).

* fix(quick-create): branch Output section + deterministic origin lookup

Addresses GPT-Boy's second-pass review on #1786:

1. The runtime_config.go Output section forced "Final results MUST be
   delivered via multica issue comment add" for every non-autopilot
   task — quick-create still got this conflicting instruction even
   though there's no issue to comment on. Switched the Output block
   to a three-way switch so quick-create gets a tailored "stdout is
   captured automatically; do NOT call comment add" branch matching
   the autopilot variant.

2. Completion lookup was "most recent issue created by this agent
   since task.started_at", which races against concurrent issue
   creates by the same agent (assignment task running alongside
   quick-create when max_concurrent_tasks > 1). Replaced with a
   deterministic origin link:

   - Migration 060 extends issue.origin_type CHECK to allow
     'quick_create'.
   - Daemon sets MULTICA_QUICK_CREATE_TASK_ID env var when running a
     quick-create task.
   - multica issue create CLI reads the env var and stamps the new
     issue with origin_type=quick_create + origin_id=<task_id>.
   - Server CreateIssue handler accepts (origin_type, origin_id)
     from trusted callers (only "quick_create" is allowed; the pair
     is rejected unless both fields are provided together).
   - notifyQuickCreateCompleted now calls GetIssueByOrigin keyed on
     (workspace_id, "quick_create", task.ID) — no more time-window
     racing against parallel agent activity.

The old GetRecentIssueByCreatorSince query is removed.
2026-04-29 14:05:26 +08:00
carmake
805071b5b1 fix(agent/cursor): route Windows launcher through PowerShell -File to preserve multi-line prompts (#1709)
On Windows the official cursor-agent installer ships cursor-agent.cmd whose
body is `powershell ... -File cursor-agent.ps1 %*`. CreateProcess for a .cmd
file goes through cmd.exe, and `%*` in a batch file is expanded by
re-tokenising the original command line, which mangles arguments containing
newlines or other whitespace - most notably a long, multi-line `-p <prompt>`.
The agent then only sees a truncated prompt and fails with "Workspace Trust
Required" or exits 1 immediately.

When LookPath resolves cursor-agent to a .cmd/.bat launcher and a sibling
cursor-agent.ps1 exists, invoke PowerShell directly with `-File <ps1>` so
Go's os/exec passes each argv as a discrete token. This is exactly what the
.cmd does internally; we just skip the cmd.exe re-tokenisation step.
PowerShell host resolution prefers pwsh.exe (PS 7) on PATH, then
powershell.exe on PATH, and finally falls back to
%SystemRoot%\System32\WindowsPowerShell\v1.0.

Platform-specific code is split via build tags
(cursor_invocation_windows.go / cursor_invocation_other.go) so non-Windows
builds carry no Windows-only dependencies. The lookup is exposed as a
package variable to make the Windows path fully unit-testable without
spawning real PowerShell. Five unit tests cover: passthrough on non-launcher
targets, successful rewrite with a multi-line prompt, .exe direct launch
(skip), missing .ps1 (skip), and missing PowerShell host (skip).

The change leaves macOS / Linux behaviour entirely untouched and stays on
the official cursor-agent launch chain - no node.exe direct invocation, no
prompt mutation, no extra flags.

Closes #1297

Made-with: Cursor
2026-04-29 14:00:15 +08:00
Naiyuan Qing
f0c845b777 fix: popover click bubble + resilient presence loading (#1798)
* fix(popover): stop click bubble + resilient presence loading

Two related bugs surfacing on production after #1794:

* Click-through: clicking a Detail link inside an agent hover card, or
  a kebab item in agents/runtimes list rows, also fired the parent row
  link's onClick. Base UI portals popovers in the DOM but React's
  synthetic events still bubble through the React tree, so the
  ancestor <a> wrapping the trigger still received the click. Fix at
  the primitive level (HoverCardContent + DropdownMenuContent) so
  every existing and future popover gets it for free — stopPropagation
  on the popup's onClick, then forward consumer-supplied handlers.

* Presence loading forever: useAgentPresenceDetail returned "loading"
  whenever any of its three queries had data === undefined. With prod
  backend missing the new agent-task-snapshot endpoint (404), or with
  an issue assignee referencing an archived agent (not in ListAgents),
  the UI spun forever. Now: query errors degrade to empty arrays, and
  a missing agent yields a synthesised offline+idle detail. The dot
  still renders gray, hover card still shows "Agent unavailable" —
  but no infinite skeleton.

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

* feat(inbox): enable hover card on notification actor avatar

Originally excluded from the hover-card opt-in pass, but inbox
notifications are exactly the kind of "who sent me this?" surface
where seeing the actor profile on dwell is useful. Click-through to
the wrong target is no longer a concern — the popover stop-bubble
fix in this branch handles it.

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

* feat(autopilot): show agent presence dot on autopilots list rows

Autopilot detail / picker / dialog already render the dot — the list
was the lone holdout. With the autopilot-agent dependency this strong
("autopilot is dead if its agent is offline"), an at-a-glance dot is
the most useful signal in the row.

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-28 19:54:18 +08:00
devv-eve
9587a577e2 fix: guide codex multiline comments (#1795)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-28 19:33:45 +08:00
Naiyuan Qing
21e3cfaa01 Agent runtime status redesign: split presence into availability + last-task (#1794)
* feat(agent-status): add workspace live-tasks endpoint and TaskFailureReason type

Lays the API + type contract for the front-end agent presence cache:

- New `GET /api/active-tasks` returns active (queued/dispatched/running)
  tasks plus failed tasks within the last 2 minutes for the current
  workspace. The 2-minute window powers a UI-side auto-clearing "Failed"
  agent state without back-end pollers.
- `agent_task_queue` has no workspace_id column, so the query JOINs agent;
  `SELECT atq.*` keeps `failure_reason` (migration 055) on the wire.
- Adds `TaskFailureReason` to `AgentTask` so the UI can map the 5 backend
  classifiers (agent_error / timeout / runtime_offline / runtime_recovery
  / manual) to copy without parsing free-text errors.
- New `api.getActiveTasksForWorkspace()` client method; workspace is
  resolved server-side from the X-Workspace-Slug header (no path param,
  matching /api/agents and /api/runtimes conventions).

Includes the joint engineering plan and designer brief that scope the
broader Agent / Runtime status redesign — Phase 0 is this contract plus
the front-end derivation layer landing in the next commit.

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

* feat(agent-status): derive presence/health states with WS sync and desktop IPC bridge

Adds the front-end derivation layer that turns raw server data into the
user-facing 5-state agent / 4-state runtime enums. UI files are
deliberately untouched in this commit — derivation lives behind hooks
(useAgentPresence, useRuntimeHealth) that any component can call with
zero additional network traffic.

Architecture:
- Derivation is pure functions in packages/core/{agents,runtimes}; the
  back-end stays free of UI translation. Agents algorithm: runtime
  offline > recent failed (2-min window) > running > queued > available.
  Runtimes algorithm: status + last_seen_at -> online / recently_lost /
  offline / about_to_gc.
- A single workspace-wide active-tasks query backs all per-agent
  presence reads, eliminating N+1 across hover cards, list rows, and
  pickers. 30-second tick re-renders the hooks so the failed window
  expires even when no underlying data changes.
- WS task lifecycle events (dispatch / completed / failed / cancelled)
  invalidate active-tasks via the prefix dispatcher. completed/failed
  were removed from specificEvents so they go through both the prefix
  invalidate and the existing chat ws.on() handlers. Reconnect refetch
  picks up active-tasks too.
- Desktop bridges window.daemonAPI.onStatusChange directly into the
  runtimes cache via setQueryData, giving the local daemon sub-second
  feedback (vs. 75s server sweep). Bridge is wsId-bound so workspace
  switches automatically rebind the subscription; daemon_id matching
  covers the same-daemon-multiple-providers case.

24 derivation unit tests cover all branches plus null/empty/boundary
inputs (FAILED_WINDOW_MS edges, null last_seen_at, missing
completed_at). Full core suite: 112 tests passing. Typecheck green
across all 8 workspace packages.

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

* feat(agent-status): redesign agent runtime status as two orthogonal dimensions

Splits the conflated 5-state agent presence into two independent axes:

- AgentAvailability (3-state): online / unstable / offline — drives the
  dot indicator everywhere a dot appears. Pure runtime reachability;
  never sticky-red because of a past task outcome.

- LastTaskState (5-state): running / completed / failed / cancelled /
  idle — surfaced as text + icon on focused surfaces (hover card,
  agent detail page, agents list, runtime detail). Never colours the dot.

Major changes:

* Domain layer: AgentPresence union → AgentAvailability + LastTaskState.
  derive-presence split into deriveAgentAvailability + deriveLastTaskState
  + deriveAgentPresenceDetail orchestrator. Tests reorganised into three
  groups (availability invariants, last-task invariants, composition).

* Visual config: presenceConfig (5 entries) → availabilityConfig (3) +
  taskStateConfig (5). availabilityOrder + lastTaskOrder for filter chips.

* Workspace-level presence prefetch: new useWorkspacePresencePrefetch
  hook + WorkspacePresencePrefetch mount component, wired into
  DashboardLayout (web) and WorkspaceRouteLayout (desktop). Hover cards
  render synchronously with no skeleton flash on first hover.

* ActorAvatar hover: flipped default — disableHoverCard removed,
  enableHoverCard added (default false). Opt-in at ~14 decision-moment
  surfaces; pickers / decoration sub-chips stay plain. Status dot
  decoupled (showStatusDot prop) so picker rows can show presence
  without nesting popovers.

* Hover cards: AgentProfileCard simplified — availability dot only,
  Detail link top-right (logs live on the detail page). New
  MemberProfileCard mirrors the structure: name + role + email +
  top-2 owned agents (sorted by 30d run count) with click-through to
  agent detail.

* Agents list: split Status into two columns — availability (3-color
  dot + label) and Last run (task icon + label, optional running
  counts). Two independent filter chip groups (Status + Last run);
  combination acts as intersection ("online + failed" finds broken-
  but-alive agents).

* Other UI surfaces (issue list/board/detail, comments, autopilots,
  projects, runtimes, mention autocomplete, subscribers picker)
  updated to the new dot semantics; status dot now strictly 3-color.

Server changes accompany the client redesign — workspace-wide
agent-task-snapshot endpoint, runtime usage queries, etc. — to feed
the derive layer with the data it needs.

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

* refactor(agent-detail): drop last-task chip from detail header + inspector

The Recent work section on the agent detail page already shows the same
data (with task titles, timestamps, error context) — surfacing
"Completed" / "Failed" / etc. up in the header was redundant chrome.
Detail surfaces now show only the 3-state availability dot.

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

* fix(tables): handle narrow viewports across agents / skills / runtimes

Three table layouts were squeezing content into adjacent cells at
intermediate widths. Each fix is small and targeted:

* runtime-list: the Runtime cell's base name had `shrink-0`, so it
  refused to truncate when its grid column was narrowed under width
  pressure — the name visually overflowed into the Health column
  ("ClaudeOnline" etc). Removed shrink-0, added truncate. The Health
  column was also a fixed 9.5rem reservation for the worst-case
  "Recently lost · 2m 14s ago" copy; switched to minmax(0,1fr) so it
  competes fairly with Runtime.

* skills-page: had a single grid template with no responsive
  breakpoints — all 6 columns were rendered at any width and got
  visually jammed below md. Added a <md template that drops Source +
  Updated; the row markup hides those cells via `hidden md:block` /
  `md:contents`.

* agent-list-item: the new Last run column was reserved at minmax(8rem,
  max-content); on narrow md viewports the 8rem floor pushed the row
  past available width. Changed to minmax(0,max-content) so the cell
  shrinks under pressure (its content already truncates).

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

* refactor(agent-card): hover-only Detail + add Runtime row + breathing room

Three small polish tweaks to the agent hover card:

- Detail link gets `mr-1` + fades in only on card hover (group-hover).
  It was visually flush against the popover edge and competing for
  attention; now it stays out of the way during a quick glance and
  surfaces only when the user is dwelling on the card.

- Runtime row is back, in the meta block (cloud/local icon + runtime
  name). The earlier removal was over-aggressive — knowing where an
  agent runs is part of "who is this agent". The wifi badge stays
  dropped because the availability dot in the header already conveys
  reachability.

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

* feat(runtime): wifi-style health icon (4-state) for runtime list + agent card

Replaces the 6px coloured dot with a wifi-shape icon that carries both
state (Wifi vs WifiOff) and severity (success/warning/muted/destructive).

Mapping:
- online        → Wifi (success)
- recently_lost → WifiHigh (warning) — transient hiccup, fewer bars
- offline       → WifiOff (muted)    — long unreachable
- about_to_gc   → WifiOff (destructive) — sweeper coming soon

Used in two places:

- Runtime list: replaces HealthDot in the dedicated leading-icon column.
  Bumped the column from 0.5rem (dot-sized) to 0.875rem (icon-sized).

- Agent profile card RuntimeRow: derives runtime health from runtime +
  clock (matching the 4-state semantics) and renders HealthIcon next
  to the runtime name. Cloud runtimes always read as online. The
  duplicate signal with the header availability dot is intentional —
  it confirms WHICH runtime is the one currently in the dot's state.

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-28 19:21:13 +08:00
Naiyuan Qing
01855f6b09 revert(chat): Chat V2 — restore right-bottom floating drawer (#1580) (#1792)
* Revert "fix(chat): prevent UI flicker when streaming response finalizes (#1583)"

This reverts commit 71cc646951.

* Revert "fix(chat): prevent chatbox jump when sending first message (#1582)"

This reverts commit bb767e0ea6.

* Revert "feat(chat): Chat V2 — sidebar entry + main-area page (#1580)"

This reverts commit 35aca57939.
2026-04-28 18:31:33 +08:00
LinYushen
03f3180b8f fix(agent): ignore Kiro session/load history replay (#1789)
Ignore Kiro ACP session/load history replay before the active prompt starts; keep task messages, usage, and tool state scoped to the current Kiro turn. Verified with go test ./pkg/agent -run TestKiro, go test ./pkg/agent, and git diff --check origin/main...HEAD.
2026-04-28 17:50:13 +08:00
Bohan Jiang
6f9e82cecc docs(changelog): publish v0.2.19 release notes (#1791)
* docs(changelog): publish v0.2.19 release notes

Today's release covers 23 commits since v0.2.18. Headline items are the
macOS dock unread badge with focus-gated inbox notifications, the daemon
WebSocket task wakeup path that drops task startup latency, and a
client-side label filter on the issue list. Improvements / fixes round
out comment linkify, optimistic label attach, agent-to-agent mention
loop prevention, Codex turn timeouts, Windows daemon survivability, and
the comment-delete task cancellation.

The Kiro CLI runtime addition is intentionally omitted pending a
chat-mode regression flagged before release.

* docs(changelog): include Kiro CLI runtime, drop assignee-default line

Per release sign-off: Kiro CLI ACP runtime ships in v0.2.19 once the
chat-mode regression is fixed, so it goes back into the headline. The
"create-issue remembers last assignee" line is dropped from features
to keep the list to four spotlight items.
2026-04-28 17:46:28 +08:00
Bohan Jiang
bbe73ade8b feat(desktop): dock unread badge + focus-gated inbox notifications (#1445)
* feat(desktop): dock unread badge + focus-gated inbox notifications

Wire two OS-level integrations for inbox activity. Both degrade cleanly on
web and unsupported platforms.

- Unread badge on the macOS dock / Linux Unity launcher. Derived from the
  same inbox list the UI renders, deduplicated per issue, capped as "99+"
  on macOS via `app.dock.setBadge` (setBadgeCount truncates at 99). New
  `useInboxUnreadCount` hook (core/inbox) + `useDesktopUnreadBadge`
  (views/platform) keep renderer and main in sync via a `badge:set` IPC.
- Native OS notification on `inbox:new`, fired from the renderer only when
  `document.hasFocus()` is false — in-focus feedback is the existing inbox
  sidebar's unread styling, so we don't fight macOS's deliberate foreground
  suppression. Clicking the banner focuses the main window and navigates
  to `/inbox?issue=<key>` via the shared `multica:navigate` bus.

Refactors `inbox-page.tsx` to read the unread count through the new hook
(was a per-render inline filter).

* fix(desktop): pin notification routing to source workspace + mark read on URL select

Two bugs GPT-Boy caught on PR #1445:

1. A notification from workspace A used `getCurrentSlug()` at click time,
   so if the user switched to workspace B before clicking the banner (macOS
   Notification Center persists banners), routing landed on `/B/inbox?issue=<A key>`
   and 404'd. Fix: round-trip the emit-time `slug` through the IPC payload
   and use it in the click handler.
2. Notification-click navigation set the URL param but never fired the
   mark-read mutation (only InboxPage's click-handler did). The row stayed
   unread and the dock badge didn't decrement. Fix: move the mark-read
   logic from handleSelect into a useEffect keyed on the selected item —
   it now covers both click-to-select and URL-param-select.

IPC payload gains `slug` and `itemId`; preload types + main handler + the
desktop bridge are updated to match.
2026-04-28 17:33:48 +08:00
devv-eve
1845eaf42c fix: update kiro runtime icon (#1787)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-28 17:21:30 +08:00
LinYushen
c366cf2ba1 feat(agent): add Kiro CLI ACP runtime (#1780)
* feat(agent): add kiro cli acp runtime

* fix(agent): align kiro acp prompt and notifications

* chore(agent): clarify kiro acp args compatibility
2026-04-28 17:03:46 +08:00
LinYushen
fae108ebdc fix: refresh mention issue search results 2026-04-28 16:54:41 +08:00
Bohan Jiang
0236e409e4 feat(issues): client-side label filter on the issues list (#1782)
Adds a Label submenu to the workspace issues filter dropdown, backed by
labelFilters in the shared issue view store. The filter is OR'd within
itself (issue matches if it carries any of the selected labels) and
AND'd with the existing status / priority / assignee / creator /
project dimensions, mirroring the multi-select semantics already in
place. Each label row renders via LabelChip for color parity with the
sidebar picker, and each row's count comes from the same
useIssueCounts pass that drives the other filter chips.

Filtering stays client-side, consistent with all other filters today.
The pagination caveat is a known limitation we'll revisit if real
workspaces start hitting it; this PR intentionally does not change the
fetch path.
2026-04-28 16:47:33 +08:00
Bohan Jiang
2f793fb6fe docs(desktop-app): correct self-host callout to reflect build-time URLs (#1777)
Released Desktop builds bake VITE_API_URL/VITE_WS_URL/VITE_APP_URL
at build time and ship pointing at Multica Cloud — there is no
in-app 'Connect to a self-hosted instance' button. Reported in
multica-ai/multica#1768.

- Replace the misleading callout in desktop-app.mdx (and zh) with
  the actual self-host path: build from source with custom env, or
  use web + CLI. Link to #1371 for the runtime-config feature.
- Soften the corresponding 'Next steps' link in self-host-quickstart
  (and zh) so it no longer implies one-click Desktop self-host.
2026-04-28 16:46:03 +08:00
Naiyuan Qing
b2fb39ed21 refactor(issues): flatten status group headers in list/board (#1783)
Drop the filled status chip (bg-warning/text-white etc.) from the
list/board column headers — StatusIcon already carries the semantic
color, so the chip duplicated it on the text background, and the
bg-muted variants were nearly invisible against the muted/40 row
background. Wrap the shared icon + label + count in a new StatusHeading
component used by both list-view and board-column.

Remove the now-unused badgeBg/badgeText fields from STATUS_CONFIG.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:45:26 +08:00
Bohan Jiang
abd69890a8 Revert "feat(issues): server-side filters incl. label, fixing pagination drop…" (#1779)
This reverts commit 246fcd4ce4.
2026-04-28 16:29:42 +08:00
Bohan Jiang
246fcd4ce4 feat(issues): server-side filters incl. label, fixing pagination drops (#1776)
* feat(issues): server-side label + filter querying for issue list

Extends GET /api/issues with label_ids, priorities, creator_ids,
project_ids, include_no_assignee, and include_no_project params, and
moves the existing single-value filters onto array-form. Each filter
becomes part of the SQL WHERE clause so paginated buckets reflect the
user's selection — fixes the bug where client-side filtering hid
matches sitting past the first page (#1491).

CLI gains a repeatable --label flag; legacy --priority/--assignee/
--project keep working via the single-value compatibility paths.

* feat(issues): drive workspace + my-issues filters from the server

issueListOptions and myIssueListOptions now key the React Query cache
on a normalized filter object, so each filter combination has its own
cache entry and a filter change re-fetches with the wire-shape filter
applied server-side. Drops the client-side filterIssues step on the
issues page, my-issues page, and project detail — that step silently
hid matches that lived past the first paginated page (#1491).

Adds a Label submenu to the workspace issues filter dropdown, plus
labelFilters in the view store. Mutations and ws-updaters fan their
optimistic patches across every filter-keyed list cache via
qc.setQueriesData on issueKeys.listPrefix(wsId), and the editor's
mention-suggestion reads from any matching list cache for instant
first paint regardless of which filter is active.

* fix(issues): route Members/Agents scope through server-side filter

The Members/Agents scope tabs on the workspace issues page were still
narrowing client-side via `assignee_type === 'member'`. That hits the
exact pagination-blind bug this PR is meant to fix: if the first 50
issues per status don't include the right assignee type, the tab
shows "No issues" while later pages have matches.

Adds an `assignee_types text[]` filter to ListIssues / ListOpenIssues /
CountIssues, threads it through the API client, normalizer and view
filter, and maps the scope tab to it. Each scope now keys its own
list cache and refetches with the correct first page.

Also disables the My Issues "My Agents" query when the user owns no
agents — `assignee_ids: []` was getting dropped by both the API client
and the query-key normalizer, so the request went out unfiltered and
surfaced unrelated issues under "My Agents".
2026-04-28 16:13:56 +08:00
devv-eve
9db91e89f5 feat: add daemon websocket task wakeups (#1772)
* feat: add daemon websocket task wakeups

* feat: fan out daemon wakeups across nodes

* fix: dedupe daemon wakeup loopback events

* fix: lengthen daemon polling fallback interval

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 16:07:24 +08:00
Bohan Jiang
541aaa974d fix(server): clarify silent-exit prompt and pin handoff contract (#1775)
Follow-ups to #1765 review nits:

- Tighten the per-turn prompt and AGENTS.md workflow instructions so
  that "exit with no output" only applies when the trigger is from
  another agent AND no actual work was produced this turn. If the
  agent did real work, the standard "post results as a comment" rule
  still applies — a result reply is not a noise comment.

- Add TestAgentExplicitMentionStillTriggers as a positive control
  documenting the boundary the structural fix preserves: suppressing
  implicit parent-mention inheritance for agent authors does NOT
  block deliberate handoffs. An agent that explicitly @mentions
  another agent in its own content still enqueues a task for the
  mentioned agent and does not self-trigger.
2026-04-28 15:21:39 +08:00
Bright Zheng
81231e06f8 fix(server): prevent agent-to-agent mention inheritance loops (BRI-34) (#1765)
When an agent replied in a thread whose root mentioned another agent,
the reply inherited the parent mention and re-triggered the other agent.
This caused 'No reply needed' ping-pong loops between co-assigned agents.

Structural fix:
- In enqueueMentionedAgentTasks, suppress parent-mention inheritance
  when authorType == 'agent'. Explicit @mentions in the agent's own
  comment still work for deliberate handoffs.

Defense-in-depth (prompt):
- Strengthen per-turn prompt and AGENTS.md workflow instructions to
  explicitly forbid posting 'No reply needed' noise comments.

Regression test:
- TestAgentReplyDoesNotInheritParentMentions covers both the fix
  (agent reply does not re-trigger) and the positive control
  (member reply still inherits mentions).

Also updates TestBuildPromptCommentTriggeredByAgent to match the
new prompt wording.
2026-04-28 15:14:14 +08:00
devv-eve
6ef711cd35 fix: gate dev verification code behind explicit env (#1773)
* fix: gate dev verification code behind explicit env

* docs: fold dev verification code into env table

* docs: clarify fixed verification code opt-in

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 15:14:07 +08:00
Bohan Jiang
b8f661e006 feat(create-issue): default assignee to last-selected value (#1774)
The create-issue modal now remembers the assignee picked at submit
time and prefills the picker with that value when the modal next
opens. Implemented by tracking lastAssigneeType/Id alongside the
draft and seeding clearDraft's reset with those values.
2026-04-28 15:11:10 +08:00
Bohan Jiang
f628e48775 refactor(server): error-returning ParseUUID to prevent silent data loss
* refactor(server): make ParseUUID error-returning to prevent silent data loss (MUL-1410)

util.ParseUUID previously swallowed errors and returned a zero pgtype.UUID
on invalid input. When this zero UUID reached a write query (DELETE/UPDATE),
the SQL matched zero rows and the handler returned 2xx success — producing
silent data corruption. #1661 (DeleteIssue with identifier-style ID) was the
visible symptom; PR #1680 patched that one site, this commit closes the
class of bug.

Changes:

- util.ParseUUID now returns (pgtype.UUID, error). Add util.MustParseUUID
  for trusted round-trips that should panic on invalid input.
- handler/handler.go: parseUUID wrapper now calls MustParseUUID — any
  unguarded user-input string reaching it surfaces as a recovered panic
  (chi middleware.Recoverer → 500) instead of silently corrupting data.
  Add parseUUIDOrBadRequest(w, s, fieldName) for handler entry points.
- Convert every Queries.Delete*/Update* call site reachable from raw user
  input (autopilot, comment, project, skill, skill_file, label, pin,
  attachment, feedback, issue assignee, daemon runtime, workspace) to
  validate UUIDs explicitly with parseUUIDOrBadRequest, returning 400 on
  invalid input. Where a resolved entity.ID is already in scope, write
  queries now use it directly instead of re-parsing the URL string.
- Update getWorkspaceMember + loadIssueForUser to handle invalid UUIDs
  gracefully (404/400 instead of panic).
- Update util/middleware/cmd-level callers (subscriber_listeners,
  notification_listeners, activity_listeners, scope_authorizer,
  middleware/workspace) to use the error-returning API.
- Add server/internal/util/pgx_test.go covering valid/invalid input and
  the MustParseUUID panic contract.
- Add TestDeleteIssueByIdentifier + TestDeleteIssueRejectsInvalidUUID
  regression tests in handler_test.go (the original #1661 bug + the
  invalid-input case).
- Document the handler UUID parsing convention in CLAUDE.md so the rule
  is enforceable in future PR review.

* fix(server): address GPT-Boy review of #1748

P1 fixes from PR #1748 review:

1. Migrate remaining request-boundary UUIDs to parseUUIDOrBadRequest so
   malformed input returns 400 instead of panic/500. Was missing on:
   - issue.go: workspace_id in CreateIssue/ChildIssueProgress/ListIssues/
     SearchIssues/BatchUpdateIssues/BatchDeleteIssues; project_id /
     parent_issue_id / lead_id / assignee_id / assignee_ids / creator_id
     filters; batch issue_ids and assignee/parent/project fields in
     BatchUpdateIssues (skip on bad input via util.ParseUUID, matching
     the existing per-row continue semantics).
   - project.go: project id + workspace_id in GetProject/UpdateProject/
     DeleteProject; lead_id in CreateProject/UpdateProject;
     workspace_id in ListProjects + SearchProjects.
   - handler.go: resolveActor now uses util.ParseUUID for X-Agent-ID /
     X-Task-ID headers; invalid UUID falls back to "member" (matches
     pre-existing semantics) instead of panicking.
   - issue.go: validateAssigneePair returns 400 on invalid workspace_id
     instead of panicking.

2. Fix issue:deleted WS event payloads to emit uuidToString(issue.ID)
   instead of the raw URL string. After an identifier-path delete
   ("MUL-7"), the previous payload would have leaked the identifier to
   subscribers, leaving stale entries in frontend caches that key by
   UUID. Updated DeleteIssue (issue.go:1341) and BatchDeleteIssues
   (issue.go:1641). The slog "issue deleted" log line also now records
   the resolved UUID so logs match the WS payload.

3. Extend TestDeleteIssueByIdentifier to subscribe to the bus and
   assert issue:deleted.payload.issue_id is the resolved UUID, not
   the identifier.

* fix(server): validate remaining reviewed UUID inputs

* fix(server): validate remaining handler UUID inputs

* fix(server): finish request boundary UUID audit

* fix(server): validate remaining request body UUIDs

* fix(server): validate runtime path UUIDs

* fix(server): validate remaining audit UUID inputs

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 14:50:28 +08:00
devv-eve
f864a07bd5 feat: add server Prometheus metrics endpoint
Add Prometheus metrics endpoint with local-bind listener support and baseline metrics collectors.
2026-04-28 14:29:01 +08:00
devv-eve
c381d59c7a fix: preserve authored markdown links during linkify (#1761)
Co-authored-by: Eve <eve@multica.ai>
2026-04-28 08:57:15 +08:00
Bohan Jiang
1292ecf71b fix(labels): apply label attach optimistically (#1746)
* fix(labels): apply attach optimistically so chips render before round-trip

Attach went through onSuccess only, so users waited for the server
before seeing the new chip — out of step with detach (already optimistic)
and with status/assignee/priority via useUpdateIssue. Mirror the detach
pattern: snapshot the byIssue cache, look up the full label from the
workspace list cache, patch byIssue + the issue list/detail caches via
onIssueLabelsChanged in onMutate, and roll back on error. onSuccess and
onSettled keep the existing reconcile behavior.

* fix(labels): only patch attach when prev label set is known

GPT-Boy's review caught a corruption case: when byIssue cache was
unpopulated (user clicked before issueLabelsOptions resolved), the
optimistic patch fell back to an empty prev.labels, then mirrored
[label] into issue list/detail via onIssueLabelsChanged — wiping any
denormalized labels already on the issue. Worse, onError only restored
byIssue when ctx.prev existed, so the wipe persisted on failure.

Match useDetachLabel's invariant: skip the optimistic patch unless prev
is in cache. The chip will wait for the round-trip in the rare race
window, but caches stay consistent and rollback always works.
2026-04-27 18:24:40 +08:00
Bohan Jiang
b77acdf642 fix(comments): cancel triggered tasks when comment is deleted (#1747)
When a user deletes a comment that triggered an agent task, the agent
would still run with the now-deleted content baked into its prompt
(fetched at task claim time) — manifesting as "the agent still sees the
deleted comment". The FK ON DELETE SET NULL only nullified
trigger_comment_id; the queued task itself was never cancelled.

DeleteComment now cancels any queued/dispatched/running task whose
trigger is the deleted comment, before the comment row is removed.
2026-04-27 18:24:07 +08:00
dyjxg4xygary
6bd5bbad9c fix: timeout stalled Codex turns (#1730)
* fix: timeout stalled codex turns

* fix: count codex progress events as activity
2026-04-27 18:23:31 +08:00
songlei
4c81fbed2b fix(daemon/windows): break out of parent shell Job Object so daemon survives
Approved and merged via Multica after CI passed.
2026-04-27 17:47:30 +08:00
Alex Fishlock
d63e7c1c45 ci(release): skip homebrew-tap publish on forks (#1687)
The release job uses GoReleaser to bump the formula in
multica-ai/homebrew-tap. Forks don't have HOMEBREW_TAP_GITHUB_TOKEN
and should not publish to that tap, so the job currently fails on
every fork tag push (401 Bad credentials against the upstream tap).
This makes the workflow red on downstream forks even though the
actual artifact pipeline (verify → docker-backend-build →
docker-backend-merge) succeeds and produces a usable image.

Gate the release job on `github.repository_owner == 'multica-ai'`.
Upstream behaviour unchanged. Forks now see a clean green run for
docker artifacts only.
2026-04-27 17:47:11 +08:00
Bohan Jiang
dabebe0c12 docs(changelog): publish v0.2.18 release notes (#1745)
* docs(changelog): publish v0.2.18 release notes

Today's release covers 13 PRs since v0.2.17. Spotlight is the full Issue
Labels feature (backend + CLI + Web UI), plus the Labs settings tab,
sidebar invitation indicator, and the sharded Redis realtime relay.
Improvements and fixes round out comment rendering, project-icon usage
across the app, self-host env-var pass-through, and several
Windows-specific agent issues.

* docs(changelog): simplify v0.2.18 entries

Trim each line to a short, user-facing sentence; drop implementation
detail (sharded relay, build-id symlinks, --description-stdin, etc.) per
review feedback that the previous draft was too detailed.
2026-04-27 17:34:07 +08:00
Bohan Jiang
d14265de2a fix(comments): preserve newlines from agent CLI writes (#1744)
* fix(comments): preserve newlines from agent CLI writes

Agents (e.g. Codex) routinely emit `multica issue comment add --content
"para1\n\npara2"` because Python/JSON-style string literals are their
default. Bash does not expand `\n` inside double quotes, so the literal
4-char sequence flowed through the CLI into the database and rendered
as text in the issue panel — comments came out as one wall of prose.

Three coordinated fixes so the platform behavior no longer depends on
whether a given model has strong bash-quoting intuition:

- CLI: decode `\n / \r / \t / \\` in `--content` and `--description` for
  `issue create / update / comment add` (callers needing a literal
  backslash still have `--content-stdin`).
- Agent prompt: rewrite the comment-add example in the injected runtime
  config to require `--content-stdin` + HEREDOC for any multi-line body,
  and call out the same rule for `--description`. The previous wording
  flagged stdin only for "backticks, quotes", which models read as
  irrelevant to plain paragraphs.
- Renderer: add `remark-breaks` to the shared Markdown plugin chain so a
  bare `\n` becomes a visible line break instead of a CommonMark soft
  break — protects against models that emit single newlines for
  formatting.

Tests: pin the new CLI helper, and pin the runtime-config guidance so
the multi-line wording cannot decay back into a footnote.

* fix(comments): address review feedback on newline-rendering PR

- Cover the issue panel: ReadonlyContent (used by every comment card and
  the issue description) has its own react-markdown wiring; add
  remark-breaks there too so the renderer fix actually applies to the
  surface the bug was reported on, not just the chat panel. Pinned by
  ReadonlyContent line-break tests.
- Make the prompt's `--description` guidance executable: add
  `--description-stdin` to `issue create` / `issue update`, refactor
  comment-add to share a single `resolveTextFlag` helper, and have the
  injected runtime config name the real flag instead of an imaginary
  "stdin / a tempfile" path. Pinned by the runtime-config guidance test.
- Document the unescape contract on each affected flag's help text and
  pin the precise boundary in tests: `\n / \r / \t / \\` are decoded;
  `\d / \w / \s / \u / \0` and other unrecognised escapes pass through
  verbatim, so regex literals and Windows paths survive intact unless
  they embed a literal `\n` / `\r` / `\t`. Callers that need the literal
  sequence have `--content-stdin` / `--description-stdin` as the escape
  hatch.
2026-04-27 17:17:34 +08:00
Bohan Jiang
bf6509be96 fix(issues): show labels in my-issues view + place chips after title (#1743)
- my-issues page lost labels because myIssuesViewStore cherry-picked
  name/storage/partialize from viewStorePersistOptions and dropped the
  cardProperties-aware merge. Persisted snapshots predating the labels
  toggle had cardProperties.labels = undefined, falsy-shorting the chip
  render. Extracted mergeViewStatePersisted as a generic and wired it
  into both stores.
- list-row chips now render right after the title (with a small left
  margin for breathing room) instead of in the right-aligned cluster.
2026-04-27 16:50:13 +08:00
Bohan Jiang
6620997503 feat(issues): render labels on list/board with bulk server-side fetch (#1741)
* feat(issues): render labels on list/board with bulk server-side fetch

ListIssues / ListOpenIssues / GetIssue now bulk-fetch labels per response
via a new ListLabelsForIssues query so the client gets labels in a single
round-trip instead of N requests per visible issue. List-row and board-card
read issue.labels directly; an issue_labels:changed WS handler patches the
list and detail caches in place so chips stay live across tabs, and
attach/detach mutations mirror their result into the same caches for
immediate same-tab feedback.

Adds a "Labels" toggle to the card properties dropdown (defaults on).

* fix(issues): preserve cached labels and refresh on label edit/delete

Three fixes from gpt-boy's review of #1741:

1. IssueResponse.Labels was a non-omitempty slice, so paths that didn't
   load labels (UpdateIssue, batch updates, the issue:updated WS broadcast)
   serialized labels:null. onIssueUpdated then merged that null into the
   list/detail caches, wiping chips on every other tab whenever any non-
   label field changed. Switched to *[]LabelResponse + omitempty: nil =
   field absent (client merge keeps existing labels); non-nil (incl. empty
   slice) = authoritative.

2. issue.labels is a denormalized snapshot, but useUpdateLabel /
   useDeleteLabel and the WS label:* prefix only touched labelKeys, leaving
   stale chips in list/board after rename/recolor/delete. Mutations now
   also invalidate issueKeys.all(wsId), and the realtime refreshMap maps
   the label prefix to both labels and issues invalidation for cross-tab.

3. Persisted cardProperties from before this branch lacks the new `labels`
   key. Render fell back to `?? true` but the dropdown switch read it raw
   and showed unchecked. Added a custom Zustand merge that deep-merges
   cardProperties so newly added toggles inherit defaults for existing
   users; dropped the `?? true` fallbacks now that the store guarantees
   the key.
2026-04-27 16:33:34 +08:00
Naiyuan Qing
e268ee3e71 refactor(views): centralize project icon rendering and fix nav active state (#1738)
Extract <ProjectIcon> with sm/md/lg sizes and a single 📁 fallback,
replacing 9 inline render sites that had drifted into 6 different
sizes and a mixed FolderKanban/emoji fallback.

Two visible fixes fall out of the centralization:
- ProjectPicker trigger now shows the selected project's icon (most
  visibly in the issue detail right Properties panel, where it had
  always been a generic FolderKanban).
- Sidebar parent nav (Projects, Issues, Settings, ...) now stays
  highlighted on child detail routes via a small isNavActive helper.
  Pinned items keep strict equality.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:42:56 +08:00
Ayman Alkurdi
e9d04ecfc1 feat(labels): ship issue labels (closes #1191) (#1233)
* feat(labels): add issue label CRUD + attach/detach handlers (#1191)

The issue_label and issue_to_label tables were scaffolded in 001_init.up.sql
but never wired to any code path. This commit ships the backend for #1191:

- Migration 048: adds created_at/updated_at timestamps + workspace-scoped
  case-insensitive unique index on label names
- sqlc queries for label CRUD + issue<->label attach/detach + batch list
  (ListLabelsByIssueIDs for board/list views)
- HTTP handlers: /api/labels CRUD, /api/issues/{id}/labels attach/detach
- Protocol events: label:{created,updated,deleted} + issue_labels:changed
- Handler tests covering CRUD, duplicate-name conflict, invalid-color,
  attach/detach idempotency, and cross-workspace isolation

* feat(cli): add label and issue label subcommands (#1191)

- multica label {list,get,create,update,delete}
- multica issue label {list,add,remove}

Both follow existing CLI conventions (JSON/table output, flag shapes)
and exercise the /api/labels endpoints shipped in the previous commit.

* feat(web): add labels UI — picker with inline create + management dialog (#1191)

Exposes the backend label feature to users via the existing issue-detail
sidebar.

- `@multica/core/types/label` — Label, CreateLabelRequest, UpdateLabelRequest,
  plus response envelopes
- `@multica/core/api/client` — 8 methods for label CRUD and issue↔label
  attach/detach
- `@multica/core/labels` — labelKeys, queryOptions, and mutation hooks with
  optimistic updates (matches the project/ module layout)
- WS event type literals extended for label:{created,updated,deleted} and
  issue_labels:changed

- `views/labels/label-chip.tsx` — colored pill; uses relative luminance
  (ITU-R BT.601) to pick #111827 or #f9fafb text so chips stay readable on
  both pastel and saturated backgrounds
- `views/issues/components/pickers/label-picker.tsx`
  - Multi-select combobox in the issue sidebar
  - When 0 labels: "Add label" trigger
  - When 1+ labels: the chips themselves are the trigger; × on each chip
    detaches without opening the picker
  - Inline create: typing a new name + Enter creates with a hash-derived
    color and attaches in one motion (matches Linear/GitHub)
  - "Manage labels…" footer opens a dialog containing the full workspace
    panel — users never leave the issue context to rename/recolor/delete
- `views/issues/components/labels-panel.tsx` — workspace labels manager.
  Single-row create form (color swatch + name + Add button). Each label
  row supports inline rename + recolor + delete (with confirm dialog).
  Color input uses the browser's native picker for full-gamut access —
  no preset palette clutter.

- `PropRow label="Labels"` added to the issue-detail sidebar below Project

Labels are issue metadata everyone uses — not admin configuration.
Putting them in Settings next to destructive workspace actions misframed
them; adding a top-level nav entry or a sibling tab to the Issues page
added surface area that wasn't earning its keep for a feature users
touch occasionally. Keeping management in a dialog launched from the
picker itself keeps users in their issue context and matches how GitHub
handles label editing from the label selector.
2026-04-27 14:23:42 +08:00
Bohan Jiang
2e7da8c63f fix(desktop): disable RPM build-id symlinks to avoid Slack conflict (#1734)
Electron apps share an identical upstream Electron binary, so its GNU
build-id is the same across every Electron RPM (Slack, VS Code, Discord,
etc.). The default fpm/rpm behavior owns /usr/lib/.build-id/<hash>
symlinks, which collide between packages and make `dnf install` fail
when any other Electron app is already installed.

Pass `_build_id_links none` to rpmbuild via fpm so the multica-desktop
RPM no longer claims those paths.

Fixes multica-ai/multica#1723.
2026-04-27 14:11:16 +08:00
Jiayuan Zhang
04882c2201 feat(labs): Add labs settings tab (#1732) 2026-04-27 13:46:25 +08:00
devv-eve
ba2f19d631 fix: refresh agent status from active tasks (#1733)
Co-authored-by: Eve <eve@multica.ai>
2026-04-27 13:34:24 +08:00
devv-eve
7f6776b12f fix: harden Windows CLI architecture detection
* fix: harden windows cli architecture detection

* fix: avoid duplicate windows architecture signals

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-27 13:01:53 +08:00
Truffle
8b340fcf21 fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts (#1718)
* fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts

The npm-generated `opencode.cmd` shim forwards argv via Windows batch `%*`,
which silently truncates positional arguments at the first newline. The
daemon spawns OpenCode with a multi-line prompt (system prompt + user
message), so on Windows the agent only ever sees the first line and
responds generically as if it never received the user's message
(reported in #1717 with native-binary repro confirming the same prompt
arrives intact when cmd.exe is skipped).

When `runtime.GOOS == "windows"` and `exec.LookPath` returns a `.cmd`
shim, walk to the native binary that npm bundles next to the shim:

  <prefix>\opencode.cmd
  <prefix>\node_modules\opencode-ai\node_modules\opencode-windows-x64\bin\opencode.exe

If the native binary is missing (unusual install layout), keep the
original shim path so PATH lookup still wins. The resolver is a pure
function with an injectable `statFn`, so layout assertions are testable
on Linux:

- shim resolves to the bundled native binary
- missing native returns "" (caller keeps original path)
- non-cmd paths (Linux/Mac binary, opencode.exe direct, empty) skip resolution
- uppercase `.CMD` is accepted (PATHEXT entries can be either case)

Closes the user-facing failure mode without restructuring exec resolution
across the rest of the agent backends — the other shim-aware fixes can
follow the same shape if/when they land in similar repros.

* fix(agent/opencode): cover x64-baseline and arm64 npm package variants

`npm install -g opencode-ai` ships three Windows platform packages
(opencode-windows-x64, opencode-windows-x64-baseline for older CPUs
without AVX2, opencode-windows-arm64 for Surface / Copilot+ PC) and
installs whichever matches the host. The previous resolver only knew
about opencode-windows-x64, so baseline-x64 and arm64 hosts would fall
back to the .cmd shim and hit the multi-line prompt truncation again.

Iterate the three package candidates in GOARCH-preferred order. ARM64
hosts try arm64 first; everything else tries x64, then baseline, then
arm64 as a last resort. Cost is one extra statFn call per miss when
the GOARCH-preferred package isn't installed.

Surfaced by review on #1718.

* test(agent): add Windows counterpart to writeTestExecutable

writeTestExecutable in exec_fixture_unix_test.go is referenced by
claude_test.go / codex_test.go / kimi_test.go, but the //go:build unix
constraint meant `go test ./pkg/agent` failed to build on Windows.

ETXTBSY is a Linux/Unix fork-exec race; Windows doesn't have that
pathology, so a plain os.WriteFile is sufficient.

Lifted from #1719 (Codex) with attribution. Surfaced by review on #1718.
2026-04-27 12:16:56 +08:00
supercon99
1f770813dd fix(selfhost): pass ALLOW_SIGNUP / ALLOWED_EMAILS / ALLOWED_EMAIL_DOMAINS to backend (#1726)
docker-compose.selfhost.yml documents these as load-bearing in .env.example
but the backend service never received them, so allowlist / signup-gating
configs were silently ignored on self-hosted deployments. Wires the three
vars through with defaults matching .env.example.
2026-04-27 12:16:15 +08:00
Muhammadrizo
29122cc18b feat(sidebar): add dot to show the user about new invintation (#1711) 2026-04-27 11:41:03 +08:00
LinYushen
18524d80d0 Implement sharded Redis realtime relay (#1702)
* Implement sharded Redis realtime relay

* Isolate dual relay read pools

* Surface mirrored relay publish divergence
2026-04-26 12:03:06 +08:00
LinYushen
141c294cdb P0: isolate Redis relay pools (#1701)
* Isolate Redis relay pools

* Fix Redis relay shutdown order
2026-04-26 11:26:13 +08:00
Black
04f813a70f fix PR 1573 follow-up colors (#1699) 2026-04-26 11:14:40 +08:00
Bohan Jiang
c7a2d53f76 docs(changelog): publish v0.2.17 release notes (#1700)
* docs(changelog): publish v0.2.17 release notes

Covers commits between v0.2.16 (2026-04-24) and the v0.2.17 cut
(2026-04-26): --custom-env flag for agents, agent CLI stderr tail in
failure messages, configurable update download timeout, plus reliability
fixes around daemon cancellation, server heartbeat, Codex execenv, Pi
skills path, Windows console, CJK markdown URLs, attachment downloads
and autopilot run-only context.

Both en.ts and zh.ts updated.

* docs(changelog): trim small/internal items from v0.2.17 entry

Drops items that read as internal polish or were too narrow to belong in
release notes:
- Skills landing intro polish
- Codex execenv plugin-cache cleanup
- CLI exact-name/ShortID assignee resolution
- Settings invite role label rendering
- Skills SKILL.md fast-path
- CJK markdown URL-boundary fix
- Relative attachment download URLs

Keeps the user-facing wins: --custom-env, stderr-tail in failure
messages, configurable update timeout, cancelled-task classification,
heartbeat probe/claim split, plus the higher-impact fixes.
2026-04-26 11:10:20 +08:00
Bohan Jiang
aca74293dd fix(agent/claude): surface stderr tail on writeClaudeInput failure + lock with e2e test (#1698)
#1674 wired claude's post-handshake error path through withAgentStderr but
left the writeClaudeInput failure branch returning a bare "broken pipe"
error. That branch fires precisely when claude crashes during startup —
exactly when the stderr tail is most useful for root-causing V8 aborts,
Bun panics, or missing native modules. cmd.Wait() before sampling Tail()
flushes os/exec's internal stderr copy goroutine, matching the
Wait→Tail synchronization contract spelled out in stderr_tail.go.

Adds TestClaudeExecuteSurfacesStderrWhenChildExitsEarly mirroring the
codex test: a fake claude binary drains stdin, writes a V8-abort line to
stderr, and exits 3. Locks in the contract that Result.Error carries the
stderr tail in the post-handshake failure path on the claude backend too.
2026-04-26 11:09:38 +08:00
Bohan Jiang
12e6ca9906 refactor(execenv): collapse codex plugin cache stale-link branches (#1697)
Merge the two symlink removal branches in exposeSharedCodexPluginCache —
they shared the same os.Remove + recreate path with only the error label
differing. The branch is now keyed off Lstat's ModeSymlink bit, with
Readlink reused only to fast-path an already-correct link. Behaviour is
unchanged; just less duplicated code.
2026-04-26 11:05:08 +08:00
jmoney8896
3c3e3bd330 fix(task): reconcile agent status when cancelling tasks by issue (#1587) (#1648)
CancelTasksForIssue silently dropped the list of affected tasks, so
whenever an issue transitioned to "cancelled" or "done" while a task was
still active (6 call sites in issue.go), the underlying agent was left
stuck at status="working" indefinitely and required a manual
`multica agent update <id> --status idle` to self-correct. This matches
the symptom reported in #1587: task rows move to "cancelled" via a
non-user-initiated path, agent status never reconciles.

Change CancelAgentTasksByIssue from :exec to :many (also tack on
completed_at = now() for consistency with CancelAgentTasksByIssueAndAgent),
then update CancelTasksForIssue to iterate the returned rows and call
ReconcileAgentStatus + broadcast task:cancelled per affected task —
mirroring the pattern already used by CancelTask and RerunIssue.

No test added; the change is small and mirrors well-covered paths.
Happy to add a mock-backed test in a follow-up if reviewers prefer.

Refs #1587
Refs #1149
2026-04-26 10:58:42 +08:00
Y. L.
25b393df17 fix(execenv): hydrate Codex skill sources (#1668)
Expose the shared Codex plugin cache inside each per-task CODEX_HOME before launch so plugin-provided skills are available on the first session.

Refresh agent-assigned workspace skills for both newly prepared and reused Codex environments, and cover plugin cache plus reuse behavior with focused execenv tests.
2026-04-26 10:57:51 +08:00
songlei
6f04a6d26b feat(agent): surface agent CLI stderr tail in failure messages (#1674)
Hoist the existing stderrTail ring-buffer (previously codex-only) into
a shared pkg/agent helper so every Backend that supervises a child CLI
can include the last ~2 KB of that CLI's stderr in Result.Error. Wire
the claude backend through the same path.

Motivation: claude on Windows occasionally exits with a non-zero status
after ~5–8 minutes of a single long-running tool_use, and right now the
daemon only reports "claude exited with error: exit status 3" /
"exit status 0x80000003" — useless for root-causing V8 aborts, Bun
panics, native-module OOMs, or any other CLI-side crash. With the tail
attached, the failure message carries the real signal (panic line, V8
assertion, stderr-printed HTTP error) all the way into the task row's
error field that users see in the API.

Renames withCodexStderr to withAgentStderr(msg, label, tail) so the
helper is self-documenting across providers.
2026-04-26 10:55:21 +08:00
Bohan Jiang
58547faf31 fix(server): validate assignee_id existence on issue create/update (#1694)
* fix(server): validate assignee_id existence on issue create/update

POST /api/issues and PUT /api/issues/:id silently accepted any
well-formed UUID as assignee_id (#1662). The new validateAssigneePair
helper consolidates the existing canAssignAgent check and adds:

- existence lookup against workspace members for assignee_type=member
- existence lookup against workspace agents for assignee_type=agent
- pair consistency: type and id must be both set or both null
- whitelist for assignee_type values (member|agent)

UpdateIssue and BatchUpdateIssues now run the same validator on the
post-merge assignee pair whenever the caller touches either field,
closing the parallel gap on the update path.

* fix(server): reject malformed assignee_id at handler entry

parseUUID silently returns an invalid pgtype.UUID for unparseable input
and validateAssigneePair treats (type unset + id invalid) as "no
assignee". Together they let `POST /api/issues` and `PUT /api/issues/:id`
silently drop a malformed assignee_id and return a successful response.

Reject the parse failure inline at every entry point — Create, Update,
and BatchUpdateIssues — so the validator never sees an unparseable id.
Adds two regression tests covering the create and update paths.
2026-04-26 10:35:47 +08:00
Magnus Handeland
9b55b2a9ce feat(cli): add --custom-env flag to agent create/update (#1518)
* feat(cli): add --custom-env to agent create/update

Adds a JSON-object flag on `multica agent create` and `multica agent
update` that writes the agent's `custom_env` map via the existing
handler API. Needed so runtime bearer tokens (e.g. SECOND_BRAIN_TOKEN)
can be provisioned from the CLI without falling back to curl or
admin-only UI access.

- `--custom-env '{"KEY":"value"}'` → sets the map.
- `--custom-env '{}'` or `--custom-env ''` → clears the map on update
  (server treats a non-nil empty map as "clear all entries").
- Omitted flag → no change.
- Help text flags the value as secret material and never logged.
- Table-driven tests cover the parser (valid, clear, invalid JSON,
  wrong shape) plus flag discoverability on both commands.

* feat(cli): add --custom-env-{stdin,file}; sanitize parse errors

Security review of the --custom-env flag (PR #1518) surfaced two issues:

1. Secrets on the command line leak via shell history and /proc/<pid>/cmdline
   regardless of CLI logging. Add --custom-env-stdin and --custom-env-file
   as mutually-exclusive alternatives, and update the --custom-env help
   text to warn about shell history / 'ps' exposure so the "never logged"
   claim is no longer misleading.

2. parseCustomEnv wrapped json.Unmarshal errors with %w; SyntaxError /
   UnmarshalTypeError can surface fragments of the (secret) input. Return
   a fixed, content-free message instead.

Refactor the body-assembly blocks in both agentCreateCmd and
agentUpdateCmd to go through a single resolveCustomEnv helper so the
three input channels behave identically. Tests cover every channel,
mutual exclusion, error sanitization, and help-text wording.

* fix(cli): require explicit '{}' to clear custom_env; sanitize --custom-args errors

Address PR #1518 review feedback from @Bohan-J:

1. parseCustomEnv now errors on empty/whitespace input. The clear signal
   is the explicit '{}' object only. The previous behavior silently wiped
   the secret map when an upstream pipe was empty (cat missing.json |
   ... --custom-env-stdin without set -o pipefail) or when --custom-env-file
   pointed at an empty file. resolveCustomEnv emits channel-specific error
   messages (e.g. "--custom-env-stdin: empty input; pass '{}' to clear").

2. Drop the '&& filePath != ""' guard so an explicit --custom-env-file ""
   surfaces an error instead of being silently ignored.

3. Rewrite TestAgentUpdateNoFieldsMentionsCustomEnv into
   TestAgentUpdateNoFieldsErrorMentionsAllCustomEnvFlags — the body now
   actually runs runAgentUpdate with no flags and asserts the resulting
   "no fields" error names all three --custom-env channels.

4. Extract parseCustomArgs helper. Replace the '%w'-wrapped json error
   with a content-free message, mirroring parseCustomEnv. Although
   custom_args is not a dedicated secret channel, callers regularly stuff
   sensitive values like "--api-key=..." into it, so json.Unmarshal must
   never echo input fragments. Adds TestParseCustomArgsErrorSanitization.

Also adds resolveCustomEnv subtests for stdin/file empty-input, empty
file contents, empty file path, and explicit '{}' positive cases.

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

---------

Co-authored-by: Implementer (Multica Agent) <implementer@multica-agent.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:32:55 +08:00
Bohan Jiang
c7bac0aa6b docs(changelog): publish v0.2.16 release notes (#1695)
Covers everything between v0.2.15 (2026-04-22) and v0.2.16 (2026-04-24):
Chat V2, issue right-click context menu, in-app feedback + Help launcher,
Autopilot modal redesign, Skills page redesign, bilingual flat docs site
rewrite, plus the supporting agent / runtime / chat / desktop fixes.

Both en.ts and zh.ts updated.
2026-04-26 10:22:53 +08:00
Bohan Jiang
101601a4c3 fix(settings): render invite role label via roleConfig in members tab (#1693)
The invite-member role Select rendered the raw value ("member"/"admin")
in the trigger because Base UI's SelectValue defaults to the value, not
the item text. PR #1672 worked around it with `className="capitalize"`,
but this file already owns a roleConfig map with proper labels and the
codebase has an established render-prop pattern for SelectValue (see
trigger-config.tsx and runtime-local-skill-import-panel.tsx).

Use roleConfig[inviteRole].label inside SelectValue and reuse the same
labels for SelectItem children. Single source of truth for role display
names; future role additions or i18n won't depend on CSS capitalize.
2026-04-26 09:43:35 +08:00
Bohan Jiang
95912243bb test(daemon): cover cancelled classification in executeAndDrain (#1692)
Follow-up to #1686. Locks in two nits flagged during review:

1. agent.Result.Status doc comment now lists "cancelled" alongside the
   existing values, so the enum surface matches actual usage.
2. New TestExecuteAndDrain_ContextCancelled_ReportsCancelled exercises
   the path added in #1686: when the parent context is cancelled before
   the backend produces a Result, executeAndDrain must return
   Status="cancelled" (not "timeout"). A regression here would silently
   restore the misleading log line we just fixed.
2026-04-26 09:27:13 +08:00
Kagura
24e135541b fix(server): use resolved issue ID in DeleteIssue handler (#1680)
DeleteIssue passed the raw URL parameter through parseUUID(), which
returns a zero UUID for human-readable identifiers like "API-123".
This caused DELETE requests with identifier-style IDs to silently
succeed (204) without actually deleting the issue.

Use issue.ID from the already-resolved issue object instead, consistent
with BatchDeleteIssues and all other operations in the same handler.

Fixes #1661
2026-04-26 09:24:19 +08:00
Alex Fishlock
2df969cffc fix(daemon): report cancelled tasks as "cancelled", not "timeout" (#1686)
When the server cancels a task (e.g. assignee changes during execution,
explicit user cancel, or workspace_isolation check fail), the daemon's
cancellation poll fires runCancel() on the run context. The drainCtx
derived from runCtx then signals Done(), but executeAndDrain() was
returning Status: "timeout" regardless of *why* the context ended.

The "agent finished status=timeout" log line is then misleading — it
suggests an actual deadline timeout when really the task was cancelled
by upstream. We spent hours misdiagnosing a healthy handoff as a
broken timeout because of this.

Distinguish context.Canceled from context.DeadlineExceeded in
executeAndDrain, and add a "cancelled" case to runTask so the status
propagates through the existing log path.

No behaviour change for genuine timeouts; no behaviour change for
the cancelled-by-poll discard path in handleTask. Only the daemon
log line and TaskResult.Status get the more accurate label.
2026-04-26 09:23:32 +08:00
lmorgan-yozu
5eab1dbbe1 fix: handle relative attachment download URLs
Resolve server-relative attachment download URLs against the CLI server base URL while preserving signed absolute URL behavior.
2026-04-25 02:13:18 +08:00
Bohan Jiang
a89064d693 docs: clean up leftover .pi/agent/skills references (#1645)
PR #1632 updated the Pi project-level skill dir from
.pi/agent/skills/ to .pi/skills/, but missed two references:

- server/internal/daemon/execenv/runtime_config.go:20 — the comment
  block here lists project-level paths for every other provider, so
  using Pi's global path was inconsistent and misleading.
- docs/docs-rewrite-plan.md:88 — planning doc still listed the old
  path in the Skills row.

Follow-up to #1632.
2026-04-25 02:08:33 +08:00
etern
68a312c297 fix(runtimes): fix pi skills dir to: .pi/skills (#1632)
change .pi/agent/skills to .pi/skills

Pi loads skills from:

Global:
  ~/.pi/agent/skills/
  ~/.agents/skills/
Project:
  .pi/skills/
  .agents/skills/

- ref: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md#locations
2026-04-25 02:06:25 +08:00
Bohan Jiang
683ff132ca fix(server/heartbeat): probe/claim split + slow-log + model-list running timeout (#1644)
Mitigates #1637 and the related model-discovery failure in MUL-1397 by bounding the /api/daemon/heartbeat hot path with an ack-safe probe/claim split, adding structured slow-log attribution, and closing the ModelListStore running-state gap. See PR description for details.
2026-04-25 02:06:00 +08:00
Truffle
93fe324bb9 fix(skills): fast-path root-level SKILL.md with frontmatter guard (#1625)
Closes the functional gap the reporter hit on alchaincyf/huashu-design
(skills.sh/alchaincyf/huashu-design/huashu-design) without expanding
candidatePaths unconditionally, which would let an unrelated root
SKILL.md hijack a different skill URL in a multi-skill repo.

Try SKILL.md at the repo root before falling into the recursive tree
fallback added in #1432. Verify the frontmatter name matches the
requested skill so only genuine single-skill repos take the fast path.
For those repos this also shaves the recursive tree API call.

Also clarifies the candidate-path comment so the root case is
explicit.
2026-04-25 01:40:23 +08:00
Bohan Jiang
74593fdb88 fix(daemon): use CREATE_NEW_CONSOLE to stop grandchild console popups on Windows (#1521) (#1643)
* fix(daemon): use CREATE_NEW_CONSOLE to stop grandchild console popups on Windows (#1521)

CREATE_NO_WINDOW strips the console entirely. When the agent CLI then
spawns a console-subsystem grandchild (bash, cmd, netstat, findstr,
timeout) without itself passing CREATE_NO_WINDOW, Windows allocates a
brand-new visible console window per invocation — trading one popup per
agent run for N popups per tool call.

Switch to CREATE_NEW_CONSOLE + HideWindow=true so the agent gets a
hidden console that grandchildren inherit. Stdio pipes still work via
STARTF_USESTDHANDLES; no changes needed at the 17 hideAgentWindow call
sites.

Add a Windows-only regression test asserting CREATE_NEW_CONSOLE is set
and CREATE_NO_WINDOW is not, per the #1474 Windows-test follow-up.

Root-cause diagnosis by @matrenitski (verified against the shipped
multica.exe and the Claude Code CLI it spawns) in issue #1521.

* test(agent): use CREATE_NEW_CONSOLE-compatible flag in preservation test

CREATE_NEW_PROCESS_GROUP is silently ignored by Windows when combined
with CREATE_NEW_CONSOLE, so asserting it 'survives' was only bitwise-true,
not semantically meaningful. Switch the example to
CREATE_UNICODE_ENVIRONMENT (documented compatible) and also assert a
non-flag field (NoInheritHandles) survives to exercise full struct
preservation.
2026-04-25 01:40:15 +08:00
Bohan Jiang
60fdc82824 fix(cli): resolve assignee by exact name or ShortID to avoid substring collisions (#1642)
`multica issue assign --to <name>` matched agent/member names with a plain
`strings.Contains` check, so an exact match on `reviewer` became ambiguous
whenever a longer agent like `peer-reviewer` also existed. There was also
no way to disambiguate by ID.

Rework `resolveAssignee` to bucket candidates by priority:
1. Full UUID or 8-char ShortID (matches `truncateID` output) — case-insensitive.
2. Case-insensitive exact name (with surrounding whitespace trimmed).
3. Substring fallback — preserves the existing partial-name UX.

The first non-empty bucket wins. Ambiguity inside a higher-priority bucket
still errors and short-circuits lower-priority matching.

All six call sites (`issue assign/update/create/list`, `issue subscriber`,
`project`) are fixed by this single change.

Fixes #1620
2026-04-25 01:05:29 +08:00
Naiyuan Qing
c3ae212b40 fix(markdown): treat CJK full-width punctuation as URL boundary (#1630)
linkify-it only recognizes ASCII characters as URL boundaries. In Chinese
or Japanese text a URL followed by "。" (or any other full-width
punctuation) was greedily swallowed into the URL along with everything up
to the next whitespace, producing hrefs like
`https://.../pull/1623。merge` that 404 when clicked.

Truncate the detected URL at the first CJK full-width punctuation
character and re-scan the tail, so adjacent URLs separated only by
full-width punctuation are still each linked individually. The
terminator character set mirrors the fix applied in mattermost/marked#22.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:47:47 +08:00
Joey
d17b2bfb8c feat(cli): 添加更新下载超时配置选项 (#1622)
- 在 update 命令中添加 --download-timeout 标志用于设置下载超时时间
- 实现 UpdateViaDownloadWithTimeout 函数支持自定义下载超时
- 添加 updateDownloadTimeoutOrDefault 辅助函数处理超时值验证
- 设置默认下载超时时间为 120 秒
- 添加 updateDownloadTimeoutOrDefault 函数的单元测试
- 验证超时参数必须大于零的错误处理逻辑
2026-04-24 17:05:23 +08:00
devv-eve
13d9d7df1b fix: pass autopilot run-only context to agents
Fix run-only autopilot tasks so agents receive autopilot context instead of empty issue instructions. Add regression coverage for run-only terminal event sync.
2026-04-24 16:36:04 +08:00
Naiyuan Qing
71b2032174 feat(skills): restore page description, link to docs, polish intro layout (#1618)
* feat(skills): restore page description, link to docs, polish intro layout

The previous card-layout refactor (#1614) dropped the page-top
description entirely; without it the page jumps straight from the
PageHeader to a brand-colored banner that explains *how sharing works*,
with nothing answering "what IS a skill?". Bring the description back,
add a docs entry point, and tighten the visual hierarchy so the intro
block reads as one coherent unit above the table card.

- Restore a one-line description as the page's primary intro:
  "Instructions any agent in this workspace can use." — uses "any agent
  ... can use" (capability, not factual usage) since skills must be
  manually attached to take effect.
- Add an inline "Learn more about Skills →" link mirroring the
  onboarding docs-link pattern (muted underline, new tab) — opens
  https://multica.ai/docs/skills.
- Visual hierarchy: description is text-base + text-foreground (primary),
  link is text-xs + text-muted-foreground (auxiliary). Same line, eye
  follows weight order.
- Banner padding bumped from px-3 py-2 to px-4 py-3 so it breathes and
  its inner text lands at the same x as the table content.
- Wrap description + banner in a shared `pl-4 space-y-3` so they read as
  one intro block, indented to align with the table card's content.
- Loading skeleton updated to mirror the new structure.

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

* feat(skills): keep docs link underline subtle, only animate text color on hover

The underline was inheriting text-decoration-color from the link's text,
so when hover bumped the text from muted to foreground the underline
got darker too — making the link feel more prominent on hover than at
rest, the opposite of what we want for a tertiary docs link.

Pin decoration-color to muted-foreground/30 explicitly so it stays
faint regardless of hover state. Only the text color transitions; the
underline stays as a constant low-key marker that the element is a link.

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-24 15:35:33 +08:00
Naiyuan Qing
f7fe0829f2 refactor(skills): wrap list as card, use shared PageHeader, add scroll fade (#1614)
The skills page rolled its own HeroHeader instead of the shared PageHeader,
which meant no mobile sidebar trigger and visual drift from other list
pages. The table was also edge-to-edge inside the dashboard container, so
it felt "naked" compared to the rest of the product.

- Replace custom HeroHeader with shared PageHeader (gives mobile hamburger
  and h-12 chrome for free); move "New skill" into the PageHeader as the
  page-level action.
- Keep search + scope filters in a toolbar, but move that toolbar *inside*
  a bordered, rounded card together with the table, so the whole unit
  reads as a single scrollable surface with internal padding.
- Use the existing useScrollFade hook on the row list so the top/bottom
  edges fade while scrolling.
- Drop `divide-y` in favor of `border-b` per row — divide-y leaves the
  last row without a bottom rule, which looks unfinished when only a
  couple of skills exist and the scroll area is taller than the content.
- Drop the redundant description paragraph from the old hero; keep the
  "Shared with your workspace" banner above the card since it carries
  non-obvious UX context.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:04:09 +08:00
LinYushen
9e1e3981fb fix(workspace): defense-in-depth owner check in DeleteWorkspace handler
Adds an owner check inside DeleteWorkspace as defense-in-depth and covers both router-level and direct handler paths.
2026-04-24 14:29:39 +08:00
Naiyuan Qing
c7e725ef66 feat: surface docs from onboarding + landing, unify Autopilot naming (#1613)
* docs(autopilot): rename Routines → Autopilots to match product UI

Unify naming between docs and product. Sidebar label, URL route,
CLI command, and onboarding copy all call this feature "Autopilot";
the docs were the only surface that diverged. Aligning the docs to
the product (rather than the reverse) because the 830+ code-side
references would be a much larger rename to propagate.

- Rename routines.mdx / routines.zh.mdx → autopilots.mdx / autopilots.zh.mdx
- Update meta.json / meta.zh.json index entries (routines → autopilots)
- Drop the reconciliation note ("docs say Routines, CLI says autopilot")
  that shipped in the original routines.mdx and the cli.mdx section header
- Update cross-references in cli, how-multica-works, tasks,
  assigning-issues, chat, mentioning-agents, daemon-runtimes (EN + ZH)

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

* feat(onboarding): link to docs from key steps and starter tasks

Users who want to dig deeper now have a next hop from inside the flow
instead of having to dig through the help menu. Placed as secondary
links (muted, underline-offset-4) so they don't pull focus from the
primary CTA on each step.

Placement — one link per surface, placed in secondary regions:
- Welcome: "Learn how Multica works" below the subhead
- Questionnaire: "Learn how agents work" in the Why-we-ask aside
- Runtime aside (shared by desktop + web): "Learn about runtimes"
- Agent step: "Creating your first agent" in the About-agents aside
- StarterContentPrompt dialog: "Learn how Multica works"

Starter tasks (content/starter-content-templates.ts): added a single
"Learn about X" tail link per task, only on first occurrence of each
concept within a branch. 8 links on the agent-guided branch + 8 on
the self-serve branch + 1 on the welcome issue header (17 total).

URL scheme: absolute https://multica.ai/docs/{slug} throughout —
absolute so desktop (Electron) opens them in the system browser, and
the /en prefix is omitted because the docs middleware redirects it
away (English is the default, Chinese is /zh/).

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

* feat(landing): add docs link to footer and how-it-works section

Docs were previously reachable only from the in-app help menu. Landing
now surfaces them in two places, both locale-aware (/docs for English,
/docs/zh for Chinese):

- Footer Resources group: Documentation link was pointing at the
  GitHub repo; replaced with the real docs URL
- How-It-Works section CTA row: added "Read the docs" between the
  primary CTA and the GitHub link, same ghost styling

Locale resolution: href is picked per-render based on the landing's
current locale (cookie-driven via useLocale). The docs app itself
does not auto-detect language, so we must pick the right path
explicitly when emitting the link.

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

* fix(onboarding): clean up Autopilot rename leftovers and link formatting

- comments.mdx: "not routine updates" → "not day-to-day updates"
  (adjectival holdover now that the feature is renamed Autopilot;
  zeroes out remaining "routine" mentions in user-facing docs)
- starter-content-templates.ts: move the arrow inside the markdown
  link — "[text →](url)" instead of "→ [text](url)" — so the arrow
  is part of the clickable region. 17 occurrences.

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

* fix(onboarding): drop docs link from welcome screen and starter-content dialog

"Learn how Multica works" was showing up too often in the first two
screens users see. Keep the link in the post-import welcome issue
header (where users actually have time to explore); remove it from
the two earlier surfaces where it competes with the primary CTA.

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-24 14:27:53 +08:00
Naiyuan Qing
fe84e29b64 fix(ui): stop menu hover from overriding icon colors (#1612)
Menu primitives (context/dropdown/menubar/select/command) had rules like
`focus:**:text-accent-foreground` and `*:[svg]:text-destructive` that forced
descendant svg colors on focus, overriding icons that set their own color
(e.g. StatusIcon's `text-warning`). Remove them so icon color comes from
inheritance only: colored icons keep their color on hover, uncolored icons
still inherit the item's focus/destructive color as before.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:26:58 +08:00
Naiyuan Qing
4f40f70ea7 fix(skills): remove double-flicker on CreateSkillDialog close (#1610)
CreateSkillDialog used a controlled \`open\` prop while staying mounted,
so closing meant a data-open → data-closed flip on the already-mounted
Popup plus a tail re-render from \`useEffect([open])\` resetting \`method\`.
Visible as a double-blink: first the close animation, then a second
fade when the reset effect fired.

Align with the CreateIssue / CreateProject pattern: parent conditionally
renders the dialog and \`<Dialog open>\` is hard-coded. Close now unmounts
the component and Base UI's Portal owns the single exit animation. The
per-open method reset becomes unnecessary — fresh mount, fresh state.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:24:50 +08:00
LinYushen
99154d97b9 Restrict /health/realtime metrics exposure (MUL-1342) (#1608)
* Restrict /health/realtime metrics exposure (MUL-1342)

The realtime metrics endpoint was registered on the public router with
no authentication, exposing per-event/per-scope counters, redis.last_error,
and redis.node_id to anonymous callers. This enables information disclosure
and traffic profiling.

Move the handler behind a token + loopback policy:

- If REALTIME_METRICS_TOKEN is set, require Authorization: Bearer <token>
  using a constant-time compare. Reject other callers with 401 plus a
  WWW-Authenticate hint.
- If the env var is unset, only serve loopback callers and return 404 to
  remote clients so the endpoint is not enumerable. This keeps local dev
  workflows working without configuration.

The handler is extracted into health_realtime.go with focused unit tests
covering the token, loopback, and rejection paths. .env.example documents
the new variable.

Refs: https://github.com/multica-ai/multica/issues/1606

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

* Fail closed for proxied /health/realtime requests (MUL-1342)

Addresses review on PR #1608: when the server runs behind a reverse
proxy (Caddy / Nginx -> localhost:8080), public callers reach the Go
handler with RemoteAddr=127.0.0.1, so the previous loopback shortcut
exposed the metrics surface in self-hosted deployments.

The no-token path now treats any forwarding header
(X-Forwarded-For / -Host / -Proto, X-Real-Ip, Forwarded) as a
'this request was proxied, can't attribute, fail closed' signal and
returns 404. Direct loopback callers without those headers still work
for local dev. Token-gated path is unchanged.

Tests cover all listed proxy headers (incl. multi-hop XFF chain and
RFC 7239 Forwarded) over both 127.0.0.1 and ::1, plus a regression
case ensuring an empty/whitespace forwarding header does not break
direct loopback access. .env.example updated to call out that proxied
deployments must configure REALTIME_METRICS_TOKEN.

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

---------

Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 14:04:10 +08:00
Naiyuan Qing
7067d8f125 refactor(skills): redesign list page and add skill detail page (#1607)
* feat(core): add skill detail path and query helpers

- paths.workspace(slug).skillDetail(id) → /:slug/skills/:id
- skillDetailOptions(wsId, skillId) for fetching a single skill
- selectSkillAssignments(agents) folds the cached agent list into
  Map<skillId, Agent[]>; returns a stable reference so consumers can
  memoize against agent-array identity without re-rendering on unrelated
  agent updates

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

* feat(views): add cross-platform openExternal helper

On Electron, route through window.desktopAPI.openExternal so the
http/https-only guard in the main process kicks in — direct window.open
inside Electron opens a new renderer window instead of handing the URL
to the OS shell. On web, fall back to window.open with noopener+noreferrer.
SSR-safe.

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

* refactor(skills): extract edit-permission hook and origin helper

- use-can-edit-skill: mirrors the server's rule (admin/owner ∨ creator)
  so the UI can hide/disable actions instead of waiting for a 403. Takes
  wsId explicitly per the repo rule for workspace-aware hooks.
- lib/origin: discriminated view over Skill.config.origin (manual /
  runtime_local / clawhub / skills_sh) so consumers don't spread JSONB
  parsing across the UI tree.

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

* refactor(skills): rewrite skills list page and collapse import UI

- SkillsPage rewritten: new hero header, single table layout with
  columns (Name / Used by / Source · Added by / Updated), agent avatar
  stack per skill, filter tabs aligned with Issues/MyIssues header
  (Button variant=outline + Tooltip + bg-accent active state).
- CreateSkillDialog: dedicated dialog for the manual/import entry
  points, replaces the inline row-triggered dialog.
- runtime-local import: dialog variant deleted; panel is now the single
  entry point, embeddable inside CreateSkillDialog. Panel covered by a
  new test.
- Deleted runtime-local-skill-row (no longer needed — row rendering
  lives in SkillsPage directly) and the old skills-page.test.tsx
  (structure diverged beyond salvaging; will be re-added alongside the
  detail-page tests).

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

* feat(skills): add skill detail page and wire routes on web and desktop

- SkillDetailPage: dedicated view for a single skill (name, description,
  origin, assignments, file listing). Uses skillDetailOptions and the
  new origin / use-can-edit-skill helpers.
- apps/web: /:workspaceSlug/skills/:id Next.js route.
- apps/desktop: /:slug/skills/:id added to the memory router under
  WorkspaceRouteLayout.

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

* test(skills): bump runtime-local-skill-import-panel timeouts for CI

The test chains a five-step async cascade (runtime list → setSelectedRuntimeId
effect → skills query → auto-select effect → row render). Comfortable on
local (~600ms) but tight against RTL's 1 s default on CI where jsdom +
Vitest import takes ~100s. Bump findByText and the two waitFor calls to
5 s each — no production behaviour change.

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-24 13:51:58 +08:00
devv-eve
9ed1fa95fc feat(server): add readiness health endpoints (#1605)
* feat(server): add readiness health endpoints

* fix(server): cache readiness checks

* fix(server): raise readiness cache ttl

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-24 13:50:24 +08:00
Naiyuan Qing
147fb2ee66 fix(autopilot): confirm before deleting autopilot or trigger (#1604)
Destructive actions in the autopilot detail page fired immediately on
click. Wrap "Delete autopilot" and per-trigger delete with AlertDialog
confirmation, matching the existing issue-delete pattern.

Also fix a latent bug in trigger deletion where the success toast was
shown synchronously after mutate(), so failures still reported success —
switch to mutateAsync + try/catch.
2026-04-24 13:11:52 +08:00
L.Amar
9c177562e2 fix(daemon/repocache): make bare repo cache keys collision-resistant 2026-04-24 13:04:08 +08:00
Naiyuan Qing
5bab95ad26 fix(issues): unify board card hover and active visual (#1603)
Hover and popup-open states now share the same bg-accent + border-accent
treatment. Drop the shadow-md hover (invisible in dark mode) and the
multi-property transition in favor of a single transition-colors.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:55:30 +08:00
Naiyuan Qing
0bd6ba9354 fix(issues): cleaner board card hover with shadow elevation (#1600)
Replace translucent tinted hover (border-accent/50 + bg-accent/20) with
a single-dimension shadow lift. The previous overlay was visually weak
because --accent is nearly identical to --card, so a 20% tint rendered
as almost no change. Active (popup-open) state now uses solid bg-accent
so hover and active are distinguished by different dimensions —
elevation vs color — instead of competing on the same axis.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:15:07 +08:00
Naiyuan Qing
40cea8454d feat(autopilot): redesign modal — simpler schema, consistent schedule UI (#1595)
Drop priority and project_id from autopilot. project_id was never exposed
in the UI and priority duplicated the agent's own task queue priority.

Redesign the create/edit modal as a Runbook (left) + Configuration (right)
layout. Rework the Schedule section around a single visual shell so every
picker aligns pixel-for-pixel on the same row:

- TimeInput (new): segmented HH:MM control adapted from openstatusHQ/time-picker,
  driven by keyboard (ArrowUp/Down to step, ArrowLeft/Right to jump segment,
  digit typing with a 2s two-digit window). Replaces <input type="time">,
  whose native UI broke the design system. Supports a minuteOnly variant
  for hourly schedules.
- TimezonePicker (new): searchable Popover with a fixed-width left check
  slot so rows stay aligned and GMT offsets never collide with the selected
  indicator.
- Runbook editor now lives in a bordered card, giving the placeholder an
  input surface instead of bare document flow.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:05:33 +08:00
Naiyuan Qing
d54daa62c5 feat(issues): right-click context menu + unified issue actions (#1594)
* feat(issues): add right-click context menu on list rows and board cards

Extract the detail page's ⋯ dropdown (~180 lines of inline JSX) into a
shared `useIssueActions` hook plus two thin wrappers so the same action
set (status / priority / assignee / due date / sub-issue ops / pin / copy
link / delete) can be mounted as both a DropdownMenu and a Base UI
ContextMenu. Right-click on any list row or board card now opens the
full action menu without entering the detail page.

Shell-level modals replace the detail-page-local state for set-parent /
add-child / delete-confirm / backlog-agent-hint, so any trigger (detail
page, list, board) can open them through `useModalStore`. Detail page
detects its own deletion via a query-transition effect, avoiding the
need to smuggle callbacks through the store.

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

* feat(issues): hover and active styling on list rows and board cards

Mirror the sidebar's same-color/different-intensity pattern for the new
right-click context menu states. Base UI adds `data-popup-open` to the
ContextMenuTrigger when the menu is open; `hover:not-data-[popup-open]`
suppresses hover feedback on the already-active item.

List rows apply the pattern directly to background color (`accent/60`
hover, `accent` active). Board cards additionally modulate the card's
border and a lighter background tint (`accent/20` hover, `accent/40`
active) so the card's own bg/border/shadow identity stays intact.

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

* feat(modals): show target issue banner in SetParent/AddChild pickers

When triggered from an issue's action menu, the IssuePickerModal now
displays a banner at the top showing "Setting parent of" / "Adding
sub-issue to" followed by the originating issue's status, identifier,
and title. Previously the operation target was only implied by the
modal's sr-only title.

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

* feat(modals): create-issue gains ⋯ overflow menu with parent issue linkage

Add a dropdown-menu with "Set parent issue..." / "Remove parent" at the
end of the property pill row. The ⋯ button is always the last DOM child
of the row so it stays at the tail even when the row wraps to multiple
lines. Menu state reflects current selection — unset shows a single
"Set parent…" entry, set shows the current identifier plus a separate
Remove option.

When a parent is set (either via the new menu or via `data.parent_issue_id`
from a "Create sub-issue" trigger), a chip appears in the pill row
showing "Sub-issue of {identifier}" with the same click-to-change /
click-×-to-clear semantics. This replaces the old header breadcrumb
disclosure that was neither editable nor visible in the form.

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

* refactor(issues): group relationship actions under "More" submenu

Nest Create sub-issue / Set parent issue / Add sub-issue inside a
`More >` submenu in the issue actions menu (both Dropdown and
Context variants). Top-level keeps Status/Priority/Assignee/Due date
category submenus plus Pin and Copy link; the relationship ops are
lower-frequency and will grow with future relation types (blocks,
duplicates, related) that fit the same category.

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

* feat(modals): create-issue adds Add sub-issue with deferred linking

The create modal's ⋯ menu gains an "Add sub-issue..." entry that queues
existing issues as children of the new one. Picked issues appear as
chips in the pill row (downward arrow, distinct from the upward parent
chip), each individually removable.

Linking is deferred because the new issue's ID doesn't exist at pick
time. Once createIssueMutation resolves, we run updateIssueMutation
for every queued child in parallel and surface any partial failures
via toast — the new issue itself is already committed and never rolls
back. Parent and child pickers exclude each other so a single issue
can't occupy both relations simultaneously.

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

* polish(issues): add MoreHorizontal icon to "More" submenu trigger

The "More" label was visually misaligned because every other top-level
entry has a leading icon. Use MoreHorizontal (same icon as the outer ⋯
trigger — semantically "more options, nested") and drop the `inset`
padding hack.

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

* revert(modals): drop target-issue banner from IssuePickerModal

The banner sat directly above the search input and rendered the target
issue with bolder styling than the "Setting parent of" / "Adding sub-issue
to" caption, which made it read like a pre-selected search result rather
than a context label. Users opening the modal from a menu item already
carry the context, so the extra chrome was redundant.

Remove the contextIssue / contextLabel API from IssuePickerModal and
drop the now-unused issueDetailOptions query in SetParentIssueModal.

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

* polish(modals): exclude current parent from create-issue parent picker

Re-opening the parent picker to change the already-set parent used to
show that parent in the results — picking it was a silent no-op. Mirror
the child picker's exclude-list construction so the current parent is
always filtered out.

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-24 10:48:46 +08:00
Naiyuan Qing
8c2e08418f feat(docs-site): rewrite docs as bilingual flat content tree (#1591)
* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:30:54 +08:00
Jiayuan Zhang
71cc646951 fix(chat): prevent UI flicker when streaming response finalizes (#1583)
The live timeline was rendered in a separate <div> from the persisted
messages list. When the streamed task finished and its ChatMessage
landed, the live <div> unmounted and a new <MessageBubble> mounted —
two different DOM elements showing the same content. useAutoScroll's
ResizeObserver + MutationObserver fired on both the unmount and the
mount, causing the visible jump-then-re-render.

Merge the two paths: inject a synthetic assistant message with the
pending task_id while streaming, and key every assistant bubble by
task_id. When the real message arrives (same task_id), React preserves
the DOM element across the invalidate → refetch window — no remount,
no double scroll, no flicker.

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 02:01:12 +08:00
Jiayuan Zhang
bb767e0ea6 fix(chat): prevent chatbox jump when sending first message (#1582)
The ChatInput wrapper toggled between pb-8 (empty state) and pb-4
(has messages), causing a 16px vertical jump the moment hasMessages
flipped. EmptyState already centers itself inside flex-1, so the
extra padding wasn't needed — collapse to a single pb-4.

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 02:00:12 +08:00
Jiayuan Zhang
35aca57939 feat(chat): Chat V2 — sidebar entry + main-area page (#1580)
* feat(chat): Chat V2 — sidebar entry + main-area page

Replace the floating drawer + FAB with a first-class workspace route
`/:slug/chat`. Sidebar gets a single `Chat` entry under Inbox with an
unread dot; session history lives inside the Chat tab via a popover
rather than leaking into the global sidebar (keeps Multica's "nouns in
the nav" semantic — Inbox / Issues / Projects are work objects, Chat is
a tool).

- Add `paths.workspace(slug).chat()` + update link-handler route set.
- New `ChatPage` view with PageHeader, history popover, centered
  messages/composer column, and empty-state starter prompts.
- Delete `ChatWindow`, `ChatFab`, resize helpers, and standalone
  `ChatSessionHistory` (history now embedded in the popover).
- Drop `isOpen`/`toggle`/`showHistory`/resize fields from `useChatStore`
  — the page is a route now, not an overlay.
- Wire the new `/chat` route on web (App Router) and desktop
  (react-router + tab-store icon mapping).

Addresses MUL-1322.

* fix(chat): align composer width with message column

The ChatPage wrapper added px-4 on top of ChatInput's own px-5, making
the composer 32px narrower than the messages column. Drop the outer
px-4 so both share the same max-w-3xl outer + px-5 inner padding
provided by ChatMessageList / ChatInput.

* fix(chat): taller default composer (~3 lines visible, 8 max)

min-h 4rem → 7rem, max-h 10rem → 15rem. Empty state previously
showed only 1 text row after pb-9 for the action bar; raise the
floor so there's visible writing room and lift the ceiling so a
longer draft can grow before scrolling kicks in.

* fix(chat): restore anchor + in-flight indicator + cold-start session restore

Three issues surfaced by review:

1. ContextAnchorButton always disabled on /:slug/chat — useRouteAnchorCandidate
   only matches issue/project/inbox pathnames, so moving chat to its own route
   dropped 'bring the page I was on into the conversation'. Track the last
   anchor-eligible location globally (new useAnchorTracker mounted in AppSidebar
   + lastAnchorLocation on useChatStore) and substitute it when on /chat.

2. No global 'Multica is working' cue after ChatFab deletion. Subscribe the
   sidebar Chat entry to pendingChatTasksOptions and swap the unread dot for a
   spinner while any chat task is in flight.

3. ChatPage restore effect latched didRestoreRef before the sessions query
   resolved, so cold-start direct nav to /chat landed on the empty state even
   when the server had an active session. Wait for isSuccess before locking
   the ref.

* fix(chat): clear lastAnchorLocation on workspace rehydration

The pathname captured in workspace A would otherwise be reused against
workspace B's wsId, triggering a cross-workspace issue/project fetch
and silently leaking anchor context into chat messages.

---------

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 01:46:37 +08:00
Bohan Jiang
e0e91fc792 feat(daemon): harden agent mention-loop instructions (#1581)
* feat(daemon): harden agent mention-loop instructions

Two agents that mention each other via `mention://agent/<id>` can fall into
an infinite reply loop — each says "I'm done" in prose but keeps
`@mentioning` the other, which re-enqueues their run. Adding hard caps on
agent-to-agent turns conflicts with Multica's design principle of giving
agents the same authorship freedom as humans, so this change hardens the
instructions that the harness injects instead.

- Replace the terse "mentions are actions" blurb with a full Mentions
  protocol: `side-effecting` warning, explicit "when NOT to mention"
  (replying to another agent, sign-offs, thanks) and "when a mention IS
  appropriate" (human escalation, first-time delegation, user asked).
- Add a pre-workflow decision step for comment-triggered runs: decide
  whether a reply is warranted at all, decide whether to include any
  `@mention`, and clarify that the post-a-comment rule is mandatory *if*
  you reply — silence is a valid exit for agent-to-agent threads.
- Thread the triggering comment's author kind + display name
  (`TriggerAuthorType` / `TriggerAuthorName`) from the claim endpoint
  through the daemon task type, per-turn prompt, and CLAUDE.md workflow.
  When the author is another agent, both surfaces now name that agent
  and warn against sign-off mentions.
- Soften the old closing line that told agents to `always` use the
  mention format — the word generalized to member/agent mentions and
  encouraged the very behavior that causes loops.

Refs GH#1576, MUL-1323.

* fix(daemon): remove MUST-respond conflict and sanitize trigger author name

Addresses two blocking points on PR #1581:

1. buildCommentPrompt told the agent "You MUST respond to THIS comment"
   and unconditionally appended the reply command — directly conflicting
   with the new agent-to-agent silence-as-valid-exit workflow. Models
   were likely to keep following the older must-reply rule and fall back
   into the loop this PR is trying to close.

   Rewrite the header as "Focus on THIS comment — do not confuse it
   with previous ones" (keeps the anti-stale-comment signal) and change
   BuildCommentReplyInstructions to open with "If you decide to reply,
   post it by running exactly this command" so the reply command is
   available but conditional across both prompt surfaces.

2. Raw agent/user display names were being embedded directly into the
   high-priority prompt and CLAUDE.md via TriggerAuthorName. Agent and
   member names are only validated as non-empty at write time, so a
   name containing newlines, backticks, or fake mention markup would
   turn the field into a cross-agent prompt-injection surface.

   Add execenv.SanitizePromptField — strip control runes, collapse
   whitespace, drop markdown structural characters (backtick, asterisk,
   brackets, pipe, angle brackets, hash, backslash), truncate to 64
   runes — and apply it at both embed sites (per-turn prompt and
   CLAUDE.md). Defense-in-depth at the consumption layer so this works
   for already-stored names without a migration.

Tests: TestSanitizePromptField covers the policy; TestBuildPromptSanitizesAgentName
plants an attack payload in TriggerAuthorName and checks the rendered prompt
does not leak the newline-anchored injection or the fake mention markup.
TestBuildPromptCommentTriggered*{,ByMember} updated to lock in the
conditional reply-command framing.

* refactor(daemon): trim redundant CLAUDE.md preamble and drop name sanitizer

Per PR #1581 feedback:

1. Remove the `if ctx.TriggerAuthorType == "agent"` preamble block in
   runtime_config.go. It duplicated what workflow steps 4 and 5 already
   say ("Decide whether a reply is warranted", "Never @mention the
   agent you are replying to as a thank-you or sign-off"), so the
   signal lands the same without the extra ~7 lines of CLAUDE.md. The
   per-turn prompt preamble in prompt.go stays — that surface has no
   numbered workflow below it and would otherwise lose the
   silence-as-exit signal.

2. Delete execenv.SanitizePromptField + its test. Workspace agents are
   created by trusted team members, so the cross-agent name-injection
   surface it defended isn't realistic in the current trust model.

3. Drop TriggerAuthorType/Name from execenv.TaskContextForEnv and stop
   populating them in daemon.go — they're no longer read by the
   execenv package. The same fields on daemon.Task stay because
   prompt.go still needs them to label the triggering author in the
   per-turn prompt.

Tests simplified to match the leaner shape: CLAUDE.md regression
guards now assert that the anti-loop phrases live in the numbered
workflow, and the sanitizer-specific tests are removed.
2026-04-24 01:39:12 +08:00
Jiayuan Zhang
977b0c0558 feat(agents): show profile card on agent avatar hover (#1577)
* feat(agents): show profile card on agent avatar hover

Hovering an agent avatar now opens a preview card with name, status,
runtime mode + connectivity, model, skills, and owner. Wired through
the shared ActorAvatar wrapper so every render site gets it; opt-out
via disableHoverCard in pickers and the agent's own detail header
where the card would be redundant or interfere with click selection.

* fix(agents): keyboard-focusable hover card + opt out on settings avatar

- Make the agent profile-card hover trigger focusable (tabIndex=0 with
  visible focus ring), so keyboard users can open the card. Drops
  cursor-default so the trigger inherits the parent control's cursor
  instead of fighting it.
- Disable the hover card on the agent settings avatar — it's a
  click-to-upload target on the agent's own settings page, where the
  card would be redundant and the trigger conflicted with the upload
  affordance.

* fix(agents): scope hover-card tab stop to standalone avatars only

Detect a focusable ancestor (link/button/role=button/tabindex>=0) at
mount and only flip the agent profile-card trigger to tabIndex=0 when
none exists. Avatars rendered inside an existing focusable parent (issue
list rows wrapped in AppLink, button-style cards, etc.) keep the trigger
unfocusable so they don't add redundant nested tab stops or bloat
keyboard navigation. Standalone avatars (e.g. comment author, issue
detail meta) remain keyboard-accessible with a focus-visible ring.

---------

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 00:53:55 +08:00
Black
17136742b9 fix(runtimes): fix dark mode chart visibility and invalid CSS color syntax (#1573)
All chart components used `hsl(var(--chart-X))` but `--chart-X` holds a
full oklch value, not bare HSL components — making the expression invalid
CSS. Browsers silently fell back to black, so bars/areas/heatmap cells were
invisible against the dark background.

- Replace `hsl(var(--chart-X))` with `var(--color-chart-X)` across all
  runtime chart components and the landing feature section
- Fix heatmap opacity using `color-mix(in oklch, ...)` instead of the
  invalid `hsl(var(--chart-3) / 0.3)` syntax; switch to foreground color
  so cells blend with the neutral theme in both light and dark mode
- Raise dark-mode chart-2 through chart-5 lightness values so they
  contrast clearly against the dark background

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 00:47:41 +08:00
Jiayuan Zhang
5e51f5b356 feat(desktop): add right-click context menu with clipboard actions (#1575)
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 00:11:16 +08:00
Jiayuan Zhang
13daede63e docs: remove Star History chart from README (#1574)
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 00:09:09 +08:00
Bohan Jiang
6107211a6e docs(selfhost): correct WebSocket guidance for LAN access (#1567)
The previous note claimed the frontend's auto-derived WebSocket URL
worked on LAN without extra configuration. It does not: Next.js
`rewrites()` only proxy HTTP requests, so the `Upgrade` handshake
required for WebSocket never reaches the Go backend, and real-time
features (chat streaming, live issue updates, notifications) silently
fail when accessing the app via a non-localhost host.

Replace the incorrect sentence with a dedicated subsection that points
users at the reverse-proxy recipe (already documented above, includes
the correct /ws Upgrade headers) and, for setups without a proxy,
documents the build-time NEXT_PUBLIC_WS_URL + selfhost.build.yml
override path.

Refs: GH #1522
2026-04-23 18:25:02 +08:00
Naiyuan Qing
044d1443b5 fix(issues): keep reply editor expand icon muted on focus (#1565)
The expand button relied on the parent row's inherited color, which
flipped to text-foreground via group-focus-within while the editor was
focused. The attach and submit buttons set text-muted-foreground on
themselves and stayed muted regardless of focus, so expand was the only
one changing color — inconsistent with the "default muted" convention
the other icon-buttons in this editor follow.

Give expand its own text-muted-foreground and drop the now-unused color
classes from the button row container.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:05:36 +08:00
Bohan Jiang
8f10741a4d feat(daemon/gc): tighten GC defaults + flex duration suffix (#1559)
* feat(daemon/gc): tighten GC defaults + flex duration suffix

Driven by user feedback in #1539 (40 GB VPS filling within 24h of heavy
AI-coding usage): the existing TTLs were sized for desktop/laptop
deployments and are too lenient for small-disk, long-running daemons.

- GCTTL: 5d → 24h. Done/canceled issues almost never need a multi-day
  grace period in AI-coding workflows.
- GCOrphanTTL: 30d → 72h. Covers crash-leftover and pre-GC directories
  without a month-long wait.
- Issue-deleted orphans (API returns 404) are now cleaned on the next GC
  cycle regardless of mtime. The issue row is gone; there is nothing
  left to protect.
- parseFlexDuration: accept a `d` (day) suffix in addition to the stdlib
  time.ParseDuration syntax. MULTICA_GC_TTL=5d now works; previously only
  120h was accepted.

* fix(daemon/gc): address review — 404 safety + decimal/overflow in duration parser

Two issues flagged in PR review:

1. 404-immediate-clean is unsafe. The /gc-check endpoint returns 404 for
   both "issue deleted" AND "daemon token has no access to the workspace"
   (anti-enumeration, see requireDaemonWorkspaceAccess). Clean-on-404
   would let a scoped-down daemon token wipe taskDirs whose issues are
   still live. Restore the mtime gate against GCOrphanTTL. With the new
   72h default we still shrink the original 30d window dramatically
   without the cross-workspace hazard. Lock the behavior in with a new
   test that asserts a recent 404 is skipped.

2. parseFlexDuration mishandled decimals and swallowed Atoi errors:
   "0.5d" → 7m12s (regex matched only the "5d"), "1.5d" → 1h7m12s,
   and 20+ digit day values Atoi-errored silently to 0. Match the full
   decimal number with `\d*\.\d+|\d+` and parse with ParseFloat so
   fractional days and oversized inputs both go through
   time.ParseDuration correctly — fractions as sub-hour durations,
   overflow as a returned error.
2026-04-23 17:40:09 +08:00
Bohan Jiang
cbe0cbef56 fix(daemon): retry local-skill reports on transient server errors (#1561)
Review follow-up on PR #1557: the server-side change started returning
500 when the store write failed, but the daemon's handleLocalSkillList /
handleLocalSkillImport were discarding the ReportLocalSkill*Result error
return. Net effect was a silent drop — the daemon moved on, the request
stayed in "running" on the server, and the user saw the same "daemon did
not respond within 30 seconds" timeout the store refactor was supposed
to kill.

Fix: route both report calls through reportLocalSkillResultWithRetry,
which retries on 5xx + network errors with 0 / 0.5s / 2s / 4s backoff
(total ~6.5s, well inside the 60s server-side running timeout), stops
on 4xx (request expired / cross-workspace rejection — retry won't help),
bails on context cancel, and logs Error on exhaustion so ops has a
footprint to grep for.

Tests (server/internal/daemon/local_skill_report_test.go, 6 new cases):
- 500 twice then success -> 3 attempts, second retry lands
- 404 -> exactly 1 attempt (permanent, no retry)
- import 502 then success -> 2 attempts
- All-500 -> burns through all backoff slots then gives up with ERROR log
- Context cancel mid-backoff -> exactly 1 attempt, cancellation logged
- Smoke: report paths hit /api/daemon/runtimes/<rt>/local-skills{,import}/<req>/result

localSkillReportBackoffs is var-assignable so tests can swap in zero-delay
schedules without paying real sleep latency.
2026-04-23 17:39:20 +08:00
Naiyuan Qing
502add4bd1 fix(issues): restore compact single-line reply editor, keep expand overlap fix (#1562)
#1558 fixed the expand button covering trailing text, but also collapsed
the reply editor's "empty = 1 line, has content = 2 lines" behavior by
making the button row a permanent flex sibling below the editor.

Restore the original absolute-positioned button row on both editors:

- comment-input: back to `pb-8` container + `absolute bottom-1 right-1.5`
  buttons (pre-#1558 layout; never had the overlap bug).
- reply-input: absolute buttons + `pb-7` gated on `!isEmpty || isExpanded`.
  Empty → single-line compact; any content → two-row layout with buttons
  below text (no overlap by construction).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:37:36 +08:00
affe (Yufei Zhang)
5ef957ca1b fix(skills): resolve aliased skills.sh imports (#1432)
* fix(skills): resolve aliased skills.sh imports

* fix(skills): harden alias fallback scan
2026-04-23 17:33:30 +08:00
Kagura
6d9ca9de93 fix(daemon): suppress agent terminal windows on Windows (#1474)
* fix(daemon): suppress agent terminal windows on Windows (#1471)

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

* fix: add hideAgentWindow to detectCLIVersion and avoid SysProcAttr overwrite

- Add missing hideAgentWindow(cmd) call in detectCLIVersion (claude.go:554)
  so --version checks don't flash console windows on Windows.
- Refactor hideAgentWindow to preserve existing SysProcAttr fields
  instead of overwriting the entire struct.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 17:23:00 +08:00
Naiyuan Qing
e994d77982 feat(help): mark external links with arrow, move Feedback last (#1560)
Add an ArrowUpRight glyph next to Docs and Change log to signal they
open externally, and reorder so Feedback (internal modal) sits at the
bottom.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:18:18 +08:00
Bohan Jiang
ad803b86ec fix(skills): shared-state runtime local-skill stores (MUL-1288) (#1557)
* fix(skills): shared-state runtime local-skill stores (MUL-1288)

Fixes the bug Bohan surfaced on MUL-1288: behind prod's multi-node API the
runtime-local-skill list/import flow would intermittently time out or 404.
Root cause: LocalSkillListStore and LocalSkillImportStore were per-process
sync.Mutex+map, so when the frontend POST, the daemon heartbeat and the
frontend GET landed on different API instances, each saw a different
pending set. Confirmed against production daemon logs — the failed
request_id never showed up in the daemon's "runtime local skills
requested" log, even though other requests around the same window worked.

Per Yushen's guidance (server must stay stateless; state lives in
storage), migrate both stores to Redis so every node agrees on the same
pending set.

What changed
- LocalSkillListStore / LocalSkillImportStore are now interfaces. Methods
  take context.Context and return error.
- InMemoryLocalSkill{List,Import}Store — renamed from the existing types,
  kept as the default for single-node dev and the in-process test suite.
- RedisLocalSkill{List,Import}Store — new. Keyed on
  mul:local_skill:{list,import}:<id> (JSON record, TTL = retention), with
  a per-runtime ZSET mul:local_skill:{list,import}:pending:<runtime_id>
  (score = created_at UnixNano) providing cross-node ordering. PopPending
  wins the claim via ZREM == 1, so concurrent pops from different nodes
  never return the same request twice.
- NewRouter gets an optional *redis.Client; when non-nil it swaps in the
  Redis-backed stores. main.go hoists the existing Redis client (already
  used by the realtime relay) so both subsystems share one client.
- Handler fields flip to interface types; handler.New still constructs
  in-memory stores by default.
- Daemon heartbeat's PopPending call sites thread r.Context() through so
  Redis operations inherit request cancellation. Errors warn instead of
  poisoning the heartbeat response.

Tests
- Existing in-memory tests updated for the new signatures (ctx + error).
- New runtime_local_skills_redis_store_test.go covers:
  - Create/Get/Complete round trip preserves skills payload
  - PopPending across two *store instances sharing one rdb (the exact
    regression: node A creates, node B pops)
  - N concurrent PopPending on one record => exactly one winner
  - Pending-timeout threshold transitions the record and removes the zset
    member so a later PopPending doesn't return a timed-out request
  - Import store round-trips CreatorID (which is json:"-" on the public
    struct — needs a Redis envelope so ReportLocalSkillImportResult can
    still attribute the created Skill)
  - Per-runtime isolation — a PopPending for runtime B does not disturb
    A's pending zset
- Tests skip gracefully if REDIS_TEST_URL is unset; CI now spins up a
  redis:7-alpine service and exports the URL so the suite actually runs
  there.

Out of scope
PingStore / UpdateStore / ModelListStore have the same shape and the
same latent bug (they just fire rarely enough to have gone unnoticed).
Migrating them to Redis is a follow-up — MUL-1288 is specifically the
local-skills break Bohan is blocked on.

* fix(skills): atomic Redis claim + surface store write failures (PR #1557 review)

Two real gaps GPT-Boy flagged:

1. RedisLocalSkill{List,Import}Store.PopPending was doing ZREM then SET as
   two separate round-trips. If the SET failed for any reason — transient
   Redis error, context cancellation, pod getting SIGKILL'd mid-call — the
   request was already gone from the pending zset but the stored record
   still said "pending", and no subsequent PopPending would re-dispatch
   it. Exactly the "request disappears" class of bug this PR is supposed
   to kill.

   Fix: push the claim into a Lua script so Redis runs ZREM + SET as one
   atomic unit. If ZREM returns 0 (another node won the race), SET is
   skipped and the caller retries.

2. ReportLocalSkill{List,Import}Result handlers were logging Complete/Fail
   store failures at Warn and still returning 200 OK. That made the
   daemon think the report landed when it hadn't, leaving the request
   stuck in "running" until the server-side timeout and — worse for the
   import flow — leaving the just-created Skill row orphaned in Postgres
   so every retry collided with the unique-name constraint.

   Fix: escalate to Error + return 500 so the daemon (and monitoring) can
   see the write failed. For the import flow, Complete failure after the
   Skill row is already committed also triggers a best-effort DeleteSkill
   so a daemon retry lands on a clean slate instead of hitting
   "a skill with this name already exists" forever.

Tests
- New TestRedisLocalSkillListStore_PopPendingAtomicClaim asserts the
  happy-path invariant: after one PopPending the record is "running"
  AND a second PopPending returns nothing. Deliberately does NOT poke
  Redis internals directly so the test survives any future key-layout
  refactor.
- Existing cross-instance / concurrent / timeout / per-runtime tests
  continue to pass against the Lua-based claim path (verified locally
  against a scratch redis-server; 8/8 Redis tests green).
2026-04-23 17:07:34 +08:00
Bohan Jiang
b51d1c4dc3 fix(cli): make browser-login work from a machine that isn't the server (#1556)
* fix(cli): make browser-login work from a machine that isn't the server

The #923 callback host fix only worked when the CLI and the self-hosted
server ran on the same box. In a cross-machine setup — `multica login`
from a laptop against a self-hosted server on a NAS — the flow silently
wedged on two issues:

1. The callback host was derived from `--app-url`, so the `cli_callback`
   URL pointed at the server's IP and the browser could never reach
   the CLI's local listener on the laptop. The OAuth token never came
   back and subsequent `/api/workspaces` calls 401'd on stale state.

2. `net.Listen("tcp", ...)` on macOS can produce an IPv6-only socket.
   Browsers and `curl` resolve `localhost`/`127.0.0.1` to IPv4 first and
   get "connection refused" even when the URL is otherwise correct.

Changes:

- Derive the callback host from the CLI's own outbound interface by
  dialing the server (UDP, no packets sent — just asks the kernel which
  source IP it would use). Falls back to loopback for public app URLs
  and to the app IP for offline detection.
- Add `--callback-host` flag on `login` and `setup self-host` so
  reverse-proxy / FQDN users can override auto-detection — this is the
  follow-up @hassaanz asked for on #923.
- Pin the callback listener to `tcp4` so macOS never lands on an
  IPv6-only socket.
- `multica setup self-host`: when the user explicitly passes a remote
  `--server-url` but omits `--app-url`, infer app URL from the server
  host and warn instead of silently defaulting to `localhost:3000`.

Unit tests cover the binding-decision matrix (public, localhost, same-
machine LAN, cross-machine LAN, outbound-detect failure, flag override)
and the new setup helpers.

Reported by @RafeRoberts in #1494 with very clear repro details.

* fix(cli): prompt for app_url instead of guessing on remote server_url

Per GPT-Boy's review on MUL-1260: deriving app_url as
http://<server-host>:3000 breaks for the common api.example.com +
app.example.com split and for https-fronted deploys — the setup flow
would still open a broken login URL, just slightly later.

Replace the guess with an interactive prompt. If the user hits enter
(or stdin is unavailable), fail loudly with a clear usage hint instead
of proceeding with bad data.
2026-04-23 16:41:29 +08:00
Naiyuan Qing
efc08a1e37 fix(issues): stop expand button from covering text in comment/reply editors (MUL-1297) (#1558)
The comment and reply editors positioned their three trailing buttons
(expand, attach, submit) with `absolute` and relied on `pr-14` /
`pb-8` magic numbers to reserve space. The reserved 56px is smaller
than the actual 80px button row, so the leftmost button (expand)
visibly overlaps the trailing characters of a long line of text.

Restructure the button row as a normal flex sibling below the editor.
Text can no longer flow under the buttons, and the layout no longer
needs the `pr-14` hack, `pb-8` padding, or the ResizeObserver that
toggled `pb-7` when content overflowed.

Also align the expand button in comment-input with the reply-input
version (`h-6 w-6` + `h-3.5 w-3.5` icon) so the two entry points
match.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:38:04 +08:00
Bohan Jiang
6fd1255873 feat(runtimes): remove Test Connection / runtime ping feature (#1554)
* feat(runtimes): remove Test Connection / runtime ping feature

The Test Connection action invoked a real single-turn agent run to verify
runtime connectivity. In practice it was expensive (reuses none of the
normal task exec env, so it also gave misleading results) and low value —
daemon heartbeat + Online status already covers the "is the runtime
alive" question. Dropping the whole end-to-end probe path:

- deletes server handler and in-memory PingStore
- drops pending_ping from the heartbeat response and daemon poll loop
- removes daemon.handlePing, PendingPing, ReportPingResult
- removes the CLI `multica runtime ping` command
- removes the PingSection UI block and RuntimePing types / api methods

* docs: fix runtime CLI subcommand list in product-overview
2026-04-23 16:18:21 +08:00
Naiyuan Qing
6c72c71e3e feat(analytics): add onboarding_runtime_detected event on desktop Step 3 (#1553)
Answers "did the user have an AI CLI installed locally when they hit
Step 3" — currently unanswerable from the existing funnel because the
bundled daemon fails to register at all when zero CLIs are on PATH, so
`runtime_registered` is silent on that cohort. Splits the 40% of
`completion_path=runtime_skipped` into "had CLIs, skipped anyway" vs "no
CLIs available, had no choice" — the two cases need opposite product
fixes.

Fires once per Step 3 mount in `step-runtime-connect.tsx` (desktop
only), when the scanning phase resolves — either immediately on first
runtime registration or after the 5 s empty timeout. Reports
`runtime_count`, `online_count`, sorted `providers`, convenience
booleans (`has_claude` / `has_codex` / `has_cursor`), and `detect_ms`.
Also writes `has_any_cli` + `detected_cli_count` via `$set` as cohort
signals.

Not emitted from the web Step 3 (`step-platform-fork.tsx`) — web users
don't run the bundled daemon, so their runtime list can reflect
daemons on other machines and would corrupt the
"CLI installed locally" signal.

Refs MUL-1250.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:56:55 +08:00
Jiayuan Zhang
83a3683d07 feat(landing): add sticky date navigation to changelog page (#1552)
* feat(landing): add sticky date navigation to changelog page

Adds a right-side "On this page" nav that lists every release date and
scroll-spies the active entry as the user reads through the changelog.
Dates are formatted per locale (e.g. "April 22" / "4月22日").

* feat(landing): move changelog date nav to left as timeline sidebar

Moves the date navigation from the right to the left and restyles it
as a grouped timeline:

- Releases are grouped under a month-year header ("April 2026").
- A vertical rail connects a dot per release; the active dot is filled
  with a soft halo ring, the row text goes full-opacity + semibold.
- Clicking a date smooth-scrolls to the release and pins the hash; a
  short nav lock suppresses scroll-spy flicker while the page animates.
- Sidebar is sticky up to viewport height, scrollable when there are
  many releases; on <lg the sidebar collapses and content falls back
  to the existing centered layout.
- Entry headers now render the full localized date for clarity.

Label changed from "On this page" / "本页目录" to "All releases" /
"历史版本" to match the new nav-style role.

* fix(landing): align changelog nav day/version columns

Reserve a fixed-width right-aligned slot for the day number so
single-digit days (e.g. "1", "9") don't shift the version column.

---------

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-23 15:54:06 +08:00
Bohan Jiang
fae3afee79 fix(agents): drop auto-loading Local Runtime Skills section from Skills tab (#1551)
* fix(agents): drop auto-loading Local Runtime Skills section from Skills tab

Every visit to an agent's Skills tab fired POST
/api/runtimes/<id>/local-skills + a polling GET, which:

- Created noise on every tab open (the section was rarely the user's
  reason for entering the tab — they came in for workspace skills).
- Currently 404s under the dev backend's multi-replica deploy because
  the runtime-local-skills request store is in-process; the polling
  GET frequently lands on a different replica than the POST. The
  protocol fix is tracked separately; this PR just stops the
  unsolicited polling.

Removes the entire `Local Runtime Skills` inline section, the
`runtimeLocalSkillsOptions` query, and the per-skill Import dialog
mount on this tab. Users who want to import a local skill go through
the Skills page's `+ Add Skill` → `From Runtime` tab — the same flow
that handles all other skill creation, only triggered explicitly.

Top blue callout stays — still accurate: local runtime skills are
auto-available to the agent, importing creates an editable workspace
copy.

* test(agents): replace stale Local Runtime Skills assertion with negative case

The previous test required the inline section + auto-loading runtime
local skills query, both removed in this PR. Replace it with a
regression test that asserts the section is gone, the per-row import
button is gone, and the top informational callout still renders so we
know the tab body actually mounted.

Drops the now-unused @multica/core/runtimes mock; if a future change
re-introduces that import, the missing mock would surface immediately.
2026-04-23 15:47:29 +08:00
LinYushen
91424752ac feat(realtime): phase 0 — extract Broadcaster interface + add metrics (MUL-1138) (#1429)
* feat(realtime): phase 0 — extract Broadcaster interface + add metrics

Phase 0 of the WebSocket horizontal-scaling plan tracked in MUL-1138.
This change is intentionally behavior-preserving: it sets up the seams
needed for later phases (subscribe/unsubscribe protocol, scope-level
fanout, Redis Streams relay) without altering any wire protocol or
producer call sites.

What changed
- New realtime.Broadcaster interface covering the three fanout methods
  producers already use on *Hub (BroadcastToWorkspace, SendToUser,
  Broadcast). *Hub continues to satisfy it; a future Redis-backed
  implementation can be dropped in without touching listeners.
- registerListeners now depends on realtime.Broadcaster instead of
  *realtime.Hub, isolating the bus → realtime fanout layer behind an
  interface.
- New realtime.Metrics singleton with atomic counters: connects,
  disconnects, active connections, slow-client evictions, total
  messages sent/dropped, and per-event-type send counters. Wired into
  Hub register/unregister/broadcast paths and into every listener.
- New GET /health/realtime endpoint returning a JSON snapshot of the
  metrics so we can observe baseline fanout pressure before phase 1.

Why phase 0 first
GPT-Boy's only-Redis plan and CC-Girl's review both call out the same
prerequisite: get a Broadcaster seam and visibility in place before
introducing scope-level subscriptions or a Redis relay. Doing this as
a standalone step keeps each later PR focused and trivially revertable.

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

* feat(realtime): only-Redis fanout — scopes, subscribe protocol, Redis Streams relay (MUL-1138)

Implements the final-version plan agreed in MUL-1138 on top of phase 0:

* Hub: 4 scope types (workspace/user/task/chat), per-client subscription
  set, subscribe/unsubscribe WS frames, ScopeAuthorizer hook for
  task/chat scope auth, first/last-subscriber callbacks for the relay,
  workspace+user auto-subscribe on connect.
* RedisRelay: Broadcaster impl that XADDs every event into
  ws:scope:{type}:{id}:stream and XREADGROUPs only the scopes for which
  this node has live subscribers. Per-node consumer group, heartbeat,
  stale-consumer sweeper, MAXLEN cap, lag/disconnect metrics.
* Listeners: route task:* events to ScopeTask, chat:* events to
  ScopeChat; workspace remains the default for everything else.
* events.Event: optional TaskID / ChatSessionID hints so the listener
  layer can pick the right scope without re-parsing payloads.
* Handler: publishTask / publishChat helpers; chat + task message
  publishers updated to use them.
* main.go: when REDIS_URL is set, wrap the hub with NewRedisRelay and
  pass the relay (instead of the hub) to registerListeners. A
  db-backed ScopeAuthorizer enforces that task/chat subscribes belong
  to the caller's workspace.
* Metrics: per-scope subscribe/deny counters, redis connect state, node
  id, lag/dropped counters surfaced via /health/realtime.

Behavior in single-node mode (REDIS_URL unset) is unchanged.

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

* fix(realtime): address PR #1429 review must-fix items (MUL-1138)

- listeners: keep task/chat events on workspace fanout until the WS
  client supports scope-subscribe + reconnect-replay. Routing them
  through BroadcastToScope today (without any client subscriber) would
  silently drop every chat / task message and break the live timeline,
  chat unread badges, and pending-task UI. The server-side scope infra
  (Hub subscribe/unsubscribe, ScopeAuthorizer, Redis Streams relay)
  stays in place so flipping the switch in the client follow-up PR is
  a one-line change.

- scope_authorizer: ScopeChat now enforces CreatorID == userID, mirroring
  the HTTP layer (handler/chat.go: GetChatSession / SendChatMessage /
  MarkChatSessionRead). Without this, any workspace member who learned a
  session_id could subscribe to chat:message / chat:done /
  chat:session_read for a peer's private chat. The same creator-only
  check is applied to ScopeTask when the task is a chat task
  (task.ChatSessionID set). Issue tasks remain workspace-scoped.

- Refactor scope authorizer to depend on a narrow scopeAuthQuerier
  interface so its decisions can be unit-tested without a live DB.

- Add tests:
  * listeners_scope_test.go pins the workspace-fanout fallback for
    task:message / task:progress / chat:message / chat:done /
    chat:session_read.
  * scope_authorizer_test.go covers chat creator-only access, chat-task
    creator-only access, and issue-task workspace-only access (creator
    allowed, peer denied, cross-workspace denied, missing session
    denied, empty userID denied).

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
2026-04-23 13:36:55 +08:00
LinYushen
d97aec83d7 fix: pass model to Hermes ACP and add hermes to InjectRuntimeConfig (#1203)
* fix: pass model to Hermes ACP session/new and add hermes to InjectRuntimeConfig

- hermes.go: include opts.Model in session/new params so Hermes uses
  the configured model instead of its default (fixes local LLM failures)
- runtime_config.go: add "hermes" to the AGENTS.md provider list so
  Hermes receives the Multica runtime instructions and skill discovery

Fixes: https://github.com/multica-ai/multica/issues/1195

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

* fix(hermes): drop false native-skill claim and add regression tests

The previous change added 'hermes' to the 'skills discovered automatically'
branch of buildMetaSkillContent, but resolveSkillsDir has no Hermes case so
skills still land in the .agent_context/skills/ fallback. AGENTS.md ended up
claiming native discovery while the files were somewhere else, which would
mislead Hermes (and future debuggers).

- Move 'hermes' to the fallback branch alongside 'gemini' so AGENTS.md points
  Hermes at .agent_context/skills/ — matching where writeContextFiles actually
  writes them.
- Extract buildHermesSessionParams so the session/new payload is unit-testable.
- Add regression tests covering:
  * buildHermesSessionParams includes/omits 'model' correctly
  * InjectRuntimeConfig('hermes') writes AGENTS.md with the fallback hint
  * writeContextFiles('hermes') writes skills to .agent_context/skills/

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
2026-04-23 12:43:30 +08:00
Naiyuan Qing
95bcffef8c fix(desktop): expose search params from root navigation adapter (#1547)
DesktopNavigationProvider stubbed `searchParams` to an empty
URLSearchParams, so any shell-level consumer of useNavigation() that
looked at query params read blanks. The miss surfaced in focus-mode:
on /inbox?issue=<id>, ChatWindow's useRouteAnchorCandidate couldn't
see the selection, so the Focus button stayed disabled.

Mirror the full location (pathname + search) from the active tab's
router — same subscription pattern TabNavigationProvider already uses
~30 lines below. InboxPage itself was fine because it's rendered
inside TabNavigationProvider; the bug only hit components mounted at
the shell root (ChatWindow, ChatFab, and any future sibling).

No test: the fix is an identical copy of a production-shipped pattern
in the same file, and the mock surface needed to exercise the adapter
(useActiveTabRouter + memory router + tab store) exceeds the fix
itself. Verified via pnpm typecheck across all packages.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:02:43 +08:00
Naiyuan Qing
d6e7824ff1 feat(feedback): in-app feedback flow + Help launcher (#1546)
* feat(feedback): add in-app feedback flow and Help launcher

Replaces the duplicated bottom-sidebar user popover and "What's new" links
with a single Help menu (Docs / Feedback / Change log) pinned to the
sidebar footer. Feedback opens a rich-text modal that POSTs to a new
/api/feedback endpoint; submissions land in a dedicated feedback table
with per-user hourly rate limiting (10/hr) to deter spam without adding
middleware infrastructure. User identity (avatar + name + email) moves
into the workspace dropdown header so the sidebar is no longer visually
redundant.

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

* fix(feedback): harden submit path and cap request body

- Read editor markdown via ref at submit time instead of debounced state,
  so ⌘+Enter immediately after typing doesn't drop the last keystrokes.
- Block submission while images are still uploading; toast prompts the
  user to wait instead of silently sending markdown with blob: URLs
  that get stripped.
- Cap /api/feedback request body at 64 KiB via MaxBytesReader so an
  authenticated client can't bloat the metadata JSONB column with an
  oversized url field.
- Add Go handler tests covering happy path, empty-message rejection,
  and the hourly rate limit boundary.

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

* feat(analytics): instrument feedback funnel

Adds two events pairing frontend intent with backend conversion so we
can compute a completion rate for the in-app Feedback modal:

- `feedback_opened` (frontend) — fires once on FeedbackModal mount.
  Source is currently always "help_menu" but the type is a union so
  future entry points have to extend it explicitly. Workspace id is
  attached when present.
- `feedback_submitted` (backend) — fires from CreateFeedback after the
  DB insert succeeds and the hourly rate-limit check has passed.
  Message content itself is never sent to PostHog; the event carries
  a coarse length bucket (0-100 / 100-500 / 500-2000 / 2000+), an
  image-presence flag, and the client platform / version pulled from
  X-Client-* headers via middleware.ClientMetadataFromContext.

Affects no existing funnel; seeds a new Feedback funnel for product
triage.

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-23 10:35:55 +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
898 changed files with 86983 additions and 12503 deletions

View File

@@ -11,17 +11,21 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# 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.
# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to
# "production" by default. Local dev can leave it unset.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
# Optional local/testing shortcut. Empty by default, so there is no fixed
# verification code. Without RESEND_API_KEY, generated codes print to stdout.
# If you need deterministic local automation, set a 6-digit value such as
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# Prometheus metrics are disabled by default. When enabled, bind to loopback
# unless you protect the listener with private networking, allowlists, or
# proxy auth. Do not expose this endpoint through the public app/API ingress.
# HTTP request metrics start accumulating only when this listener is enabled.
# METRICS_ADDR=127.0.0.1:9090
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
@@ -45,8 +49,7 @@ 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 (only when APP_ENV != "production"; see above).
# For local/dev use, leave RESEND_API_KEY empty — generated codes print to stdout.
# 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
@@ -60,6 +63,9 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
S3_BUCKET=
S3_REGION=us-west-2
CLOUDFRONT_KEY_PAIR_ID=
@@ -85,6 +91,16 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
# callers with no forwarding headers and returns 404 to everything else —
# safe for local dev. Any deployment behind a reverse proxy (Caddy / Nginx
# terminating TLS in front of localhost:8080) MUST set this token, since
# proxied requests look like loopback at the Go layer; with no token, those
# requests are refused with 404. Pass the token as
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

View File

@@ -29,8 +29,8 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Build, type check, and test
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
- name: Build, type check, lint, and test
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
backend:
runs-on: ubuntu-latest
@@ -48,8 +48,22 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Wires up the RedisLocalSkill*_test.go suite. Distinct from REDIS_URL
# (which would flip the server binary itself onto the Redis-backed
# realtime relay + request stores); the tests talk to this Redis
# directly so they run alongside the Postgres-backed suite.
REDIS_TEST_URL: redis://localhost:6379/1
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -56,6 +56,12 @@ jobs:
release:
needs: verify
# Only run on the canonical upstream repo. Forks don't have the
# HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to
# `multica-ai/homebrew-tap` anyway. Without this guard, every fork's
# tag push fails this job (401 against the upstream tap), which makes
# downstream CI go red without affecting the actual artifact pipeline.
if: github.repository_owner == 'multica-ai'
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

@@ -2,6 +2,21 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Conventions reference
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
Read that page before:
- Writing or editing translations (`packages/views/locales/`)
- Naming a new route, package, file, DB column, or TS type
- Writing Chinese product copy (UI strings, error messages, docs)
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
## Project Context
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
@@ -136,6 +151,17 @@ make start-worktree # Start using .env.worktree
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
### Backend Handler UUID Parsing Convention
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
### Package Boundary Rules
These are hard constraints. Violating them breaks the cross-platform architecture:
@@ -191,64 +217,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, onboarding, new-workspace, invite, no-access, create-workspace modal) — i.e. anything that isn't inside the dashboard shell — needs a top drag strip so users can move the window. The native macOS traffic lights are **kept visible** for every such surface (Linear/Notion/Arc pattern); no `useImmersiveMode` by default.
**Pattern**: use the shared `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root. It's a 48px transparent row with `-webkit-app-region: drag` — the parent's bg fills through it so the page reads edge-to-edge while the top 48px stays draggable under the traffic lights.
```tsx
import { DragStrip } from "@multica/views/platform";
return (
<div className="flex min-h-svh flex-col bg-background">
<DragStrip />
<div className="flex flex-1 flex-col px-6 pb-12">
{/* page content — interactive elements placed at y ≥ 48 clear the strip;
any element at y < 48 needs WebkitAppRegion: "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. Web browsers silently ignore `-webkit-app-region`, so shared views render the strip as a plain 48px spacer on web — safe cross-platform.
**Horizontal clearance**: traffic lights occupy roughly x ∈ [16, 76] on macOS. Interactive UI (Back buttons, menus) should start at x ≥ 80 on desktop-sized viewports. The shared views default to sufficient `lg:px-20` padding; re-examine when laying out anything in the top-left corner.
Canonical example: `packages/views/platform/drag-strip.tsx`. Used by `onboarding/steps/step-welcome.tsx` (per-column), `onboarding/onboarding-flow.tsx`, `workspace/new-workspace-page.tsx`, `invite/invite-page.tsx`, `workspace/no-access-page.tsx`, `modals/create-workspace.tsx`, and desktop's `pages/login.tsx`.
**When to use `useImmersiveMode`**: only when a view must place interactive UI in the traffic-light hit-zone (y < 28 AND x < 80). For every current non-dashboard surface, buttons sit at y ≥ 48, so immersive mode is unnecessary. Hook is preserved as an escape hatch but has no callers.
### 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 (tab system interaction, native-window IPC, `useImmersiveMode`) lives in desktop-only code. The `DragStrip` + `useImmersiveMode` primitives live in `packages/views/platform/` because they're cross-platform safe (web no-op) and need to be callable from shared views that own the page layout — keeping them in desktop-only would force every shared page to leave top-padding decisions to the platform shell, fragmenting the design.
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

@@ -70,10 +70,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token
multica login --token <mul_...>
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
### Check Status
@@ -140,12 +140,15 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| [GitHub Copilot CLI](https://docs.github.com/en/copilot) | `copilot` | GitHub's coding agent (model routed by your GitHub entitlement) |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
| Gemini | `gemini` | Google's coding agent |
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
| Kimi | `kimi` | Moonshot coding agent |
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -166,11 +169,28 @@ Daemon behavior is configured via flags or environment variables:
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
| GC enabled | — | `MULTICA_GC_ENABLED` | `true` (set `false`/`0` to disable) |
| GC scan interval | — | `MULTICA_GC_INTERVAL` | `1h` |
| GC TTL (done/cancelled issues) | — | `MULTICA_GC_TTL` | `24h` |
| GC orphan TTL (no `.gc_meta.json`) | — | `MULTICA_GC_ORPHAN_TTL` | `72h` |
| GC artifact TTL (open issues) | — | `MULTICA_GC_ARTIFACT_TTL` | `12h` (set `0` to disable) |
| GC artifact patterns | — | `MULTICA_GC_ARTIFACT_PATTERNS` | `node_modules,.next,.turbo` |
#### Workspace garbage collection
The daemon periodically scans `MULTICA_WORKSPACES_ROOT` and reclaims disk space in three modes:
- **Full task cleanup** — when an issue's status is `done` or `cancelled` and has been idle for `MULTICA_GC_TTL`, the entire task directory is removed.
- **Orphan cleanup** — task directories with no `.gc_meta.json` (e.g. left over from a daemon crash) are removed once they exceed `MULTICA_GC_ORPHAN_TTL`.
- **Artifact-only cleanup** — when a task has been completed for at least `MULTICA_GC_ARTIFACT_TTL` but the issue is still open, regenerable build outputs whose directory basename matches `MULTICA_GC_ARTIFACT_PATTERNS` are removed; the rest of the workdir (source, `.git`, `output/`, `logs/`, `.gc_meta.json`) is preserved so the agent can resume the same workdir on the next task.
Patterns are basename-only — entries containing `/` or `\` are silently dropped — and `.git` subtrees are never descended into. The default list (`node_modules`, `.next`, `.turbo`) is intentionally narrow; extend it per deployment if your repos consistently produce other regenerable directories (for example, `MULTICA_GC_ARTIFACT_PATTERNS=node_modules,.next,.turbo,target,__pycache__`). To disable artifact cleanup entirely, set `MULTICA_GC_ARTIFACT_TTL=0`.
Agent-specific overrides:
@@ -178,8 +198,12 @@ Agent-specific overrides:
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` binary |
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
@@ -192,6 +216,12 @@ Agent-specific overrides:
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
### Self-Hosted Server
@@ -275,10 +305,11 @@ multica workspace members <workspace-id>
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
### Get Issue
@@ -291,9 +322,10 @@ multica issue get <id> --output json
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
### Update Issue
@@ -305,9 +337,12 @@ multica issue update <id> --title "New title" --priority urgent
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue assign <id> --unassign
```
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
### Change Status
```bash

View File

@@ -140,7 +140,7 @@ multica auth status
Expected output should show the authenticated user and server URL.
**If login fails:**
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token <mul_...>` (use `--token=` with an empty value to be prompted interactively).
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
---
@@ -166,12 +166,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
---
@@ -185,12 +185,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
---

View File

@@ -373,7 +373,8 @@ done
#### 2. Create a test user and token (automated auth)
In non-production environments the verification code is fixed at `888888`:
For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888`
in your env file before starting the backend:
```bash
curl -s -X POST "$SERVER/auth/send-code" \
@@ -476,7 +477,9 @@ This automatically:
3. Starts and manages its own daemon instance
4. Connects to the local backend
Login in the Desktop UI with `dev@localhost` and code `888888`.
Login in the Desktop UI with `dev@localhost` and the generated code from the
backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting
the backend, you can use `888888` instead.
If the backend runs on a non-default port (worktree), create
`apps/desktop/.env.development.local`:

View File

@@ -15,7 +15,7 @@ COPY server/ ./server/
# Build binaries
ARG VERSION=dev
ARG COMMIT=unknown
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate

View File

@@ -1,383 +0,0 @@
# Architecture Audit — Workspace & Realtime Cache
> 基于代码审计整理的 4 个任务。优先级P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
---
## 任务 1 — [P0] 空闲后列表数据陈旧
**关联 issue**[#951](https://github.com/multica-ai/multica/issues/951)
### 问题
用户登录后静置一段时间Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
### 根因
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
- `packages/core/query-client.ts:7``staleTime: Infinity`cache 永不主动过期
- `packages/core/query-client.ts:9``refetchOnWindowFocus: false`tab 重新获得焦点也不 refetch
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
但 WS 层存在一个**不对称**
- **服务端**`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait会清理死连接
- **客户端**`packages/core/api/ws-client.ts`142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
1. 浏览器 `readyState` 仍是 `OPEN`
2. `onclose` 不触发
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
4. `packages/core/realtime/use-realtime-sync.ts:462-487``onReconnect` 全量 invalidate 不跑
5. 期间的 WS 事件进黑洞
6. cache 保持旧快照
### 复现
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
**方法 A推荐最接近真实场景**macOS 用 pfctl 丢包
```bash
# 假设后端在 8080
sudo pfctl -E
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
# 观察:
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
# - Network 里 WS 连接仍显示 Pending / 101
# 用另一个账号/CLI 创建一个 issue
# 回到原客户端: 列表不更新
# 登出再登入: 列表恢复完整
sudo pfctl -d # 解除
```
**方法 B不动网络**:临时修改代码,在 `packages/core/api/ws-client.ts:52``onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
### 修复方案(三个选项,推荐 C
#### 选项 A — 浏览器端心跳探活(治本,改动大)
`ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
- 优点:从根本上解决半开问题
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
#### 选项 B — Page Visibility API 触发 invalidate治标改动小
`packages/core/platform/core-provider.tsx``visibilitychange` 监听tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab
- 缺点treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
#### 选项 C — **A + B 组合**(推荐)
- 短期上 B立刻止血
- 中期上 A把 cache 新鲜度从"只信 WS"改成"WS 是优化Visibility 是兜底"
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min作为第三层保险
### 改动范围
| 方案 | 文件 | 改动规模 |
|---|---|---|
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
### 验证
修完之后:
1. 跑方法 A 复现流程,确认数据不再丢失
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
---
## 任务 2 — [P1] Workspace 不在 URL 路径中
**关联 issue**MUL-723slug 不在 URL、MUL-43切换 workspace 报错、MUL-509手机端无法切换
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR。交接时请让执行人以具体症状为准。
### 问题
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载URL 里没有 workspace 信息。所有路径都是 `/issues``/issues/:id` 这种 workspace-agnostic 的。
### 根因
**数据库和 API 已经支持 slug**
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
**但前端路由和导航层没用它**
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
**workspace 切换只靠 sidebar UI**`packages/views/layout/app-sidebar.tsx:284-286`
```tsx
if (ws.id !== workspace?.id) {
push("/issues"); // 硬跳 /issuesworkspace-implicit
switchWorkspace(ws); // 然后改 store
}
```
这种设计使得:
- 手机端因为没 sidebar UI也没 URL 层切换入口,**完全切不了 workspace**MUL-509
-`/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue或找不到报错MUL-43 系列)
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
### 复现
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
2. **MUL-43**
- 加入两个 workspace A 和 B
- 在 A 中打开某个 issue `/issues/abc123`
- 切到 BURL 不变 → 访问失败 / 显示错数据
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换UI 不显示 sidebar 触发器或触发器无法切)
### 修复方案(三个选项,推荐 A
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
所有路径加上 workspace slug 前缀。例如 `/issues/abc123``/ws/my-team/issues/abc123`
**要改的地方**
1. **Web 路由目录结构**`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`~25 个文件)
2. **Desktop 路由**`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
3. **Navigation 适配器**
- `apps/web/platform/navigation.tsx``push(path)` 内部前置 `/ws/${workspace.slug}``pathname` 读取时去掉前缀
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
4. **Sidebar 切换逻辑**`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
5. **服务端中间件**`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑header 继续作为 fallback迁移期兼容
**预计改动**~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
**不改也能工作的部分**
- `packages/core/api/client.ts` — 仍旧走 header不用改
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
**风险**
- 旧的 bookmark URL 失效(如果产品还没正式 ship问题不大
- E2E 测试需要更新所有 URL 断言
#### 选项 B — `?ws=slug` query param折中
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件URL 丑但向后兼容。推荐度低于 A。
#### 选项 C — 只修症状不动架构
`switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因技术债越攒越多。**不推荐**。
### 改动范围(选项 A
| 模块 | 文件数 | 备注 |
|---|---|---|
| Web routes | ~25 | 目录搬迁 |
| Desktop routes | 1 | 路径前缀 |
| Navigation adapters | 2 | 前缀逻辑 |
| Server middleware | 1-2 | slug → ID 解析 |
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
| E2E tests | 20-30 | URL 断言更新 |
---
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
**关联 issue**MUL-43切换报错、MUL-476本地缓存未按 workspace 隔离)
> 同上,这两个编号建议交接时核对症状。
### 问题
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage切换 workspace 后里面仍是上个 workspace 的路径。
### 根因
**`packages/core/navigation/store.ts:15-31`**
```typescript
export const useNavigationStore = create<NavigationState>()(
persist(
(set) => ({
lastPath: "/issues",
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
}),
{
name: "multica_navigation",
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global不是 workspace-aware
partialize: (state) => ({ lastPath: state.lastPath }),
}
)
);
// ← 没有调 registerForWorkspaceRehydration
```
**对比:其他 store 都是正确的**
| Store | 是否 workspace-aware | 是否注册 rehydration |
|---|---|---|
| useNavigationStore | ❌ | ❌ |
| useIssuesScopeStore | ✅ | ✅ |
| useIssueDraftStore | ✅ | ✅ |
| useRecentIssuesStore | ✅ | ✅ |
| useIssueViewStore | ✅ | ✅ |
| myIssuesViewStore | ✅ | ✅ |
| useChatStore | ✅(手动用 wsKey| ✅ |
另外 `packages/core/platform/storage-cleanup.ts:10-19``WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`
**现有的 workaround**`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
### 复现
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
2. 切到 workspace B
3. 观察:如果没有 sidebar 的硬跳 workaround会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B导致 404 或错误
目前因为有硬跳 workaround症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug用户期望记住上次位置
### 修复方案(推荐 Option C组合
**三处改动**
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
3. `packages/core/platform/storage-cleanup.ts:10-19``WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285``push("/issues")` workaround改完之后不再需要
### 改动范围
| 文件 | 改动 |
|---|---|
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
---
## 任务 4 — [P2] Workspace 生命周期副作用散落
**关联 issue**MUL-727创建后闪页、MUL-728删除确认、MUL-820接受邀请不自动切
### 问题
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
- `packages/core/workspace/mutations.ts:7-21``useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand`/issues` 路由开始用新 workspace 渲染
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx``onSuccess` 里先 `switchWorkspace``push`,保证同一个微任务里完成。
**文件**`packages/core/workspace/mutations.ts``packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
**核查结果**`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
**修复**:在 `handleDeleteWorkspace``onConfirm` 成功分支里加 `push("/issues")`
**文件**`packages/views/settings/components/workspace-tab.tsx`(加一行)
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
**核查结果**:有两条路径:
-`/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**accept → switchWorkspace → push("/issues")
-**Sidebar 下拉里的 "Join" 按钮**`packages/views/layout/app-sidebar.tsx:203-209, 321-324`**是错的**:只 invalidate cache不切也不跳
**修复(推荐 Option 2**Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
```tsx
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
{inv.workspace_name}
</DropdownMenuItem>
```
**文件**`packages/views/layout/app-sidebar.tsx`~10 行)
### 复现
| Issue | 步骤 |
|---|---|
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面BUG: 没有自动跳走) |
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去BUG: 不切)|
### 长期架构建议(可选)
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bughook 抽象作为后续迭代。
### 改动范围
| Issue | 文件 | 改动规模 |
|---|---|---|
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
| MUL-728 | workspace-tab.tsx | ~1 行 |
| MUL-820 | app-sidebar.tsx | ~10 行 |
---
## 总览
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|---|---|---|---|---|
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行Option C ~1-2 天 | 低 |
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中影响面大、e2e 要改)|
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
### 建议推进顺序
1. **立刻做**:任务 1 的 Option Bvisibilitychange 触发 invalidate—— 代码最少、收益最明显,能当天止血
2. **同步开始**:任务 3navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
3. **规划立项**:任务 2URL 化)—— 大改造,需要单独开一个 iteration
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR各自 review
### 重要澄清
- **Issue 编号核对**MUL-43 / MUL-476 的编号需要核对一次agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
- **MUL-820 实际状态**`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
### 所有关键代码位置索引
```
packages/core/query-client.ts:7-10 # staleTime: Infinity
packages/core/api/ws-client.ts:1-142 # 客户端 WS无心跳
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
server/internal/middleware/workspace.go:41-46 # wsId resolution
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
```

View File

@@ -91,7 +91,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
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 " or read the generated code from backend logs when Resend is unset."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
@@ -130,7 +130,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
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 " or read the generated code from backend logs when Resend is unset."; \
echo ""; \
echo "Built images locally via docker-compose.selfhost.build.yml."; \
echo "Local tags: multica-backend:dev and multica-web:dev."; \
@@ -277,7 +277,7 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
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)" -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

View File

@@ -30,12 +30,24 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
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**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
</p>
## Why "Multica"?
Multica — **Mul**tiplexed **I**nformation and **C**omputing **A**gent.
The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing — letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.
We think the same inflection is happening again. For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the "users" multiplexing the system are both humans and autonomous agents.
In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code — just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.
Like Multics before it, the bet is on multiplexing: a small team shouldn't feel small. With the right system, two engineers and a fleet of agents can move like twenty.
## Features
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
@@ -98,7 +110,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
### 2. Verify your runtime
@@ -108,7 +120,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -160,9 +172,9 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
┌──────┴───────┐
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, OpenCode,
OpenClaw, Hermes, Gemini,
Pi, Cursor Agent)
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
OpenCode, OpenClaw, Hermes, Gemini,
Pi, Cursor Agent, Kimi, Kiro CLI)
```
| Layer | Stack |
@@ -170,7 +182,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
## Development
@@ -185,13 +197,3 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -30,12 +30,24 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi****Cursor Agent**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi****Cursor Agent**、**Kimi** 和 **Kiro CLI**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
</p>
## 为什么叫 "Multica"
Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
这个名字是在向 20 世纪 60 年代具有开创意义的操作系统 Multics 致意。Multics 首创了分时系统让多个用户能够共享同一台机器同时又像各自独占它一样使用。Unix 则是在有意简化 Multics 的基础上诞生的,强调一个用户、一个任务、一种优雅的哲学。
我们认为类似的转折点正在再次出现。几十年来软件团队一直处于一种单线程的工作模式一个工程师处理一个任务一次只专注于一个上下文。AI agents 改变了这个等式。Multica 将"分时"重新带回这个时代,只不过今天在系统中进行多路复用的"用户",既包括人类,也包括自主代理。
在 Multica 中agents 是一级团队成员。它们会被分配 issue汇报进展提出阻塞并交付代码就像人类同事一样。任务分配、活动时间线、任务生命周期以及运行时基础设施Multica 从第一天起就是围绕这一理念构建的。
和当年的 Multics 一样,这一判断建立在"多路复用"之上。一个小团队不该因为人数少就显得能力有限。有了合适的系统,两名工程师加上一组 agents就能发挥出二十人团队的推进速度。
## 功能特性
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
@@ -99,7 +111,7 @@ multica setup # 连接 Multica Cloud登录启动 daemon
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode``hermes``gemini``pi``cursor-agent`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``copilot``openclaw``opencode``hermes``gemini``pi``cursor-agent``kimi``kiro-cli`)。
### 2. 确认运行时已连接
@@ -109,7 +121,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent、Kimi 或 Kiro CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -142,9 +154,9 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
┌──────┴───────┐
│ Agent Daemon │ 运行在你的机器上
└──────────────┘ Claude Code、Codex、OpenCode
OpenClaw、Hermes、Gemini、
Pi、Cursor Agent
└──────────────┘ Claude Code、Codex、GitHub Copilot CLI
OpenCode、OpenClaw、Hermes、Gemini、
Pi、Cursor Agent、Kimi、Kiro CLI
```
| 层级 | 技术栈 |
@@ -152,7 +164,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent、Kimi 或 Kiro CLI |
## 开发
@@ -172,13 +184,3 @@ make start
## 开源协议
[Apache 2.0](LICENSE)
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -26,7 +26,7 @@ multica setup self-host
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.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -67,15 +67,15 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **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.
- **Without email configured:** 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.
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
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`.
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
### Step 3 — Install CLI & Start Daemon
@@ -92,12 +92,15 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [GitHub Copilot CLI](https://docs.github.com/en/copilot) (`copilot` on PATH)
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
- Gemini (`gemini` on PATH)
- [Pi](https://pi.dev/) (`pi` on PATH)
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
- Kimi (`kimi` on PATH)
- Kiro CLI (`kiro-cli` on PATH)
### b) One-command setup

View File

@@ -32,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
### Google OAuth (Optional)
@@ -56,13 +56,15 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
For file uploads and attachments, configure S3 and (optionally) CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
@@ -79,6 +81,7 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
@@ -102,6 +105,8 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` (GitHub Copilot CLI) binary |
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
@@ -268,20 +273,67 @@ Then restart the stack:
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.
### WebSocket for LAN / Non-localhost Access
> **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`.
HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js rewrites proxy `/api`, `/auth`, and `/uploads` to the backend. **WebSockets do not**: Next.js rewrites only forward HTTP requests, not the `Upgrade` handshake a WebSocket needs. If you open the app on `http://<lan-ip>:3000`, real-time features (chat streaming, live issue updates, notifications) will fail to connect until you do one of the following:
1. **Put a reverse proxy in front of the stack (recommended).** Nginx or Caddy terminates the WebSocket upgrade and forwards it to the backend on port 8080. See the [Reverse Proxy](#reverse-proxy) section above — the Nginx example already includes a `location /ws { ... }` block with the correct `Upgrade` / `Connection` headers. Once a proxy is in place the browser connects directly through it, so no frontend rebuild is needed.
2. **Bake a WebSocket URL into the web image.** If you are not running a reverse proxy, rebuild the web image with `NEXT_PUBLIC_WS_URL` pointing straight at the backend (port 8080 must be reachable from the browser):
```bash
# In .env
NEXT_PUBLIC_WS_URL=ws://<lan-ip>:8080/ws
# Rebuild the web image so the build-time value is baked in
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
```
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
## Health Check
The backend exposes a health check endpoint:
The backend exposes public health endpoints:
```
```text
GET /health
→ {"status":"ok"}
GET /readyz
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
GET /healthz
→ same response as /readyz
```
Use this for load balancer health checks or monitoring.
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
dependency-aware readiness probes and external monitoring that should fail when
the database is unavailable or migrations are not fully applied. `/healthz` is
kept as an alias for operator familiarity.
## Prometheus Metrics
The backend can expose Prometheus metrics on a separate management listener:
```bash
METRICS_ADDR=127.0.0.1:9090 ./server/bin/server
curl http://127.0.0.1:9090/metrics
```
`METRICS_ADDR` is empty by default, so no metrics listener is started. The
public API port does not serve `/metrics`; keep it that way for internet-facing
deployments. HTTP request metrics start accumulating only after the metrics
listener is enabled. Metrics can reveal internal routes, traffic volume,
dependency state, and runtime health.
For Docker or Kubernetes deployments, prefer a private scrape path: bind the
metrics listener to an internal interface and protect it with private
networking, allowlists, NetworkPolicy, or proxy authentication. If you bind
`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a
trusted network, for example a host-local mapping such as
`127.0.0.1:9090:9090`.
## Upgrading

View File

@@ -37,7 +37,7 @@ multica setup self-host
The `multica setup self-host` command will:
1. Configure CLI to connect to localhost:8080 / localhost:3000
2. Open a browser for login — use verification code `888888` with any email
2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset
3. Discover workspaces automatically
4. Start the daemon in the background
@@ -73,4 +73,4 @@ If the default ports (8080/3000) are in use:
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
- **Daemon issues:** `multica daemon logs`
- **Health check:** `curl http://localhost:8080/health`
- **Health checks:** `curl http://localhost:8080/health` for liveness, `curl http://localhost:8080/readyz` for dependency-aware readiness

View File

@@ -1,12 +0,0 @@
# Production environment for `pnpm package` / `pnpm build`.
# electron-vite (Vite under the hood) reads this automatically in
# production mode and inlines the values into the renderer bundle via
# import.meta.env.VITE_*. These are public URLs, not secrets.
# Backend API + websocket the desktop app talks to.
VITE_API_URL=https://api.multica.ai
VITE_WS_URL=wss://api.multica.ai/ws
# Public web app URL — used to build shareable links like "Copy link to
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
VITE_APP_URL=https://multica.ai

View File

@@ -37,6 +37,14 @@ linux:
- deb
- rpm
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
rpm:
# Disable RPM build-id symlinks. Electron apps embed the upstream Electron
# binary, whose GNU build-id is identical across every app shipping the same
# Electron version (Slack, VS Code, Discord, ...). Without this, our RPM
# would own /usr/lib/.build-id/<hash> paths and collide with any other
# Electron RPM already installed, breaking `dnf install` on Fedora/RHEL.
fpm:
- "--rpm-rpmbuild-define=_build_id_links none"
win:
target:
- nsis

View File

@@ -0,0 +1,33 @@
import { app } from "electron";
import { execSync } from "node:child_process";
/**
* Resolve the running app version. In packaged builds this is the value
* `electron-builder` baked into package.json via `extraMetadata.version`
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
* `app.getVersion()` matches the GitHub Release tag exactly.
*
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
* the Settings → Updates panel and any other UI surfacing the version
* would mislead developers into thinking they're running ancient builds.
* Fall back to `git describe --tags --always --dirty` (same source the
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
* unavailable for whatever reason, we just return the package.json value.
*/
export function getAppVersion(): string {
if (app.isPackaged) {
return app.getVersion();
}
try {
const raw = execSync("git describe --tags --always --dirty", {
cwd: app.getAppPath(),
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (!raw) return app.getVersion();
return raw.replace(/^v/, "");
} catch {
return app.getVersion();
}
}

View File

@@ -0,0 +1,33 @@
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
// Electron ships with no default right-click menu, so a user selecting text
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
// menu using `roles`, which keeps i18n + accelerator handling native.
export function installContextMenu(webContents: WebContents): void {
webContents.on("context-menu", (_event, params) => {
const { editFlags, selectionText, isEditable } = params;
const hasSelection = selectionText.trim().length > 0;
const menu = new Menu();
if (isEditable && editFlags.canCut) {
menu.append(new MenuItem({ role: "cut" }));
}
if (hasSelection && editFlags.canCopy) {
menu.append(new MenuItem({ role: "copy" }));
}
if (isEditable && editFlags.canPaste) {
menu.append(new MenuItem({ role: "paste" }));
}
if (isEditable && editFlags.canSelectAll) {
if (menu.items.length > 0) {
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(new MenuItem({ role: "selectAll" }));
}
if (menu.items.length === 0) return;
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
menu.popup({ window });
});
}

View File

@@ -1,4 +1,4 @@
import { app, ipcMain, BrowserWindow } from "electron";
import { app, ipcMain, BrowserWindow, shell } from "electron";
import { execFile } from "child_process";
import {
readFile,
@@ -914,6 +914,20 @@ export function setupDaemonManager(
stopLogTail();
});
// Reveal the daemon's log file in the user's default editor / Console
// app. Acts as the escape hatch when the in-app log viewer isn't enough
// (full history, complex search, copy-to-clipboard at scale).
ipcMain.handle("daemon:open-log-file", async () => {
const active = await ensureActiveProfile();
const logPath = profileLogPath(active.name);
if (!existsSync(logPath)) {
return { success: false, error: "Log file not found yet" };
}
// shell.openPath returns "" on success, error string on failure.
const error = await shell.openPath(logPath);
return error === "" ? { success: true } : { success: false, error };
});
// First-run CLI install kicks off here. Status bar shows "Setting up…"
// until the managed binary is on disk (instant on subsequent launches).
currentState = "installing_cli";

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
@@ -6,6 +6,10 @@ import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
@@ -35,6 +39,10 @@ if (process.platform !== "win32") {
const PROTOCOL = "multica";
let mainWindow: BrowserWindow | null = null;
let runtimeConfigResult: RuntimeConfigResult = {
ok: false,
error: { message: "Runtime config has not loaded yet" },
};
// --- Deep link helpers ---------------------------------------------------
@@ -70,7 +78,25 @@ function handleDeepLink(url: string): void {
// --- Window creation -----------------------------------------------------
// Tracks the OS-preferred language as last seen by the running process.
// Updated on each window-focus check so we can emit a `locale:system-changed`
// event to the renderer when the user changes their OS language without
// quitting the app — without restart, app.getPreferredSystemLanguages()
// would still report the boot value forever.
let lastKnownSystemLocale = "en";
function getSystemLocale(): string {
return app.getPreferredSystemLanguages()[0] ?? "en";
}
function createWindow(): void {
// Pass the OS-preferred language to the renderer via additionalArguments
// instead of a sync IPC call. process.argv is available to the preload
// script before the first network request, so the renderer's i18next
// instance can initialize with the right locale on the very first paint.
const systemLocale = getSystemLocale();
lastKnownSystemLocale = systemLocale;
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
@@ -87,6 +113,7 @@ function createWindow(): void {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
additionalArguments: [`--multica-locale=${systemLocale}`],
},
});
@@ -104,11 +131,41 @@ function createWindow(): void {
mainWindow?.show();
});
// Detect OS language changes while the app is running. Electron has no
// dedicated event for this on any platform, so we poll on focus regain —
// catches the common case where users switch System Settings → Language
// and bring the app back. The renderer decides whether to act (it ignores
// the signal when the user has an explicit Settings choice).
mainWindow.on("focus", () => {
const current = getSystemLocale();
if (current === lastKnownSystemLocale) return;
lastKnownSystemLocale = current;
mainWindow?.webContents.send("locale:system-changed", current);
});
mainWindow.webContents.setWindowOpenHandler((details) => {
openExternalSafely(details.url);
return { action: "deny" };
});
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
// reloading the page. In a desktop app an accidental reload destroys
// in-memory state (tabs, drafts, WS connections) with no URL bar to
// navigate back. DevTools refresh (via the DevTools UI) still works.
mainWindow.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return;
const cmdOrCtrl =
process.platform === "darwin" ? input.meta : input.control;
if (
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
input.key === "F5"
) {
_event.preventDefault();
}
});
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
@@ -167,7 +224,25 @@ if (!gotTheLock) {
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
});
app.whenReady().then(() => {
app.whenReady().then(async () => {
const viteEnv = import.meta.env as ImportMetaEnv & {
readonly VITE_API_URL?: string;
readonly VITE_WS_URL?: string;
readonly VITE_APP_URL?: string;
};
runtimeConfigResult = await loadRuntimeConfig({
isDev: is.dev,
// electron-vite exposes VITE_* on import.meta.env for the main process;
// keep dev URL overrides on the same source the renderer used before
// runtime config moved endpoint resolution into main/preload.
env: {
apiUrl: viteEnv.VITE_API_URL,
wsUrl: viteEnv.VITE_WS_URL,
appUrl: viteEnv.VITE_APP_URL,
},
});
electronApp.setAppUserModelId(
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
);
@@ -200,7 +275,14 @@ if (!gotTheLock) {
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 };
event.returnValue = { version: getAppVersion(), os };
});
// Sync IPC: preload exposes the validated runtime config before renderer
// boot. If desktop.json exists but is invalid, renderer receives the
// blocking error and must not silently fall back to the cloud defaults.
ipcMain.on("runtime-config:get", (event) => {
event.returnValue = runtimeConfigResult;
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
@@ -211,6 +293,64 @@ if (!gotTheLock) {
mainWindow?.setWindowButtonVisibility(!immersive);
});
// IPC: show a native OS notification for a new inbox item. The renderer
// only fires this when the app is unfocused (it gates on
// `document.hasFocus()`), so we don't fight macOS foreground suppression
// here. Clicking the banner focuses the main window and routes to the
// inbox item via a renderer-side listener.
ipcMain.on(
"notification:show",
(
_event,
{
slug,
itemId,
issueKey,
title,
body,
}: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
},
) => {
if (!Notification.isSupported()) return;
const notification = new Notification({ title, body });
notification.on("click", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
// Ship the full context back — the renderer pins the route to the
// source workspace (slug), marks the row read (itemId), and uses
// issueKey as the ?issue=<…> selector.
mainWindow.webContents.send("inbox:open", {
slug,
itemId,
issueKey,
});
});
notification.show();
},
);
// IPC: update the dock / taskbar unread badge. Values above 99 render as
// "99+". macOS is the primary target (user-visible dock badge); Linux
// Unity launchers also respect `setBadgeCount`. Windows' taskbar overlay
// needs a pre-rendered PNG and is deferred — the OS notification + the
// in-app inbox sidebar cover the core UX there for now.
ipcMain.on("badge:set", (_event, rawCount: number) => {
const count = Math.max(0, Math.floor(rawCount));
if (process.platform === "darwin") {
const label = count === 0 ? "" : count > 99 ? "99+" : String(count);
app.dock?.setBadge(label);
} else {
app.setBadgeCount(count);
}
});
createWindow();
setupAutoUpdater(() => mainWindow);

View File

@@ -0,0 +1,90 @@
import { mkdtemp, writeFile } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { describe, expect, it } from "vitest";
import { loadRuntimeConfig } from "./runtime-config-loader";
describe("loadRuntimeConfig", () => {
it("uses dev env and ignores desktop.json during electron-vite dev", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
const configPath = join(dir, "desktop.json");
await writeFile(
configPath,
JSON.stringify({ schemaVersion: 1, apiUrl: "https://prod.example.com" }),
);
await expect(
loadRuntimeConfig({
isDev: true,
configPath,
env: {
apiUrl: "http://localhost:8080",
wsUrl: "ws://localhost:8080/ws",
appUrl: "http://localhost:3000",
},
}),
).resolves.toEqual({
ok: true,
config: {
schemaVersion: 1,
apiUrl: "http://localhost:8080",
wsUrl: "ws://localhost:8080/ws",
appUrl: "http://localhost:3000",
},
});
});
it("uses cloud defaults when packaged config is absent", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
await expect(
loadRuntimeConfig({
isDev: false,
configPath: join(dir, "missing.json"),
env: {},
}),
).resolves.toEqual({
ok: true,
config: {
schemaVersion: 1,
apiUrl: "https://api.multica.ai",
wsUrl: "wss://api.multica.ai/ws",
appUrl: "https://multica.ai",
},
});
});
it("parses a valid packaged desktop.json", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
const configPath = join(dir, "desktop.json");
await writeFile(
configPath,
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.example.com" }),
);
await expect(
loadRuntimeConfig({ isDev: false, configPath, env: {} }),
).resolves.toEqual({
ok: true,
config: {
schemaVersion: 1,
apiUrl: "https://api.example.com",
wsUrl: "wss://api.example.com/ws",
appUrl: "https://api.example.com",
},
});
});
it("fails closed when packaged desktop.json is invalid", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
const configPath = join(dir, "desktop.json");
await writeFile(configPath, "{");
const result = await loadRuntimeConfig({ isDev: false, configPath, env: {} });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain(configPath);
expect(result.error.message).toContain("Invalid desktop runtime config JSON");
}
});
});

View File

@@ -0,0 +1,60 @@
import { app } from "electron";
import { readFile } from "fs/promises";
import { join } from "path";
import {
DEFAULT_RUNTIME_CONFIG,
parseRuntimeConfig,
runtimeConfigFromDevEnv,
type RuntimeConfig,
type RuntimeConfigEnv,
type RuntimeConfigResult,
} from "../shared/runtime-config";
export async function loadRuntimeConfig(options: {
isDev: boolean;
env: RuntimeConfigEnv;
configPath?: string;
}): Promise<RuntimeConfigResult> {
if (options.isDev) {
try {
return { ok: true, config: runtimeConfigFromDevEnv(options.env) };
} catch (err) {
return { ok: false, error: { message: errorMessage(err) } };
}
}
const configPath = options.configPath ?? desktopConfigPath();
try {
const raw = await readFile(configPath, "utf-8");
return { ok: true, config: parseRuntimeConfig(raw) };
} catch (err) {
if (isMissingFileError(err)) {
return { ok: true, config: { ...DEFAULT_RUNTIME_CONFIG } };
}
return {
ok: false,
error: {
message: `Invalid ${configPath}: ${errorMessage(err)}`,
},
};
}
}
export function desktopConfigPath(): string {
return join(app.getPath("home"), ".multica", "desktop.json");
}
function isMissingFileError(err: unknown): boolean {
return Boolean(
err &&
typeof err === "object" &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ENOENT",
);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export type { RuntimeConfig, RuntimeConfigResult };

View File

@@ -1,4 +1,5 @@
import { ElectronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
@@ -6,6 +7,12 @@ interface DesktopAPI {
version: string;
os: "macos" | "windows" | "linux" | "unknown";
};
/** OS-preferred locale (BCP 47) injected by main via additionalArguments. */
systemLocale: string;
/** Subscribe to OS language changes detected after boot. Returns an unsubscribe function. */
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig: RuntimeConfigResult;
/** 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. */
@@ -14,6 +21,24 @@ interface DesktopAPI {
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
/** Show a native OS notification for a new inbox item. */
showNotification: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
}) => void;
/** Update the OS dock / taskbar unread badge. Pass 0 to clear. */
setUnreadBadge: (count: number) => void;
/** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */
onInboxOpen: (
callback: (payload: {
slug: string;
itemId: string;
issueKey: string;
}) => void,
) => () => void;
}
interface DaemonStatus {
@@ -50,6 +75,7 @@ interface DaemonAPI {
startLogStream: () => void;
stopLogStream: () => void;
onLogLine: (callback: (line: string) => void) => () => void;
openLogFile: () => Promise<{ success: boolean; error?: string }>;
}
interface UpdaterAPI {

View File

@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
// Synchronously fetch app metadata from main at preload time so the renderer
// can pass it into CoreProvider during the initial render — the alternative
@@ -21,12 +22,53 @@ function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" |
return { version: "unknown", os };
}
function fetchRuntimeConfig(): RuntimeConfigResult {
try {
const result = ipcRenderer.sendSync("runtime-config:get") as RuntimeConfigResult | undefined;
if (result && typeof result === "object" && "ok" in result) return result;
} catch (err) {
return {
ok: false,
error: {
message: err instanceof Error ? err.message : String(err),
},
};
}
return { ok: false, error: { message: "Runtime config unavailable" } };
}
const appInfo = fetchAppInfo();
const runtimeConfig = fetchRuntimeConfig();
// Read the OS-preferred locale that main injected via additionalArguments.
// Zero IPC, zero blocking — process.argv is populated before preload runs.
function fetchSystemLocale(): string {
const arg = process.argv.find((a) => a.startsWith("--multica-locale="));
return arg?.split("=")[1] ?? "en";
}
const systemLocale = fetchSystemLocale();
const desktopAPI = {
/** App version + normalized OS. Read once at preload time so the renderer
* can use it synchronously when initializing the API client. */
appInfo,
/** OS-preferred locale (BCP 47), passed from main via additionalArguments.
* Used by the renderer's LocaleAdapter as the system-preference signal. */
systemLocale,
/** Subscribe to OS language changes detected after boot. The renderer
* decides whether to act (no-op when the user has an explicit Settings
* choice). Returns an unsubscribe function. */
onSystemLocaleChanged: (callback: (locale: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, locale: string) =>
callback(locale);
ipcRenderer.on("locale:system-changed", handler);
return () => {
ipcRenderer.removeListener("locale:system-changed", handler);
};
},
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig,
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
@@ -50,6 +92,50 @@ const desktopAPI = {
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),
/**
* Show a native OS notification for a new inbox item. Fired from the
* renderer only when the app is unfocused — in-focus feedback is the
* inbox sidebar's unread styling. `slug`, `itemId`, and `issueKey` are
* all round-tripped on click: slug pins routing to the source workspace
* (the user may switch workspaces before clicking the banner), itemId
* lets the renderer mark the row read, issueKey maps to the inbox URL
* param.
*/
showNotification: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
}) => ipcRenderer.send("notification:show", payload),
/**
* Update the OS dock / taskbar unread badge. Pass 0 to clear. Values
* above 99 render as "99+" (capping is handled in the main process).
*/
setUnreadBadge: (count: number) =>
ipcRenderer.send("badge:set", Math.max(0, Math.floor(count))),
/**
* Subscribe to "open this inbox row" requests sent by the main process
* when the user clicks an OS notification banner. Returns an unsubscribe
* function. The payload echoes the `slug`, `itemId`, and `issueKey` that
* were passed to `showNotification`.
*/
onInboxOpen: (
callback: (payload: {
slug: string;
itemId: string;
issueKey: string;
}) => void,
) => {
const handler = (
_event: Electron.IpcRendererEvent,
payload: { slug: string; itemId: string; issueKey: string },
) => callback(payload);
ipcRenderer.on("inbox:open", handler);
return () => {
ipcRenderer.removeListener("inbox:open", handler);
};
},
};
interface DaemonStatus {
@@ -101,6 +187,8 @@ const daemonAPI = {
ipcRenderer.on("daemon:log-line", handler);
return () => ipcRenderer.removeListener("daemon:log-line", handler);
},
openLogFile: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:open-log-file"),
};
const updaterAPI = {

View File

@@ -1,19 +1,23 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { pickLocale } from "@multica/core/i18n";
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 { Toaster } from "@multica/ui/components/ui/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";
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
import { RESOURCES } from "@multica/views/locales";
function AppContent() {
@@ -29,11 +33,16 @@ function AppContent() {
// first render.
const [bootstrapping, setBootstrapping] = useState(false);
const runtimeConfig = window.desktopAPI.runtimeConfig.ok
? window.desktopAPI.runtimeConfig.config
: null;
// Tell the main process which backend URL we talk to, so daemon-manager
// can pick the matching CLI profile (server_url from ~/.multica config).
useEffect(() => {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
if (!runtimeConfig) return;
window.daemonAPI.setTargetApiUrl(runtimeConfig.apiUrl);
}, [runtimeConfig]);
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
// We open the overlay regardless of login state — if the user isn't logged
@@ -99,21 +108,68 @@ function AppContent() {
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.
// Bridge local daemon IPC status into the runtimes cache so this user's
// own daemon flips to offline/online sub-second instead of waiting on the
// server's 75s sweeper. Resolves wsId from the active tab so workspace
// switches automatically rebind the subscription.
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
const activeWsId = activeWorkspaceSlug
? workspaces.find((w) => w.slug === activeWorkspaceSlug)?.id
: undefined;
useDaemonIPCBridge(activeWsId);
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
// judgment in callback / login:
// un-onboarded:
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// already onboarded:
// zero workspaces → /workspaces/new overlay
// ≥1 workspaces → no overlay, fall through to dashboard
//
// The "un-onboarded but in workspace" state is now physically impossible
// because backend transactions atomically set onboarded_at when a user
// joins the `member` table. Anyone with workspaces is by definition
// onboarded.
useEffect(() => {
if (!user || !workspaceListFetched) return;
if (!user || !workspaceListFetched) return undefined;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return;
if (overlay) return undefined;
if (wsCount > 0) return undefined;
if (!hasOnboarded) {
open({ type: "onboarding" });
return;
// Look up pending invitations by email. Network blip is non-fatal —
// fall through to onboarding so the user isn't stuck on a blank
// window. The sidebar's pending-invitations dropdown will surface
// missed invites later once they're onboarded.
let cancelled = false;
void api
.listMyInvitations()
.then((invites) => {
if (cancelled) return;
const { overlay: latestOverlay, open: latestOpen } =
useWindowOverlayStore.getState();
if (latestOverlay) return;
if (invites.length > 0) {
qc.setQueryData(workspaceKeys.myInvitations(), invites);
latestOpen({ type: "invitations" });
} else {
latestOpen({ type: "onboarding" });
}
})
.catch(() => {
if (cancelled) return;
const { overlay: latestOverlay, open: latestOpen } =
useWindowOverlayStore.getState();
if (latestOverlay) return;
latestOpen({ type: "onboarding" });
});
return () => {
cancelled = true;
};
}
if (wsCount === 0) {
open({ type: "new-workspace" });
}
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
open({ type: "new-workspace" });
return undefined;
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect
@@ -178,9 +234,21 @@ function AppContent() {
);
}
// Backend the daemon should connect to — same URL the renderer talks to.
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
function BlockingRuntimeConfigError({ message }: { message: string }) {
return (
<div className="flex h-screen items-center justify-center bg-background p-8 text-foreground">
<div className="max-w-xl rounded-lg border bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold">Desktop configuration error</h1>
<p className="mt-3 text-sm text-muted-foreground">
Multica Desktop could not load <code>~/.multica/desktop.json</code>. Fix or remove the file and restart the app.
</p>
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-muted-foreground">
{message}
</pre>
</div>
</div>
);
}
// On logout, wipe desktop-only in-memory state and stop the daemon so that
// a subsequent login as a different user never inherits the previous user's
@@ -204,22 +272,61 @@ async function handleDaemonLogout() {
export default function App() {
const { version, os } = window.desktopAPI.appInfo;
const systemLocale = window.desktopAPI.systemLocale;
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
// 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],
);
// Locale resolution happens once at app boot. Switching language goes
// through window.location.reload() to avoid hydration mismatch.
const localeAdapter = useMemo(
() => createDesktopLocaleAdapter(systemLocale),
[systemLocale],
);
const locale = useMemo(() => pickLocale(localeAdapter), [localeAdapter]);
const resources = useMemo(
() => ({ [locale]: RESOURCES[locale] }),
[locale],
);
// React to OS-level language changes detected by main on focus regain.
// Only act when the user is following the system signal (no explicit
// Settings choice) — otherwise their preference wins. Cross-device sync
// for the explicit-choice case is handled inside CoreProvider.
useEffect(() => {
return window.desktopAPI.onSystemLocaleChanged((nextSystemLocale) => {
if (localeAdapter.getUserChoice()) return;
const next = pickLocale({
...localeAdapter,
getSystemPreferences: () =>
nextSystemLocale ? [nextSystemLocale] : [],
});
if (next === locale) return;
localeAdapter.persist(next);
window.location.reload();
});
}, [localeAdapter, locale]);
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>
{runtimeConfigResult.ok ? (
<CoreProvider
apiBaseUrl={runtimeConfigResult.config.apiUrl}
wsUrl={runtimeConfigResult.config.wsUrl}
onLogout={handleDaemonLogout}
identity={identity}
locale={locale}
resources={resources}
localeAdapter={localeAdapter}
>
<AppContent />
</CoreProvider>
) : (
<BlockingRuntimeConfigError message={runtimeConfigResult.error.message} />
)}
<Toaster />
<UpdateNotification />
</ThemeProvider>

View File

@@ -1,150 +1,261 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
Play,
Square,
RotateCw,
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
ArrowDown,
Copy as CopyIcon,
Search,
Server,
ChevronDown,
Trash2,
X,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { toast } from "sonner";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@multica/ui/components/ui/sheet";
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { toast } from "sonner";
import type { DaemonStatus } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
DAEMON_STATE_LABELS,
formatUptime,
} from "../../../shared/daemon-types";
import { parseLogLine, type LogLevel, type ParsedLogLine } from "./parse-daemon-log";
interface DaemonPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
status: DaemonStatus;
}
const LOG_LEVEL_COLORS: Record<string, string> = {
INFO: "text-info",
WARN: "text-warning",
ERROR: "text-destructive",
DEBUG: "text-muted-foreground",
};
function colorizeLogLine(line: string): { level: string; className: string } {
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
if (line.includes(level)) return { level, className };
}
return { level: "", className: "text-muted-foreground" };
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-4 py-1">
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="truncate text-right text-sm">{value}</span>
</div>
);
}
function StatusDot({ state }: { state: DaemonState }) {
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
}
interface LogEntry {
id: number;
line: string;
/** Number of runtimes this local daemon has registered (for the context badge). */
runtimeCount: number;
}
const MAX_LOG_LINES = 500;
let logIdCounter = 0;
const LEVELS: readonly LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const LEVEL_BADGE_CLASS: Record<LogLevel, string> = {
DEBUG: "border-muted-foreground/25 text-muted-foreground/70",
INFO: "border-foreground/15 text-foreground/80",
WARN: "border-warning/40 text-warning",
ERROR: "border-destructive/40 text-destructive",
};
// What gets rendered in the viewport — a single line or a folded group of
// consecutive lines that share the same `message`. The group form is what
// turns a wall of `DBG poll: no tasks` into a single placeholder.
type DisplayItem =
| { kind: "line"; line: ParsedLogLine }
| { kind: "group"; first: ParsedLogLine; rest: ParsedLogLine[] };
export function DaemonPanel({
open,
onOpenChange,
status,
runtimeCount,
}: DaemonPanelProps) {
const [logs, setLogs] = useState<ParsedLogLine[]>([]);
const [search, setSearch] = useState("");
// Each level chip is an independent toggle. DEBUG is off by default so
// poll-loop noise doesn't drown out real events when the panel opens —
// users opt in if they want to see it.
const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
() => new Set<LogLevel>(["INFO", "WARN", "ERROR"]),
);
const [autoScroll, setAutoScroll] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [expandedFields, setExpandedFields] = useState<Set<number>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
const idCounterRef = useRef(0);
const logContainerRef = useRef<HTMLDivElement>(null);
// --- Log stream subscription ---
// Active only while the modal is open. On open we replay the file's tail
// (~200 lines) so users have context for "what just happened"; on close
// we tear down the watcher so the main process isn't doing work for a
// hidden UI.
useEffect(() => {
if (!open) return;
setLogs([]);
setExpandedFields(new Set());
setExpandedGroups(new Set());
idCounterRef.current = 0;
window.daemonAPI.startLogStream();
const unsub = window.daemonAPI.onLogLine((line) => {
setLogs((prev) => {
const next = [...prev, { id: ++logIdCounter, line }];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
const id = ++idCounterRef.current;
const parsed = parseLogLine(line, id);
const next =
prev.length >= MAX_LOG_LINES
? [...prev.slice(prev.length - MAX_LOG_LINES + 1), parsed]
: [...prev, parsed];
return next;
});
});
return () => {
unsub();
window.daemonAPI.stopLogStream();
};
}, [open]);
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
// --- Derived: counts per level (for filter chip badges) ---
const levelCounts = useMemo(() => {
const counts: Record<LogLevel, number> = {
DEBUG: 0,
INFO: 0,
WARN: 0,
ERROR: 0,
};
for (const l of logs) {
if (l.level) counts[l.level] += 1;
}
}, [logs, autoScroll]);
return counts;
}, [logs]);
const handleLogScroll = useCallback(() => {
// --- Derived: filtered list (level toggle + search) ---
// Lines that didn't parse (level = null) always pass — they're typically
// panic stack traces / partial writes; never silently drop them.
const filtered = useMemo(() => {
let result = logs;
result = result.filter((l) => {
if (!l.level) return true;
return enabledLevels.has(l.level);
});
if (search) {
const q = search.toLowerCase();
result = result.filter((l) => l.raw.toLowerCase().includes(q));
}
return result;
}, [logs, enabledLevels, search]);
// --- Derived: collapse runs of consecutive lines that share the same
// message into a single group placeholder. The most common case is the
// 1-min `DBG poll: no tasks` heartbeat that otherwise pushes real events
// off-screen. Grouping happens AFTER filtering so toggling DEBUG off
// doesn't strand groups.
const displayed = useMemo<DisplayItem[]>(() => {
const out: DisplayItem[] = [];
for (const line of filtered) {
const last = out[out.length - 1];
if (!last) {
out.push({ kind: "line", line });
continue;
}
const lastMessage =
last.kind === "line" ? last.line.message : last.first.message;
if (lastMessage && lastMessage === line.message) {
if (last.kind === "line") {
out[out.length - 1] = {
kind: "group",
first: last.line,
rest: [line],
};
} else {
last.rest.push(line);
}
} else {
out.push({ kind: "line", line });
}
}
return out;
}, [filtered]);
// --- Auto-scroll: pin to bottom while live; release on user scroll ---
useEffect(() => {
if (!autoScroll) return;
const el = logContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [displayed, autoScroll]);
const handleScroll = useCallback(() => {
const el = logContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
setAutoScroll(atBottom);
// Only flip auto-scroll OFF on user-initiated scroll-up; never flip ON
// here. Re-enabling lives in the "Jump to latest" footer button so a
// burst of lines doesn't yank a reading user back to the bottom.
if (!atBottom && autoScroll) setAutoScroll(false);
}, [autoScroll]);
const handleResume = useCallback(() => {
setAutoScroll(true);
const el = logContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, []);
const scrollToBottom = useCallback(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
setAutoScroll(true);
const handleCopy = useCallback(async () => {
const text = filtered.map((l) => l.raw).join("\n");
try {
await navigator.clipboard.writeText(text);
toast.success(
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
);
} catch (err) {
toast.error("Failed to copy", {
description: err instanceof Error ? err.message : String(err),
});
}
}, [filtered]);
const handleClear = useCallback(() => {
setLogs([]);
setExpandedFields(new Set());
setExpandedGroups(new Set());
}, []);
const handleStart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.start();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to start daemon", { description: result.error });
}
const toggleLevel = useCallback((lv: LogLevel) => {
setEnabledLevels((prev) => {
const next = new Set(prev);
if (next.has(lv)) next.delete(lv);
else next.add(lv);
return next;
});
}, []);
const handleStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to stop daemon", { description: result.error });
}
const toggleFields = useCallback((id: number) => {
setExpandedFields((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
}
const toggleGroup = useCallback((id: number) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const isTransitioning = status.state === "starting" || status.state === "stopping";
const hasActiveFilter = !!search || enabledLevels.size < LEVELS.length;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex flex-col sm:max-w-md"
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
showCloseButton={false}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
<SheetTitle className="flex items-center gap-2">
<Server className="size-4" />
Local Daemon
</SheetTitle>
{/* Header */}
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<Server className="size-4 shrink-0 text-muted-foreground" />
<DialogTitle className="text-sm font-medium">
Local daemon logs
</DialogTitle>
<ContextBadge status={status} runtimeCount={runtimeCount} />
</div>
<button
type="button"
onClick={() => onOpenChange(false)}
@@ -153,157 +264,412 @@ export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
>
<X className="size-4" />
</button>
</SheetHeader>
</div>
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
<div className="shrink-0 space-y-4">
{/* Status info */}
<div className="rounded-lg border p-3 space-y-0.5">
<InfoRow
label="Status"
value={
<span className="flex items-center gap-1.5">
<StatusDot state={status.state} />
{DAEMON_STATE_LABELS[status.state]}
</span>
}
{/* Toolbar */}
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2">
{/* Search */}
<div className="relative w-56">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="h-7 w-full rounded-md border bg-background pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
<InfoRow label="Profile" value={status.profile || "default"} />
{status.serverUrl && (
<InfoRow
label="Server"
value={
<span className="font-mono text-xs" title={status.serverUrl}>
{status.serverUrl}
</span>
}
/>
)}
{status.agents && status.agents.length > 0 && (
<InfoRow label="Agents" value={status.agents.join(", ")} />
)}
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
{status.daemonId && (
<InfoRow
label="Daemon ID"
value={<span className="font-mono text-xs">{status.daemonId}</span>}
/>
)}
{typeof status.workspaceCount === "number" && (
<InfoRow label="Workspaces" value={status.workspaceCount} />
)}
{status.pid && (
<InfoRow
label="PID"
value={<span className="font-mono text-xs">{status.pid}</span>}
/>
)}
</div>
{/* Actions */}
{status.state === "installing_cli" ? (
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
Setting up the local runtime this only happens the first time.
</div>
) : status.state === "cli_not_found" ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
<p className="text-sm">
Couldn&apos;t download the local runtime. Check your network
connection and try again.
</p>
<Button
size="sm"
variant="outline"
onClick={async () => {
setActionLoading(true);
try {
await window.daemonAPI.retryInstall();
} finally {
setActionLoading(false);
}
}}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
{/* Level toggle chips. Each chip is independent — click to
show/hide that level. DEBUG starts hidden because the
poll-loop heartbeat dominates otherwise. */}
<div className="flex items-center gap-1">
{LEVELS.map((lv) => (
<FilterChip
key={lv}
active={enabledLevels.has(lv)}
onClick={() => toggleLevel(lv)}
label={lv}
count={levelCounts[lv]}
variant={lv}
/>
))}
</div>
{/* Right-aligned actions */}
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={handleCopy}
disabled={filtered.length === 0}
>
<CopyIcon className="size-3.5 mr-1.5" />
Copy
</Button>
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={handleClear}
disabled={logs.length === 0}
>
<Trash2 className="size-3.5 mr-1.5" />
Clear
</Button>
</div>
</div>
{/* Logs viewport */}
<div
ref={logContainerRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto bg-muted/20 px-2 py-1 font-mono text-xs"
>
{displayed.length === 0 ? (
<EmptyState
hasLogs={logs.length > 0}
hasFilter={hasActiveFilter}
isRunning={status.state === "running"}
/>
) : (
<div className="flex gap-2">
{status.state === "stopped" ? (
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
<Play className="size-3.5 mr-1.5" />
Start
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={handleStop}
disabled={actionLoading || isTransitioning}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={actionLoading || isTransitioning}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
</>
<div className="flex flex-col">
{displayed.map((item) =>
item.kind === "line" ? (
<LogLineRow
key={item.line.id}
line={item.line}
expanded={expandedFields.has(item.line.id)}
onToggle={() => toggleFields(item.line.id)}
search={search}
/>
) : (
<GroupRows
key={item.first.id}
first={item.first}
rest={item.rest}
expanded={expandedGroups.has(item.first.id)}
onToggle={() => toggleGroup(item.first.id)}
expandedFields={expandedFields}
onToggleFields={toggleFields}
search={search}
/>
),
)}
</div>
)}
</div>
{/* Logs — fills remaining vertical space down to the sheet bottom */}
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
<div className="flex items-center justify-between shrink-0">
<h3 className="text-sm font-medium">Logs</h3>
{!autoScroll && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={scrollToBottom}
>
<ChevronDown className="size-3 mr-1" />
Scroll to bottom
</Button>
)}
</div>
<div
ref={logContainerRef}
onScroll={handleLogScroll}
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
>
{logs.length === 0 ? (
<p className="text-muted-foreground/50 text-center py-8">
{status.state === "running"
? "Waiting for logs…"
: "Start the daemon to see logs"}
</p>
) : (
logs.map((entry) => {
const { className } = colorizeLogLine(entry.line);
return (
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
{entry.line}
</div>
);
})
)}
</div>
</div>
</div>
</SheetContent>
</Sheet>
{/* Status bar — count only. The "is the user following" state is
communicated implicitly by the presence of the Jump-to-latest
button below; an explicit "Paused" word read as "log stream is
paused" (it isn't — data keeps flowing into the buffer). */}
<div className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground">
<span className="tabular-nums">
Showing {filtered.length} of {logs.length}
{logs.length === MAX_LOG_LINES && (
<span className="ml-1 text-muted-foreground/60">
(buffer full)
</span>
)}
</span>
{!autoScroll && (
<button
type="button"
onClick={handleResume}
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 hover:bg-muted hover:text-foreground"
>
<ArrowDown className="size-3" />
Jump to latest
</button>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ---------- Sub-components ----------
function ContextBadge({
status,
runtimeCount,
}: {
status: DaemonStatus;
runtimeCount: number;
}) {
const isRunning = status.state === "running";
return (
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
<span
className={cn(
"size-1.5 rounded-full",
DAEMON_STATE_COLORS[status.state],
)}
/>
<span
className={cn(
"tabular-nums",
isRunning ? "text-foreground" : "text-muted-foreground",
)}
>
{DAEMON_STATE_LABELS[status.state]}
</span>
{isRunning && status.uptime && (
<span className="text-muted-foreground">
· {formatUptime(status.uptime)}
</span>
)}
{isRunning && runtimeCount > 0 && (
<span className="text-muted-foreground">
· {runtimeCount} runtime{runtimeCount === 1 ? "" : "s"}
</span>
)}
</span>
);
}
function FilterChip({
active,
onClick,
label,
count,
variant,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
variant?: LogLevel;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs transition-colors hover:bg-accent",
active
? variant
? LEVEL_BADGE_CLASS[variant]
: "bg-accent text-accent-foreground"
: "border-dashed text-muted-foreground/50",
)}
>
{label}
<span
className={cn(
"tabular-nums",
active ? "text-current/80" : "text-muted-foreground/40",
)}
>
{count}
</span>
</button>
);
}
function LevelBadge({ level }: { level: LogLevel }) {
return (
<span
className={cn(
"inline-flex h-4 shrink-0 items-center rounded border px-1 text-[10px] font-medium uppercase tracking-wide",
LEVEL_BADGE_CLASS[level],
)}
>
{level}
</span>
);
}
function LogLineRow({
line,
expanded,
onToggle,
search,
}: {
line: ParsedLogLine;
expanded: boolean;
onToggle: () => void;
search: string;
}) {
const fieldEntries = Object.entries(line.fields);
const hasFields = fieldEntries.length > 0;
// Unparseable line — render the raw text so nothing is hidden. Common
// for panic stack traces and partial writes during log rotation.
if (!line.timestamp || !line.level) {
return (
<div className="break-all whitespace-pre-wrap px-2 py-0.5 text-muted-foreground/70">
{highlight(line.raw, search)}
</div>
);
}
return (
<div
className={cn(
"grid grid-cols-[auto_auto_minmax(0,1fr)] items-baseline gap-2 rounded px-2 py-0.5 hover:bg-accent/30",
hasFields && "cursor-pointer",
)}
onClick={hasFields ? onToggle : undefined}
>
<span className="shrink-0 tabular-nums text-muted-foreground/60">
{line.timestamp}
</span>
<LevelBadge level={line.level} />
<div className="min-w-0">
<div className="flex min-w-0 items-baseline gap-2">
<span className="break-words">{highlight(line.message, search)}</span>
{hasFields && !expanded && (
<span className="min-w-0 truncate text-muted-foreground/60">
{fieldEntries
.map(([k, v]) => `${k}=${truncateValue(v)}`)
.join(" ")}
</span>
)}
</div>
{expanded && hasFields && (
<div className="ml-1 mt-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-muted-foreground">
{fieldEntries.map(([k, v]) => (
<Fragment key={k}>
<span className="text-muted-foreground/70">{k}</span>
<span className="break-all text-foreground/85">{v}</span>
</Fragment>
))}
</div>
)}
</div>
</div>
);
}
function GroupRows({
first,
rest,
expanded,
onToggle,
expandedFields,
onToggleFields,
search,
}: {
first: ParsedLogLine;
rest: ParsedLogLine[];
expanded: boolean;
onToggle: () => void;
expandedFields: Set<number>;
onToggleFields: (id: number) => void;
search: string;
}) {
// Folded: show the first occurrence so the user still sees a sample
// (timestamp, level, message), then a click-to-expand placeholder for
// the suppressed run. The placeholder uses a dashed border + italics
// so the eye reads it as "not a real line".
if (!expanded) {
return (
<>
<LogLineRow
line={first}
expanded={expandedFields.has(first.id)}
onToggle={() => onToggleFields(first.id)}
search={search}
/>
<button
type="button"
onClick={onToggle}
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 bg-muted/30 px-2 py-0.5 text-[11px] italic text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
>
<span>···</span>
<span>
{rest.length} more &ldquo;{truncateValue(first.message, 48)}
&rdquo; click to expand
</span>
</button>
</>
);
}
// Unfolded: render every line, then a small "collapse" affordance at
// the end so the user can put the toothpaste back in the tube.
return (
<>
<LogLineRow
line={first}
expanded={expandedFields.has(first.id)}
onToggle={() => onToggleFields(first.id)}
search={search}
/>
{rest.map((l) => (
<LogLineRow
key={l.id}
line={l}
expanded={expandedFields.has(l.id)}
onToggle={() => onToggleFields(l.id)}
search={search}
/>
))}
<button
type="button"
onClick={onToggle}
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 px-2 py-0.5 text-[11px] italic text-muted-foreground/60 hover:text-foreground"
>
<span>···</span>
<span>collapse {rest.length + 1} repeated</span>
</button>
</>
);
}
function EmptyState({
hasLogs,
hasFilter,
isRunning,
}: {
hasLogs: boolean;
hasFilter: boolean;
isRunning: boolean;
}) {
let title: string;
let subtitle: string;
if (hasFilter) {
title = "No matching log lines";
subtitle = "Try a different search or level toggle.";
} else if (!isRunning) {
title = "Daemon isn't running";
subtitle = "Start the daemon to see logs here.";
} else if (!hasLogs) {
title = "Waiting for logs…";
subtitle = "New entries will appear in real time.";
} else {
title = "";
subtitle = "";
}
return (
<div className="flex h-full flex-col items-center justify-center gap-1 text-center text-muted-foreground/70">
<p className="text-sm">{title}</p>
<p className="text-xs text-muted-foreground/50">{subtitle}</p>
</div>
);
}
// ---------- Helpers ----------
function truncateValue(value: string, max = 32): string {
return value.length > max ? `${value.slice(0, max)}` : value;
}
function highlight(text: string, query: string): ReactNode {
if (!query) return text;
const q = query.toLowerCase();
const lower = text.toLowerCase();
const idx = lower.indexOf(q);
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className="rounded bg-warning/30 px-0.5 text-foreground">
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
);
}

View File

@@ -1,22 +1,94 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import {
AlertCircle,
Play,
Square,
RotateCw,
Server,
Activity,
ScrollText,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeListOptions } from "@multica/core/runtimes";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import {
Card,
CardAction,
CardDescription,
CardHeader,
CardTitle,
} from "@multica/ui/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { toast } from "sonner";
import { DaemonPanel } from "./daemon-panel";
import type { DaemonStatus } from "../../../shared/daemon-types";
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
DAEMON_STATE_LABELS,
daemonStateDescription,
formatUptime,
} from "../../../shared/daemon-types";
/**
* Header card on the desktop Runtimes page that surfaces the daemon embedded
* in this Electron app. The same daemon process registers N runtimes with the
* server (one per detected CLI), which appear in the runtime list below — so
* this card is the parent control surface for "what's running on this Mac".
*
* Why this lives only on desktop: web users don't have an embedded daemon;
* they bring their own (CLI-launched or remote VM) and just see runtimes in
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
*/
export function DaemonRuntimeCard() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [panelOpen, setPanelOpen] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [confirmStop, setConfirmStop] = useState(false);
const wsId = useWorkspaceId();
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
// Snapshot also includes each agent's latest terminal; the filter below
// drops anything that isn't running/dispatched, so terminal rows pass
// through harmlessly.
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
// Used both to count "how many CLIs am I contributing" and to figure
// out which active tasks would be impacted by a Stop.
const localRuntimeIds = useMemo(() => {
if (!status.daemonId) return new Set<string>();
return new Set(
runtimes
.filter((r) => r.daemon_id === status.daemonId)
.map((r) => r.id),
);
}, [runtimes, status.daemonId]);
const runtimeCount = localRuntimeIds.size;
// Tasks that are actually doing work on this daemon right now —
// running or dispatched. Queued tasks haven't claimed a runtime yet,
// so stopping the daemon won't break them (they'll wait for any
// available daemon). The number drives the Stop-confirmation dialog.
const affectedTasks = useMemo(
() =>
snapshot.filter(
(t) =>
localRuntimeIds.has(t.runtime_id) &&
(t.status === "running" || t.status === "dispatched"),
),
[snapshot, localRuntimeIds],
);
useEffect(() => {
window.daemonAPI.getStatus().then((s) => setStatus(s));
@@ -36,7 +108,10 @@ export function DaemonRuntimeCard() {
}
}, []);
const handleStop = useCallback(async () => {
// The actual stop call, separated from the click handler so we can call
// it both from the direct path (no active tasks) and from the confirm
// dialog's confirm button.
const performStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
if (!result.success) {
@@ -44,112 +119,214 @@ export function DaemonRuntimeCard() {
}
}, []);
// Click on the Stop button. If there's nothing running, just stop;
// otherwise pop a confirm dialog explaining the blast radius.
const handleStopClick = useCallback(() => {
if (affectedTasks.length === 0) {
void performStop();
} else {
setConfirmStop(true);
}
}, [affectedTasks.length, performStop]);
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
return;
}
// Success feedback — the daemon takes a few seconds to come back online,
// and the only other UI signal is the state badge flipping briefly. A
// toast confirms the click was received and tells the user what to expect.
toast.success("Restarting daemon", {
description: "Runtimes will be back online in a few seconds.",
});
}, []);
const handleRetryInstall = useCallback(async () => {
setActionLoading(true);
try {
await window.daemonAPI.retryInstall();
} finally {
setActionLoading(false);
}
}, []);
const isTransitioning = status.state === "starting" || status.state === "stopping";
const isRunning = status.state === "running";
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
const isStopped = status.state === "stopped";
const isCliMissing = status.state === "cli_not_found";
const isTransitioning =
status.state === "starting" || status.state === "stopping";
const isInstalling = status.state === "installing_cli";
return (
<>
<div
role="button"
tabIndex={0}
onClick={() => setPanelOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setPanelOpen(true);
}
}}
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
<Server className="size-4 text-muted-foreground" />
</div>
<div>
<h3 className="text-sm font-medium">Local Daemon</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
{isRunning && status.uptime && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
</>
<Card size="sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="size-4 text-muted-foreground" />
Local daemon
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
<span
className={cn(
"size-1.5 rounded-full",
DAEMON_STATE_COLORS[status.state],
)}
{isRunning && status.agents && status.agents.length > 0 && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
</>
/>
<span
className={cn(
"tabular-nums",
isRunning ? "text-foreground" : "text-muted-foreground",
)}
</div>
</div>
</div>
<div
className="flex items-center gap-1.5 shrink-0"
onClick={stopPropagation}
>
{isStopped && (
<Button
size="sm"
variant="outline"
onClick={handleStart}
disabled={actionLoading || status.state === "cli_not_found"}
>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isRunning && (
<>
{DAEMON_STATE_LABELS[status.state]}
</span>
{isRunning && status.uptime && (
<span className="text-muted-foreground">
· {formatUptime(status.uptime)}
</span>
)}
</span>
</CardTitle>
<CardDescription>
{daemonStateDescription(status.state, runtimeCount)}
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-1.5">
{isRunning && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => setPanelOpen(true)}
>
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
{isStopped && (
<Button
size="sm"
variant="ghost"
onClick={handleRestart}
onClick={handleStart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isCliMissing && (
<Button
size="sm"
variant="outline"
onClick={handleStop}
onClick={handleRetryInstall}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
<RotateCw className="size-3.5 mr-1.5" />
Retry setup
</Button>
</>
)}
{isTransitioning && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</div>
</div>
)}
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</CardAction>
</CardHeader>
</Card>
<DaemonPanel
open={panelOpen}
onOpenChange={setPanelOpen}
status={status}
runtimeCount={runtimeCount}
/>
<StopConfirmDialog
open={confirmStop}
onOpenChange={setConfirmStop}
affectedCount={affectedTasks.length}
onConfirm={() => {
setConfirmStop(false);
void performStop();
}}
/>
</>
);
}
// ---------- Sub-components ----------
function StopConfirmDialog({
open,
onOpenChange,
affectedCount,
onConfirm,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
affectedCount: number;
onConfirm: () => void;
}) {
const plural = affectedCount === 1 ? "" : "s";
const verb = affectedCount === 1 ? "is" : "are";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm" showCloseButton={false}>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<DialogHeader className="flex-1 gap-1">
<DialogTitle className="text-sm font-semibold">
Stop daemon with {affectedCount} active task{plural}?
</DialogTitle>
<DialogDescription className="text-xs leading-relaxed">
{affectedCount} task{plural} {verb} currently running on this
device. Stopping now will interrupt {affectedCount === 1 ? "it" : "them"}{" "}
affected tasks get marked <strong>failed</strong> once the
timeout hits. The daemon won&apos;t auto-restart.
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Stop daemon
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,13 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import type { DaemonPrefs } from "../../../shared/daemon-types";
import { cn } from "@multica/ui/lib/utils";
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
DAEMON_STATE_LABELS,
formatUptime,
} from "../../../shared/daemon-types";
function SettingRow({
label,
@@ -10,7 +16,7 @@ function SettingRow({
}: {
label: string;
description: string;
children: React.ReactNode;
children: ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-6 py-4">
@@ -23,14 +29,44 @@ function SettingRow({
);
}
// One row inside the diagnostics block. Values that are likely to be
// long IDs / URLs render as monospaced + truncated with a tooltip.
function DiagnosticsRow({
label,
value,
mono,
}: {
label: string;
value: ReactNode;
mono?: boolean;
}) {
return (
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-baseline gap-3 py-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 truncate text-sm",
mono && "font-mono text-xs",
)}
title={typeof value === "string" ? value : undefined}
>
{value}
</span>
</div>
);
}
export function DaemonSettingsTab() {
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
useEffect(() => {
window.daemonAPI.getPrefs().then(setPrefs);
window.daemonAPI.isCliInstalled().then(setCliInstalled);
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const updatePref = useCallback(
@@ -98,6 +134,68 @@ export function DaemonSettingsTab() {
)}
</div>
</div>
{/* Diagnostics — moved out of the logs panel so the panel can focus
on logs. These fields matter for support tickets and bug reports,
not for everyday use. */}
<div className="mt-8">
<h3 className="text-sm font-semibold">Diagnostics</h3>
<p className="text-xs text-muted-foreground mt-1">
Identification and connection details. Useful when filing a bug
report or investigating why a runtime isn&apos;t showing up.
</p>
<div className="mt-3 rounded-lg border bg-muted/20 px-4 py-2">
<DiagnosticsRow
label="State"
value={
<span className="inline-flex items-center gap-1.5">
<span
className={cn(
"size-1.5 rounded-full",
DAEMON_STATE_COLORS[status.state],
)}
/>
{DAEMON_STATE_LABELS[status.state]}
</span>
}
/>
<DiagnosticsRow
label="Uptime"
value={status.uptime ? formatUptime(status.uptime) : "—"}
/>
<DiagnosticsRow
label="PID"
value={status.pid ?? "—"}
mono={!!status.pid}
/>
<DiagnosticsRow
label="Daemon ID"
value={status.daemonId ?? "—"}
mono={!!status.daemonId}
/>
<DiagnosticsRow
label="Profile"
value={status.profile || "default"}
/>
<DiagnosticsRow
label="Server URL"
value={status.serverUrl ?? "—"}
mono={!!status.serverUrl}
/>
<DiagnosticsRow
label="Device name"
value={status.deviceName ?? "—"}
/>
<DiagnosticsRow
label="Workspaces"
value={
typeof status.workspaceCount === "number"
? status.workspaceCount
: "—"
}
/>
</div>
</div>
</div>
);
}

View File

@@ -14,8 +14,9 @@ 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 { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { useDesktopUnreadBadge } from "@multica/views/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
@@ -97,6 +98,38 @@ function useInternalLinkHandler() {
}, []);
}
/**
* Bridge between the renderer and the Electron main process for inbox-level
* OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the
* current workspace's id for the badge hook.
*
* Two responsibilities:
* 1. Mirror the unread inbox count onto the dock/taskbar badge.
* 2. When the user clicks an OS notification, open the notified
* workspace's inbox focused on that item. The route uses the `slug`
* that the notification was *emitted* with — not the currently active
* workspace — so a notification from workspace A always opens A's
* inbox even if the user has since switched to workspace B. Marking
* the row read is handled by InboxPage's selected-item effect, which
* covers both click-to-select and URL-param-select paths.
*/
function DesktopInboxBridge() {
const workspace = useCurrentWorkspace();
useDesktopUnreadBadge(workspace?.id ?? null);
useEffect(() => {
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
if (!slug) return;
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
);
});
}, []);
return null;
}
export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
@@ -118,6 +151,7 @@ export function DesktopShell() {
users see the window-level overlay (new-workspace flow)
triggered by IndexRedirect, not a route. */}
<WorkspaceSlugProvider slug={slug}>
<DesktopInboxBridge />
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}

View File

@@ -0,0 +1,243 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
// vi.hoisted shared state — every store mock reads the same object so each
// test can mutate it then re-render to drive the tracker.
const state = vi.hoisted(() => ({
user: null as { id: string } | null,
overlay: null as { type: string; invitationId?: string } | null,
activeWorkspaceSlug: null as string | null,
byWorkspace: {} as Record<
string,
{ activeTabId: string; tabs: { id: string; path: string }[] }
>,
capturePageview: vi.fn<(path?: string) => void>(),
}));
vi.mock("@multica/core/analytics", () => ({
capturePageview: state.capturePageview,
}));
// Auth store — single selector pattern (`s => s.user`).
vi.mock("@multica/core/auth", () => {
const useAuthStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useAuthStore };
});
// Window overlay store — same shape.
vi.mock("@/stores/window-overlay-store", () => {
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useWindowOverlayStore };
});
// Tab store — selectors read activeWorkspaceSlug + byWorkspace. Also expose
// getState() for the seed pass and the helpers the tracker imports
// (useActiveTabIdentity, getActiveTab) so we don't have to re-import them
// from the real store inside a mocked module.
vi.mock("@/stores/tab-store", () => {
const useTabStore = Object.assign(
(selector: (s: typeof state) => unknown) => selector(state),
{ getState: () => state },
);
const getActiveTab = (s: typeof state) => {
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) ?? null;
};
const useActiveTabIdentity = () => ({
slug: state.activeWorkspaceSlug,
tabId: state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
});
return { useTabStore, getActiveTab, useActiveTabIdentity };
});
import { PageviewTracker } from "./pageview-tracker";
function reset() {
state.user = { id: "u1" };
state.overlay = null;
state.activeWorkspaceSlug = null;
state.byWorkspace = {};
state.capturePageview.mockClear();
}
beforeEach(() => {
reset();
});
describe("PageviewTracker", () => {
it("suppresses pageview when switching to a previously-visible tab on its existing path", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
// Initial mount on tA — seeded as observed, no pageview because both
// tabs were already in the persisted store before the tracker mounted.
expect(state.capturePageview).not.toHaveBeenCalled();
// Switch to tB (already-known tab on its already-known path).
state.byWorkspace = {
acme: {
activeTabId: "tB",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
// Switch back to tA — still no pageview.
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
});
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
state.byWorkspace = {
acme: {
activeTabId: "tC",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tC", path: "/acme/agents" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/acme/agents");
});
it("fires pageview when switchWorkspace opens a new path in another workspace", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Cross-workspace navigation: switchWorkspace("butter", "/butter/inbox")
// creates a fresh tab in the destination workspace and makes it active.
state.byWorkspace = {
acme: { activeTabId: "tA", tabs: [{ id: "tA", path: "/acme/issues" }] },
butter: {
activeTabId: "tD",
tabs: [{ id: "tD", path: "/butter/inbox" }],
},
};
state.activeWorkspaceSlug = "butter";
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/butter/inbox");
});
it("fires pageview on intra-tab navigation (path changes for the same tabId)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues/123" }],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/acme/issues/123");
});
it("fires overlay and login pageviews and suppresses re-entry into the same tab afterward", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Open onboarding overlay.
state.overlay = { type: "onboarding" };
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenLastCalledWith("/onboarding");
// Close overlay back to the tab — the tab is already observed on
// /acme/issues so this is a re-activation, no pageview.
state.capturePageview.mockClear();
state.overlay = null;
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
// Logout fires /login.
state.user = null;
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenLastCalledWith("/login");
});
it("suppresses on initial mount when the active tab was restored from persistence", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
render(<PageviewTracker />);
// Restored tab — seeded, treated as a re-activation.
expect(state.capturePageview).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,17 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { capturePageview } from "@multica/core/analytics";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore } from "@/stores/tab-store";
import {
getActiveTab,
useActiveTabIdentity,
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.
* Fires a PostHog $pageview whenever the user's visible surface changes,
* EXCEPT for re-activations of an already-known tab on its already-known
* path.
*
* Desktop has three layers that can own the visible page:
*
@@ -17,10 +23,18 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
* 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.
* Tab-switch suppression: re-activating an already-open tab surfaces a
* previously-visited path under a `(workspace, tabId)` we have already
* seen — the pageview was emitted when the user originally navigated
* there, so re-emitting on every switch just inflates PostHog billing
* without adding signal (real-data audit: desktop tab switches were
* ~50% of all `$pageview` events).
*
* Newly opened tabs (`openInNewTab`, `addTab`) and cross-workspace
* `switchWorkspace(slug, path)` to a previously-unseen tab still fire,
* because their key is not in the observed map yet. The map is seeded
* from the persisted tab store on first render so tabs restored from a
* previous session don't all re-emit on first activation.
*
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
* `initAnalytics`) so this component owns the event shape, matching the web
@@ -29,34 +43,75 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
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 { slug: activeWorkspaceSlug, tabId: activeTabId } = useActiveTabIdentity();
const activeTabPath = useTabStore((s) => getActiveTab(s)?.path ?? null);
const path = resolvePath(user, overlay, activeTabPath);
// (slug:tabId) → last path observed while that tab was visible. Lets us
// tell "re-activating a tab on a path we already saw" (suppress) apart
// from "newly opened tab" or "intra-tab navigation" (fire). Seeded
// synchronously on first render from the persisted tab store so
// session-restored tabs don't re-emit on first click.
const observedTabsRef = useRef<Map<string, string> | null>(null);
if (observedTabsRef.current === null) {
const seed = new Map<string, string>();
for (const [slug, group] of Object.entries(useTabStore.getState().byWorkspace)) {
for (const tab of group.tabs) {
seed.set(`${slug}:${tab.id}`, tab.path);
}
}
observedTabsRef.current = seed;
}
const lastSurfaceRef = useRef<{
kind: "login" | "overlay" | "tab" | null;
key: string | null;
path: string | null;
}>({ kind: null, key: null, path: null });
useEffect(() => {
if (!path) return;
let kind: "login" | "overlay" | "tab";
let path: string;
let key: string | null = null;
if (!user) {
kind = "login";
path = "/login";
} else if (overlay) {
kind = "overlay";
path = overlayPath(overlay);
} else if (activeTabPath && activeTabId && activeWorkspaceSlug) {
kind = "tab";
key = `${activeWorkspaceSlug}:${activeTabId}`;
path = activeTabPath;
} else {
return;
}
const observed = observedTabsRef.current!;
const last = lastSurfaceRef.current;
const next = { kind, key, path };
if (kind === "tab" && key !== null) {
const knownPath = observed.get(key);
const isReactivation =
last.key !== key && knownPath !== undefined && knownPath === path;
observed.set(key, path);
if (isReactivation) {
lastSurfaceRef.current = next;
return;
}
}
const unchanged =
last.kind === kind && last.key === key && last.path === path;
if (unchanged) return;
capturePageview(path);
}, [path]);
lastSurfaceRef.current = next;
}, [user, overlay, activeWorkspaceSlug, activeTabId, activeTabPath]);
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":
@@ -65,5 +120,7 @@ function overlayPath(overlay: WindowOverlay): string {
return "/onboarding";
case "invite":
return `/invite/${overlay.invitationId}`;
case "invitations":
return "/invitations";
}
}

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import { parseLogLine } from "./parse-daemon-log";
// All sample lines below are taken verbatim from real daemon output (Go
// `slog` + `lmittmann/tint` v1.1.3 with NoColor=true). The parser must
// stay aligned with what tint actually writes — not what we assume.
describe("parseLogLine", () => {
it("parses tint's 3-letter INF level", () => {
const line =
"17:52:35.587 INF task completed component=daemon task=c45266e5 status=completed";
const r = parseLogLine(line, 1);
expect(r.timestamp).toBe("17:52:35.587");
expect(r.level).toBe("INFO");
expect(r.message).toBe("task completed");
expect(r.fields).toEqual({
component: "daemon",
task: "c45266e5",
status: "completed",
});
});
it("parses 3-letter DBG / WRN / ERR levels", () => {
expect(parseLogLine("17:53:06.644 DBG agent component=daemon", 1).level).toBe("DEBUG");
expect(parseLogLine("07:48:09.391 WRN claim task failed component=daemon", 1).level).toBe("WARN");
expect(parseLogLine("12:00:00.000 ERR something bad component=daemon", 1).level).toBe("ERROR");
});
it("still accepts 4-letter level names (defensive against config changes)", () => {
const r = parseLogLine("12:00:00.000 INFO regular component=daemon", 1);
expect(r.level).toBe("INFO");
expect(r.message).toBe("regular");
});
it("tolerates the +N / -N delta tint appends for non-standard slog levels", () => {
// tint emits e.g. "INF+1" when slog.Log is called with LevelInfo+1.
// We treat the base level as canonical and drop the delta from the UI.
const r = parseLogLine("12:00:00.000 INF+1 unusual delta component=daemon", 1);
expect(r.level).toBe("INFO");
expect(r.message).toBe("unusual delta");
});
it("preserves message text containing colons and special chars", () => {
// Real sample: "tool #1: Skill component=daemon task=..."
const r = parseLogLine(
"17:52:54.578 INF tool #1: Skill component=daemon task=8791b717",
1,
);
expect(r.message).toBe("tool #1: Skill");
expect(r.fields).toEqual({ component: "daemon", task: "8791b717" });
});
it("unquotes a double-quoted value containing escaped quotes", () => {
// Real sample with escaped quotes inside the agent's emitted text.
const line =
'17:53:06.644 DBG agent component=daemon task=8791b717 text="The issue is just \\"ping\\" with no description."';
const r = parseLogLine(line, 1);
expect(r.message).toBe("agent");
expect(r.fields.text).toBe('The issue is just "ping" with no description.');
expect(r.fields.task).toBe("8791b717");
});
it("handles a quoted value containing a URL with embedded escaped quotes and a colon", () => {
// Real sample: error="Post \"http://...\": dial tcp ..."
const line =
'07:48:09.391 WRN claim task failed component=daemon runtime_id=03f8ff17-276d error="Post \\"http://localhost:8080/api/daemon/runtimes/abc/tasks/claim\\": dial tcp [::1]:8080: connect: connection refused"';
const r = parseLogLine(line, 1);
expect(r.level).toBe("WARN");
expect(r.message).toBe("claim task failed");
expect(r.fields.runtime_id).toBe("03f8ff17-276d");
expect(r.fields.error).toBe(
'Post "http://localhost:8080/api/daemon/runtimes/abc/tasks/claim": dial tcp [::1]:8080: connect: connection refused',
);
});
it("handles a quoted value with internal whitespace (e.g. args array)", () => {
const line =
'17:52:48.757 INF agent command component=daemon exec=claude args="[-p --output-format stream-json --verbose]"';
const r = parseLogLine(line, 1);
expect(r.message).toBe("agent command");
expect(r.fields.exec).toBe("claude");
expect(r.fields.args).toBe("[-p --output-format stream-json --verbose]");
});
it("handles message words ending with characters before the field block", () => {
// 'execenv:' is part of the message — the colon shouldn't confuse parsing.
const r = parseLogLine(
"17:52:48.757 INF execenv: prepared env component=daemon repos_available=0",
1,
);
expect(r.message).toBe("execenv: prepared env");
expect(r.fields).toEqual({ component: "daemon", repos_available: "0" });
});
it("falls back to raw rendering for non-matching lines (panic stack frame)", () => {
const r = parseLogLine("\tat github.com/multica/foo (line 42)", 1);
expect(r.timestamp).toBeNull();
expect(r.level).toBeNull();
expect(r.message).toBe("\tat github.com/multica/foo (line 42)");
expect(r.fields).toEqual({});
expect(r.raw).toBe("\tat github.com/multica/foo (line 42)");
});
it("falls back to raw rendering for unrecognised level tokens", () => {
// If tint ever emits something we don't know, never crash; show raw.
const r = parseLogLine("12:00:00.000 TRACE something exotic", 1);
expect(r.timestamp).toBeNull();
expect(r.level).toBeNull();
expect(r.raw).toBe("12:00:00.000 TRACE something exotic");
});
it("attaches an id to every parsed line for stable React keys", () => {
const a = parseLogLine("17:52:35.587 INF first component=daemon", 7);
const b = parseLogLine("17:52:35.588 INF second component=daemon", 8);
expect(a.id).toBe(7);
expect(b.id).toBe(8);
});
it("returns empty fields object when there are no key=value pairs", () => {
const r = parseLogLine("17:52:35.587 INF a bare message with no fields", 1);
expect(r.message).toBe("a bare message with no fields");
expect(r.fields).toEqual({});
});
});

View File

@@ -0,0 +1,96 @@
// Pure parser for daemon log lines. The daemon writes via Go's slog with
// the `tint` handler in NoColor mode (the file isn't a TTY), so each line
// has a stable shape:
//
// HH:MM:SS.mmm LEVEL message text key=value key2="quoted value"
//
// We split it into structured pieces so the UI can render timestamp,
// level, message and structured fields in separate columns and let users
// filter / search across them. Anything that doesn't match (panic stack
// traces, third-party prints, partial writes during log rotation) falls
// back to a raw view — we never drop input.
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
export interface ParsedLogLine {
/** Monotonic id assigned at receive time; stable across re-renders. */
id: number;
/** "HH:MM:SS.mmm" or null when the line didn't match the standard shape. */
timestamp: string | null;
level: LogLevel | null;
/** Human-readable message body, with structured fields stripped off. */
message: string;
/** key/value pairs trailing the message. Empty if there were none. */
fields: Record<string, string>;
/** The original line, kept for fallback rendering and copy-to-clipboard. */
raw: string;
}
// `tint` v1.x emits the 3-letter short form (DBG / INF / WRN / ERR) and,
// for non-standard slog levels, appends a signed delta (e.g. "INF+1",
// "DBG-2"). We accept both the short and 4-letter long forms (defensive
// against future config changes) and normalize them to a canonical
// 4-letter LogLevel. The optional `[+-]\d+` suffix is captured into the
// regex and discarded — surfacing `INF+1` to the UI doesn't help users
// and complicates the level filter chips.
const HEADER_RE =
/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+(DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR)(?:[+-]\d+)?\s+(.+)$/;
const LEVEL_NORMALIZE: Record<string, LogLevel> = {
DEBUG: "DEBUG",
DBG: "DEBUG",
INFO: "INFO",
INF: "INFO",
WARN: "WARN",
WRN: "WARN",
ERROR: "ERROR",
ERR: "ERROR",
};
// Anchored to the END of the remaining string so we peel one field at a
// time from the right. `value` is either a double-quoted string (which may
// contain escaped chars) or any non-whitespace run.
const TRAILING_FIELD_RE = /\s+([a-zA-Z_][a-zA-Z0-9_.]*)=("(?:[^"\\]|\\.)*"|\S+)$/;
function unquote(value: string): string {
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
return value;
}
function extractTrailingFields(rest: string): {
message: string;
fields: Record<string, string>;
} {
const fields: Record<string, string> = {};
let work = rest;
while (true) {
const match = work.match(TRAILING_FIELD_RE);
if (!match || match.index === undefined) break;
fields[match[1]!] = unquote(match[2]!);
work = work.slice(0, match.index);
}
return { message: work.trim(), fields };
}
export function parseLogLine(raw: string, id: number): ParsedLogLine {
const match = raw.match(HEADER_RE);
if (!match) {
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
}
const [, timestamp, level, rest] = match;
const normalized = LEVEL_NORMALIZE[level!];
if (!normalized) {
// Unknown level token — keep raw shape so we don't mis-categorize.
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
}
const { message, fields } = extractTrailingFields(rest!);
return {
id,
timestamp: timestamp!,
level: normalized,
message,
fields,
raw,
};
}

View File

@@ -5,12 +5,13 @@ import { Button } from "@multica/ui/components/ui/button";
type CheckState =
| { status: "idle" }
| { status: "checking" }
| { status: "up-to-date"; currentVersion: string }
| { status: "up-to-date" }
| { status: "available"; latestVersion: string }
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const [state, setState] = useState<CheckState>({ status: "idle" });
const currentVersion = window.desktopAPI.appInfo.version;
const handleCheck = useCallback(async () => {
setState({ status: "checking" });
@@ -22,7 +23,7 @@ export function UpdatesSettingsTab() {
setState(
result.available
? { status: "available", latestVersion: result.latestVersion }
: { status: "up-to-date", currentVersion: result.currentVersion },
: { status: "up-to-date" },
);
}, []);
@@ -35,6 +36,15 @@ export function UpdatesSettingsTab() {
</p>
<div className="mt-6 divide-y">
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Current version</p>
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
v{currentVersion}
</p>
</div>
</div>
<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>
@@ -45,7 +55,7 @@ export function UpdatesSettingsTab() {
{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}).
You&apos;re on the latest version.
</p>
)}
{state.status === "available" && (

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
import { InvitePage } from "@multica/views/invite";
import { InvitationsPage } from "@multica/views/invitations";
import { OnboardingFlow } from "@multica/views/onboarding";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
@@ -58,6 +59,7 @@ function WindowOverlayInner() {
onBack={onBack}
/>
)}
{overlay.type === "invitations" && <InvitationsPage />}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws) => {

View File

@@ -9,6 +9,7 @@ import {
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { WorkspacePresencePrefetch } from "@multica/views/layout";
import { useTabStore } from "@/stores/tab-store";
/**
@@ -82,6 +83,7 @@ export function WorkspaceRouteLayout() {
return (
<WorkspaceSlugProvider slug={workspaceSlug}>
<WorkspacePresencePrefetch />
<Outlet />
</WorkspaceSlugProvider>
);

View File

@@ -0,0 +1,18 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { AgentDetailPage as SharedAgentDetailPage } from "@multica/views/agents";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function AgentDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const agent = agents.find((a) => a.id === id) ?? null;
useDocumentTitle(agent?.name ?? "Agent");
if (!id) return null;
return <SharedAgentDetailPage agentId={id} />;
}

View File

@@ -2,14 +2,23 @@ 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";
function requireRuntimeAppUrl(): string {
const runtimeConfig = window.desktopAPI.runtimeConfig;
if (!runtimeConfig.ok) {
throw new Error(
"Invariant violated: DesktopLoginPage rendered before App accepted runtime config",
);
}
return runtimeConfig.config.appUrl;
}
export function DesktopLoginPage() {
const webUrl = requireRuntimeAppUrl();
const handleGoogleLogin = () => {
// Open web login page in the default browser with platform=desktop flag.
// The web callback will redirect back via multica:// deep link with the token.
window.desktopAPI.openExternal(
`${WEB_URL}/login?platform=desktop`,
`${webUrl}/login?platform=desktop`,
);
};

View File

@@ -0,0 +1,18 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { RuntimeDetailPage as SharedRuntimeDetailPage } from "@multica/views/runtimes";
import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function RuntimeDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
const runtime = runtimes?.find((r) => r.id === id);
useDocumentTitle(runtime?.name ?? "Runtime");
if (!id) return null;
return <SharedRuntimeDetailPage runtimeId={id} />;
}

View File

@@ -0,0 +1,17 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { SkillDetailPage as SharedSkillDetailPage } from "@multica/views/skills";
import { useWorkspaceId } from "@multica/core/hooks";
import { skillDetailOptions } from "@multica/core/workspace/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function SkillDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data: skill } = useQuery(skillDetailOptions(wsId, id ?? ""));
useDocumentTitle(skill?.name ?? "Skill");
if (!id) return null;
return <SharedSkillDetailPage skillId={id} />;
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { runtimeKeys } from "@multica/core/runtimes";
import type { AgentRuntime } from "@multica/core/types";
/**
* DesktopAPI exposes a richer DaemonStatus shape than the public AgentRuntime
* type — we redeclare the fields we consume here to avoid coupling the bridge
* to the desktop preload typings (which live in apps/desktop/src/preload).
*/
interface DaemonStatusLike {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
daemonId?: string;
}
/**
* Merges a local DaemonStatus into an AgentRuntime row. Only the `status`
* field is overridden; other fields (name, provider, last_seen_at, etc)
* remain server-authoritative. We deliberately ignore intermediate states
* (starting / stopping / installing_cli / cli_not_found) so the cache
* doesn't flap during boot — if the daemon is in such a state, the runtime
* is effectively offline anyway, and the server-side sweeper will mark it
* within 75s.
*/
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
if (status.state === "stopped" || status.state === "stopping") {
return { ...rt, status: "offline" };
}
if (status.state === "running") {
return {
...rt,
status: "online",
last_seen_at: new Date().toISOString(),
};
}
return rt;
}
/**
* Subscribes to local daemon status changes via Electron IPC and writes them
* into the runtimes Query cache for the active workspace.
*
* Why: the server-side runtime sweeper takes up to 75s to flip a runtime to
* offline (heartbeat timeout 45s + sweep interval 30s). On the desktop app
* we know about local daemon state instantly via IPC, so we use it to
* pre-populate the cache and give users a sub-second feedback loop. Web and
* "looking at someone else's daemon" still go through the server path.
*
* Same-daemon-multiple-runtimes: a single daemon can back several runtimes
* in the same workspace (one per provider). We map across all matches so
* every related runtime row sees the same status flip.
*/
export function useDaemonIPCBridge(wsId: string | undefined): void {
const qc = useQueryClient();
useEffect(() => {
if (!wsId) return;
if (typeof window === "undefined") return;
const daemonAPI = (window as unknown as { daemonAPI?: { onStatusChange?: (cb: (s: DaemonStatusLike) => void) => () => void } }).daemonAPI;
if (!daemonAPI?.onStatusChange) return;
const unsubscribe = daemonAPI.onStatusChange((status) => {
if (!status.daemonId) return;
qc.setQueryData<AgentRuntime[]>(runtimeKeys.list(wsId), (old) => {
if (!old) return old;
return old.map((rt) =>
rt.daemon_id === status.daemonId ? mergeDaemonStatus(rt, status) : rt,
);
});
});
return unsubscribe;
}, [wsId, qc]);
}

View File

@@ -0,0 +1,31 @@
import type { LocaleAdapter, SupportedLocale } from "@multica/core/i18n";
const STORAGE_KEY = "multica-locale";
// Desktop adapter:
// - User choice: localStorage (set by Settings switcher).
// - System preference: locale main injected via additionalArguments
// (read from preload, exposed on window.desktopAPI.systemLocale).
// - Persist: localStorage. The Settings switcher additionally PATCHes
// /api/me when logged in so user.language follows the user across devices.
export function createDesktopLocaleAdapter(systemLocale: string): LocaleAdapter {
return {
getUserChoice() {
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
},
getSystemPreferences() {
return systemLocale ? [systemLocale] : [];
},
persist(locale: SupportedLocale) {
try {
window.localStorage.setItem(STORAGE_KEY, locale);
} catch {
// Best-effort
}
},
};
}

View File

@@ -15,11 +15,15 @@ import {
} from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
// 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";
function requireRuntimeAppUrl(scope: string): string {
const runtimeConfig = window.desktopAPI.runtimeConfig;
if (!runtimeConfig.ok) {
throw new Error(
`Invariant violated: ${scope} rendered before App accepted runtime config`,
);
}
return runtimeConfig.config.appUrl;
}
/**
* Extract the leading workspace slug from a path, or null if the path isn't
@@ -61,6 +65,13 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
}
return true;
}
if (path === "/invitations") {
overlay.open({ type: "invitations" });
if (router && router.state.location.pathname !== "/") {
router.navigate("/", { replace: true });
}
return true;
}
if (path.startsWith("/invite/")) {
let id = "";
try {
@@ -109,23 +120,38 @@ export function DesktopNavigationProvider({
}: {
children: React.ReactNode;
}) {
const appUrl = requireRuntimeAppUrl("DesktopNavigationProvider");
// Primitive-only subscriptions so this component doesn't re-render on
// unrelated store updates (e.g. an inactive tab's router tick). We
// resolve the active router here only to subscribe once per tab switch.
const { tabId: activeTabId } = useActiveTabIdentity();
const router = useActiveTabRouter();
const [pathname, setPathname] = useState(
router?.state.location.pathname ?? "/",
// Mirror the active tab router's full location (pathname + search) so
// shell-level consumers of useNavigation() — ChatWindow in particular —
// can read URL search params. Must stay in sync with TabNavigationProvider
// below; a partial shape here (just pathname) silently broke focus-mode
// anchor resolution on `/inbox?issue=…`.
const [location, setLocation] = useState<{ pathname: string; search: string }>(
() => ({
pathname: router?.state.location.pathname ?? "/",
search: router?.state.location.search ?? "",
}),
);
useEffect(() => {
if (!router) {
setPathname("/");
setLocation({ pathname: "/", search: "" });
return;
}
setPathname(router.state.location.pathname);
setLocation({
pathname: router.state.location.pathname,
search: router.state.location.search,
});
return router.subscribe((state) => {
setPathname(state.location.pathname);
setLocation({
pathname: state.location.pathname,
search: state.location.search,
});
});
}, [activeTabId, router]);
@@ -150,8 +176,8 @@ export function DesktopNavigationProvider({
back: () => {
currentActiveTab()?.router.navigate(-1);
},
pathname,
searchParams: new URLSearchParams(),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path: string, title?: string) => {
// Cross-workspace "open in new tab" switches workspace and opens
// the path there; same-workspace just adds a tab in the current group.
@@ -165,9 +191,9 @@ export function DesktopNavigationProvider({
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),
[pathname],
[appUrl, location],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
@@ -190,6 +216,7 @@ export function TabNavigationProvider({
router: DataRouter;
children: React.ReactNode;
}) {
const appUrl = requireRuntimeAppUrl("TabNavigationProvider");
const [location, setLocation] = useState(router.state.location);
useEffect(() => {
@@ -225,9 +252,9 @@ export function TabNavigationProvider({
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),
[router, location],
[appUrl, router, location],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;

View File

@@ -9,6 +9,9 @@ import type { RouteObject } from "react-router-dom";
import { IssueDetailPage } from "./pages/issue-detail-page";
import { ProjectDetailPage } from "./pages/project-detail-page";
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
import { SkillDetailPage } from "./pages/skill-detail-page";
import { AgentDetailPage } from "./pages/agent-detail-page";
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { AutopilotsPage } from "@multica/views/autopilots/components";
@@ -116,8 +119,23 @@ export const appRoutes: RouteObject[] = [
element: <DesktopRuntimesPage />,
handle: { title: "Runtimes" },
},
{
path: "runtimes/:id",
element: <RuntimeDetailPage />,
handle: { title: "Runtime" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{
path: "skills/:id",
element: <SkillDetailPage />,
handle: { title: "Skill" },
},
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{
path: "agents/:id",
element: <AgentDetailPage />,
handle: { title: "Agent" },
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "settings",

View File

@@ -15,6 +15,7 @@ import { create } from "zustand";
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string }
| { type: "invitations" }
| { type: "onboarding" };
interface WindowOverlayStore {

View File

@@ -51,3 +51,35 @@ export function formatUptime(uptime?: string): string {
const m = match[2] ? `${match[2]}m` : "";
return `${h}${m}`.trim() || uptime;
}
/**
* User-facing description for the local daemon's current state. Replaces the
* raw state label ("Running" / "Stopped") with a sentence that answers
* "what does this mean for me?" — i.e. whether tasks can run on this device.
*
* `runtimeCount` is the number of runtimes the local daemon has registered
* (claude / codex / gemini / ... — one per detected CLI). It's only consulted
* when state === "running".
*/
export function daemonStateDescription(state: DaemonState, runtimeCount: number): string {
switch (state) {
case "running":
if (runtimeCount === 0) {
return "Running, but no runtimes have registered yet.";
}
if (runtimeCount === 1) {
return "Running here · 1 runtime available for tasks.";
}
return `Running here · ${runtimeCount} runtimes available for tasks.`;
case "stopped":
return "Not running · this device can't take new tasks.";
case "starting":
return "Starting up the local daemon…";
case "stopping":
return "Shutting down the local daemon…";
case "installing_cli":
return "Setting up the runtime for the first time. Only happens once.";
case "cli_not_found":
return "Setup failed · couldn't download the runtime. Check your network.";
}
}

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_RUNTIME_CONFIG,
deriveWsUrl,
parseRuntimeConfig,
runtimeConfigFromDevEnv,
} from "./runtime-config";
describe("runtime config", () => {
it("uses cloud defaults without a desktop.json file", () => {
expect(DEFAULT_RUNTIME_CONFIG).toEqual({
schemaVersion: 1,
apiUrl: "https://api.multica.ai",
wsUrl: "wss://api.multica.ai/ws",
appUrl: "https://multica.ai",
});
});
it("derives https/wss compatible URLs from apiUrl", () => {
expect(
parseRuntimeConfig(
JSON.stringify({
schemaVersion: 1,
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
}),
),
).toEqual({
schemaVersion: 1,
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
wsUrl: "wss://congvc-x99.taila6fa8a.ts.net:18443/ws",
appUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
});
});
it("derives ws for http api URLs", () => {
expect(deriveWsUrl("http://localhost:8080")).toBe("ws://localhost:8080/ws");
});
it("accepts explicit appUrl and wsUrl", () => {
expect(
parseRuntimeConfig(
JSON.stringify({
schemaVersion: 1,
apiUrl: "https://api.example.com/",
wsUrl: "wss://ws.example.com/socket/",
appUrl: "https://app.example.com/",
}),
),
).toEqual({
schemaVersion: 1,
apiUrl: "https://api.example.com",
wsUrl: "wss://ws.example.com/socket",
appUrl: "https://app.example.com",
});
});
it("rejects invalid JSON", () => {
expect(() => parseRuntimeConfig("{")).toThrow(/Invalid desktop runtime config JSON/);
});
it("rejects unsupported schema versions", () => {
expect(() =>
parseRuntimeConfig(JSON.stringify({ schemaVersion: 2, apiUrl: "https://api.example.com" })),
).toThrow(/schemaVersion/);
});
it("rejects non-http api schemes", () => {
expect(() =>
parseRuntimeConfig(JSON.stringify({ schemaVersion: 1, apiUrl: "file:///tmp/multica" })),
).toThrow(/apiUrl must use http or https/);
});
it("rejects non-ws websocket schemes", () => {
expect(() =>
parseRuntimeConfig(
JSON.stringify({
schemaVersion: 1,
apiUrl: "https://api.example.com",
wsUrl: "https://api.example.com/ws",
}),
),
).toThrow(/wsUrl must use ws or wss/);
});
it("preserves electron-vite dev env precedence", () => {
expect(
runtimeConfigFromDevEnv({
apiUrl: "http://dev-api.example.test:8080/",
wsUrl: "ws://dev-api.example.test:8080/ws/",
appUrl: "http://dev-app.example.test:3000/",
}),
).toEqual({
schemaVersion: 1,
apiUrl: "http://dev-api.example.test:8080",
wsUrl: "ws://dev-api.example.test:8080/ws",
appUrl: "http://dev-app.example.test:3000",
});
});
});

View File

@@ -0,0 +1,157 @@
export interface RuntimeConfig {
schemaVersion: 1;
apiUrl: string;
wsUrl: string;
appUrl: string;
}
export interface RuntimeConfigError {
message: string;
}
export type RuntimeConfigResult =
| { ok: true; config: RuntimeConfig }
| { ok: false; error: RuntimeConfigError };
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
schemaVersion: 1,
apiUrl: "https://api.multica.ai",
wsUrl: "wss://api.multica.ai/ws",
appUrl: "https://multica.ai",
});
const LOCAL_DEV_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
schemaVersion: 1,
apiUrl: "http://localhost:8080",
wsUrl: "ws://localhost:8080/ws",
appUrl: "http://localhost:3000",
});
export interface RuntimeConfigEnv {
apiUrl?: string;
wsUrl?: string;
appUrl?: string;
}
export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
const apiUrl = normalizeHttpUrl(
env.apiUrl || LOCAL_DEV_RUNTIME_CONFIG.apiUrl,
"VITE_API_URL",
);
return {
schemaVersion: 1,
apiUrl,
wsUrl: env.wsUrl
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
: deriveWsUrl(apiUrl),
appUrl: normalizeHttpUrl(
env.appUrl || LOCAL_DEV_RUNTIME_CONFIG.appUrl,
"VITE_APP_URL",
),
};
}
export function parseRuntimeConfig(raw: string): RuntimeConfig {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(
`Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"}`,
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid desktop runtime config: expected a JSON object");
}
const obj = parsed as Record<string, unknown>;
if (obj.schemaVersion !== 1) {
throw new Error("Unsupported desktop runtime config schemaVersion: expected 1");
}
const apiUrl = requiredString(obj.apiUrl, "apiUrl");
const appUrl = optionalString(obj.appUrl, "appUrl");
const wsUrl = optionalString(obj.wsUrl, "wsUrl");
const normalizedApiUrl = normalizeHttpUrl(apiUrl, "apiUrl");
return {
schemaVersion: 1,
apiUrl: normalizedApiUrl,
wsUrl: wsUrl ? normalizeWsUrl(wsUrl, "wsUrl") : deriveWsUrl(normalizedApiUrl),
appUrl: appUrl ? normalizeHttpUrl(appUrl, "appUrl") : deriveAppUrl(normalizedApiUrl),
};
}
export function deriveWsUrl(apiUrl: string): string {
const url = new URL(apiUrl);
if (url.protocol === "https:") url.protocol = "wss:";
else if (url.protocol === "http:") url.protocol = "ws:";
else throw new Error("apiUrl must use http or https");
url.pathname = joinPath(url.pathname, "/ws");
url.search = "";
url.hash = "";
return trimTrailingSlash(url.toString());
}
export function deriveAppUrl(apiUrl: string): string {
const url = new URL(apiUrl);
url.pathname = "";
url.search = "";
url.hash = "";
return trimTrailingSlash(url.toString());
}
function requiredString(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string`);
}
return value;
}
function optionalString(value: unknown, field: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string when set`);
}
return value;
}
function normalizeHttpUrl(value: string, field: string): string {
let url: URL;
try {
url = new URL(value.trim());
} catch {
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Invalid desktop runtime config: ${field} must use http or https`);
}
url.search = "";
url.hash = "";
return trimTrailingSlash(url.toString());
}
function normalizeWsUrl(value: string, field: string): string {
let url: URL;
try {
url = new URL(value.trim());
} catch {
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
}
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
throw new Error(`Invalid desktop runtime config: ${field} must use ws or wss`);
}
url.search = "";
url.hash = "";
return trimTrailingSlash(url.toString());
}
function joinPath(base: string, suffix: string): string {
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
return `${normalizedBase}${suffix}`;
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}

View File

@@ -1 +1,38 @@
import "@testing-library/jest-dom/vitest";
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
key: (index: number) => Array.from(values.keys())[index] ?? null,
removeItem: (key: string) => {
values.delete(key);
},
setItem: (key: string, value: string) => {
values.set(key, value);
},
};
}
const localStorageIsUsable =
typeof globalThis.localStorage?.getItem === "function" &&
typeof globalThis.localStorage?.setItem === "function" &&
typeof globalThis.localStorage?.removeItem === "function" &&
typeof globalThis.localStorage?.clear === "function";
if (!localStorageIsUsable) {
const storage = createMemoryStorage();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(window, "localStorage", {
configurable: true,
value: storage,
});
}

View File

@@ -1,8 +1,14 @@
import { resolve } from "path";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, "src/renderer/src"),
},
},
test: {
globals: true,
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],

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 11 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 six (Hermes, Kimi, Kiro CLI, 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 11 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 支持 11 款,最常用的几款:
| 工具 | 适合 |
|---|---|
| **Claude Code** | Anthropic 官方,功能最完整;**新手首选** |
| **Codex** | OpenAI主流替代 |
| **Cursor** | Cursor 编辑器生态用户 |
| **Copilot** | 用 GitHub 账号权益的团队 |
| **Gemini** | Google 生态用户 |
另外 6 款Hermes、Kimi、Kiro CLI、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) —— 11 款工具的完整能力差别
- [把 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,82 @@
---
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 | ✓ |
| [**Autopilots**](/autopilots) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by autopilot | ✗ |
"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
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
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
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule

View File

@@ -0,0 +1,82 @@
---
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 | 当前对话历史 | 固定中 | ✓ |
| [**Autopilots**](/autopilots) | 定时 / 手动自动化 | 视模式 | 视模式 | autopilot 自定 | ✗ |
"自动重试"指基础设施故障(运行时离线、超时)导致的重试;智能体侧业务错误(比如模型自己报错)不会自动重试。详见 [**执行任务**](/tasks)。
## 在界面里分配
在 issue 详情页点 **Assignee** 选择器会列出工作区里所有成员和未归档的智能体。选一个智能体issue 立刻分给它。
几条规则:
- **工作区智能体**任何成员都能分配;**私人智能体**只有它的 owner 或工作区 admin 能分配
- 只能分配给**有在线运行时**的智能体——没人在跑的智能体picker 会提示不可选
- Issue 状态是 **Backlog** 时,分配**不会立刻触发**智能体——Backlog 是停泊场,切到 Todo / In Progress 才会真正入队
## 用 CLI 分配
等价的命令行操作:
```bash
multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
取消分配:
```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 和智能体一对一聊
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工

View File

@@ -0,0 +1,128 @@
---
title: Sign-in and signup configuration
description: Configure email + verification code sign-in, Google OAuth, signup allowlists, and local test codes.
---
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.
## Fixed local testing codes
<Callout type="warning">
**Do not enable a fixed verification code on a publicly reachable instance.**
The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
```bash
APP_ENV=development
MULTICA_DEV_VERIFICATION_CODE=888888
```
This shortcut is ignored when `APP_ENV=production`.
</Callout>
Production deployments should leave `MULTICA_DEV_VERIFICATION_CODE` empty and set `APP_ENV=production`. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, `APP_ENV` defaults to `production`.
## 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,128 @@
---
title: 登录与注册配置
description: 配 Email 验证码登录、Google OAuth、注册白名单和本地测试验证码。
---
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**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
## 固定本地测试验证码
<Callout type="warning">
**不要在公网可访问实例上启用固定验证码。**
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production
```bash
APP_ENV=development
MULTICA_DEV_VERIFICATION_CODE=888888
```
`APP_ENV=production` 时这个快捷码会被忽略。
</Callout>
生产部署应保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,并设置 `APP_ENV=production`。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,`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**: keep `MULTICA_DEV_VERIFICATION_CODE` empty on public deployments. If you enable a fixed local test code, anyone who can request a code can sign in with that value while `APP_ENV` is non-production. 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">
**自部署运维注意**:公网部署时保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。如果启用固定本地测试验证码,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用该固定值登录。详见 [自部署的认证配置](/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,85 @@
---
title: Autopilots
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
---
import { Callout } from "fumadocs-ui/components/callout";
Autopilots let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths ([assigning](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/chat), where you are the one kicking things off), the core difference with Autopilots is that they are **time-driven**.
## Configure an autopilot
Create a new autopilot on the workspace's **Autopilot** page. You set:
- **Name** — display name
- **Agent** — who the run is dispatched to
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
- **Description / prompt** — the work description the agent receives each run
- **Execution mode** — see below
- **Triggers** — at least one `schedule` (cron + timezone)
## Pick an execution mode
An autopilot has two execution modes. **Start with "create issue" mode.**
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
<Callout type="warning">
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
</Callout>
## Run it on a schedule
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
A few examples:
- `0 9 * * 1-5`, `Asia/Shanghai` — 9 AM Beijing time on weekdays
- `*/30 * * * *`, `UTC` — every 30 minutes
- `0 3 * * *`, `UTC` — every day at 3 AM UTC
The Multica server scans for due triggers every **30 seconds** — **the actual fire time can lag by up to 30 seconds**, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
## Trigger once manually
To avoid waiting for cron while debugging an autopilot, trigger it manually:
- UI: click "Run now" on the autopilot detail page
- CLI:
```bash
multica autopilot trigger <autopilot-id>
```
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
## View run history
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
- Trigger source (`schedule` / `manual`)
- Start time, completion time
- Status (`issue_created` / `running` / `completed` / `failed`)
- The linked issue (create issue mode) or `task` (run-only mode)
- Failure reason (if failed)
## What happens when an autopilot fails
<Callout type="warning">
**Autopilot failures are not auto-retried and do not send inbox notifications.** A failure leaves a `failed` entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the autopilot is periodic, **the next cron fire will trigger a new run**, but the failed work is not automatically re-run.
If an autopilot is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
</Callout>
Why no auto-retry: autopilots are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
## What's not yet available
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
## Next
- [**Assign issues to agents**](/assigning-issues) — a one-shot hand-off of an issue to an agent
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
- [**Chat**](/chat) — one-to-one conversation outside any issue

View File

@@ -0,0 +1,85 @@
---
title: Autopilots
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
---
import { Callout } from "fumadocs-ui/components/callout";
Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron 和时区,到点 Multica 自己派发 [`task`](/tasks),不需要你每次触发。适合定期巡检、周期性报告、凌晨跑的清理任务这类"standing order"场景。和前三种触发方式([分配](/assigning-issues) / [@ 提及](/mentioning-agents) / [对话](/chat) 都是你主动喊一声相比Autopilots 的核心差别是**时间驱动**。
## 配置一个 Autopilot
在工作区的 **Autopilot** 页新建一条 autopilot要定下
- **名字** — 显示名
- **执行智能体** — 到点派给谁
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
- **执行模式** — 见下节
- **触发器** — 至少加一条 `schedule`cron + 时区)
## 选择执行模式
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **直跑模式**`run_only`)—— 不建 issue直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
<Callout type="warning">
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
</Callout>
## 让它按时间跑
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
几个例子:
- `0 9 * * 1-5``Asia/Shanghai` —— 工作日北京时间早上 9 点
- `*/30 * * * *``UTC` —— 每 30 分钟一次
- `0 3 * * *``UTC` —— 每天 UTC 凌晨 3 点
Multica 服务器每 **30 秒**扫一次到期的触发器——**触发时刻最多延迟 30 秒**,不是秒级精准。服务器重启时如果恰好错过触发点,启动时会补扫漏掉的触发(不会丢触发,但会立刻补跑)。
## 手动触发一次
调试 Autopilot 时不想等 cron可以手动触发一次
- UI在 Autopilot 详情页点"手动运行"
- CLI
```bash
multica autopilot trigger <autopilot-id>
```
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
## 看运行历史
每次触发都会产生一条**运行记录**run可以在 Autopilot 详情页的"历史"tab 看到:
- 触发源(`schedule` / `manual`
- 开始时间、完成时间
- 状态(`issue_created` / `running` / `completed` / `failed`
- 关联的 issue先建 issue 模式)或 `task`(直跑模式)
- 失败原因(如果失败)
## Autopilot 失败会怎样
<Callout type="warning">
**Autopilot 失败不自动重试,也不发 inbox 通知。** 失败后只在运行历史里留一条 `failed` 记录——不会像分配 / @ 提及那样由系统重新排队,也不会给任何人发通知。如果这条 Autopilot 是周期任务,**下一次 cron 到点会重新触发一次**(新的 run但这一次失败的工作不会被自动补跑。
如果 Autopilot 很重要,要自己设计监控——例如让智能体在成功时给自己发个评论,通过缺失评论来发现失败。
</Callout>
不自动重试的理由Autopilot 本身是周期性的,系统层再加自动重试容易和下一次调度叠加,产生重复执行。调度权完全交给 cron 最干净。
## 暂不可用的能力
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
## 下一步
- [**分配 issue 给智能体**](/assigning-issues) —— 一次性把 issue 指派给智能体
- [**在评论里 @ 智能体**](/mentioning-agents) —— 评论里让智能体看一眼
- [**对话**](/chat) —— 独立于 issue 的一对一聊天

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
- [**Autopilots**](/autopilots) — 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>
## 下一步
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
- [**分配 issue 给智能体**](/assigning-issues) —— 把话题放回 issue 看板上

View File

@@ -0,0 +1,129 @@
---
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 |
## Autopilots
| Command | Purpose |
|---|---|
| `multica autopilot list` | List every autopilot in the workspace |
| `multica autopilot get <id>` | Show a single autopilot |
| `multica autopilot create ...` | Create an autopilot |
| `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,129 @@
---
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 的文件 |
## Autopilots
| 命令 | 用途 |
|---|---|
| `multica autopilot list` | 列出工作区所有 autopilot |
| `multica autopilot get <id>` | 查看单个 autopilot |
| `multica autopilot create ...` | 创建 autopilot |
| `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

@@ -78,7 +78,7 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`)
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, `kiro`, or `pi`)
3. At least one workspace is being watched
If the agents list is empty, install at least one supported AI agent CLI:
@@ -88,6 +88,8 @@ If the agents list is empty, install at least one supported AI agent CLI:
- OpenCode (`opencode`)
- OpenClaw (`openclaw`)
- Hermes (`hermes`)
- Kimi (`kimi`)
- Kiro CLI (`kiro-cli`)
Then restart the daemon:

View File

@@ -18,10 +18,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token
multica login --token <mul_...>
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
### Check Status
@@ -92,6 +92,10 @@ The daemon auto-detects these AI CLIs on your PATH:
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
| Kimi | `kimi` | Moonshot coding agent |
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
| Pi | `pi` | Inflection coding agent |
| Cursor Agent | `cursor-agent` | Cursor coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -134,6 +138,14 @@ Agent-specific overrides:
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor model used |
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
### Self-Hosted Server
@@ -209,10 +221,11 @@ multica workspace members <workspace-id>
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
### Get Issue
@@ -225,9 +238,10 @@ multica issue get <id> --output json
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
### Update Issue
@@ -239,9 +253,12 @@ multica issue update <id> --title "New title" --priority urgent
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue assign <id> --unassign
```
`--to-id <uuid>`(与 `--to` 互斥)按 UUID 精确分配;适合重名 workspace 下脚本化场景。
### Change Status
```bash

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), [Kiro CLI](/providers#kiro-cli), [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. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
**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 11 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)、[Kiro CLI](/providers#kiro-cli)、[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` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
**接下来守护进程会**
1. 3 秒内领走这条任务(任务状态从 `queued` 变 `dispatched`
2. 调用对应的 AI 编程工具开始执行(状态变 `running`
3. AI 在本地工作——可能会读你的代码目录、执行命令、编辑文件
4. 结束后把结果发回 Multica状态变 `completed` 或 `failed`,根据是否自动重试)
Web 界面会**实时**(通过 WebSocket显示进度——不需要刷新。
## 下一步
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
- [执行任务](/tasks) —— 任务生命周期、重试规则
- [AI 编程工具对照](/providers) —— 11 款工具的能力差异
- [桌面应用](/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 day-to-day 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` (11 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), [Kiro CLI](/providers#kiro-cli), [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; Autopilot-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 11 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 编程工具(内置支持 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[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 会自动重新排队Autopilots 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/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) —— 11 款 AI 编程工具的能力差异对照

View File

@@ -0,0 +1,99 @@
---
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. For the environment it is configured for, it talks to the same backend as the web app and shows the same data. By default Desktop uses Multica Cloud; self-hosted instances can be configured with a local runtime config file. Desktop also 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">
**Desktop defaults to Multica Cloud, but can be pointed at a self-hosted instance with a local config file.** There is still no in-app "connect to self-host" picker. Desktop reads `~/.multica/desktop.json` before the renderer starts; if the file is missing, it uses the Cloud defaults.
Minimal self-host config:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain"
}
```
`apiUrl` is required and must use `http` or `https`. Desktop derives `wsUrl` as `/ws` on the same origin (`wss` for `https`, `ws` for `http`) and derives `appUrl` from the API origin. If your deployment uses different origins, set them explicitly:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain",
"wsUrl": "wss://api.your-domain/ws",
"appUrl": "https://your-domain"
}
```
If `desktop.json` exists but is invalid, Desktop fails closed and shows a blocking config error instead of silently falling back to Cloud. For development builds, `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL` still take precedence during `electron-vite dev`. Runtime Desktop self-host configuration was implemented for [issue #1371](https://github.com/multica-ai/multica/issues/1371).
</Callout>
## Next steps
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend and connecting with the CLI or Desktop runtime config
- [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,99 @@
---
title: 桌面应用
description: Multica Desktop 是什么、和 Web 有什么区别、什么时候值得用。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。对它当前配置的环境来说,它和 Web 版连同一个后端、看到的数据完全一样。Desktop 默认使用 Multica Cloud自部署实例可以通过本地运行时配置文件接入。它还给了几个 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">
**Desktop 默认连接 Multica Cloud但可以通过本地配置文件指向自部署实例。** 应用内仍然没有“连接自部署”的切换入口。Desktop 会在 renderer 启动前读取 `~/.multica/desktop.json`;如果这个文件不存在,就使用 Cloud 默认值。
最小自部署配置:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain"
}
```
`apiUrl` 是必填项,必须使用 `http` 或 `https`。Desktop 会自动从它推导 `wsUrl`(同源 `/ws``https` 对应 `wss``http` 对应 `ws`)和 `appUrl`API 的同源地址)。如果你的部署使用不同域名,可以显式设置:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain",
"wsUrl": "wss://api.your-domain/ws",
"appUrl": "https://your-domain"
}
```
如果 `desktop.json` 存在但内容无效Desktop 会 fail closed显示阻塞式配置错误而不是悄悄回退到 Cloud。开发构建里`electron-vite dev` 仍然优先使用 `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`。Desktop 运行时自部署配置能力对应 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
</Callout>
## 下一步
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端,并通过 CLI 或 Desktop 运行时配置连接
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制Desktop 自动起它,但行为一样)

View File

@@ -0,0 +1,292 @@
---
title: Conventions
description: Single source of truth for code naming, i18n translation glossary, and Chinese voice guide.
---
This page is the single source of truth for code naming, the i18n translation glossary, and the Chinese voice guide. Anything that used to live in `packages/views/locales/glossary.md` or in scattered comments now lives here.
If you write Multica code, change a translation, or write Chinese product copy, this is the page to reference.
---
## 1. Code naming
### Routes
Pre-workspace routes (the routes that exist before the user is in a workspace) MUST use either a single word or the `/{noun}/{verb}` pattern.
- ✅ `/login`, `/inbox`, `/workspaces/new`
- ❌ `/new-workspace`, `/create-team`, `/accept-invite`
Hyphenated word groups at the root collide with user-chosen workspace slugs and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
### Workspace-scoped routes
Always live under `/{slug}/{section}` — `/{slug}/issues`, `/{slug}/agents`, `/{slug}/settings`. Never duplicate workspace routing logic; use `useNavigation().push()` from shared code, never framework-specific link APIs.
### Packages and modules
The monorepo enforces strict package boundaries:
| Package | May depend on | Must NOT depend on |
| --- | --- | --- |
| `packages/core` | nothing app-specific | `react-dom`, `localStorage`, `process.env`, `next/*`, UI libraries |
| `packages/ui` | nothing | `@multica/core`, business logic |
| `packages/views` | `core/`, `ui/` | `next/*`, `react-router-dom`, stores |
| `apps/web/platform/` | `next/*` | other apps |
| `apps/desktop/.../platform/` | `react-router-dom`, electron | other apps |
If logic appears in both apps, it MUST be extracted to a shared package. There are no exceptions for "small" duplication.
### Files and components
- Files: `kebab-case.tsx` / `kebab-case.ts` (e.g. `agent-row-actions.tsx`)
- Components: `PascalCase` (e.g. `AgentRowActions`)
- Hooks: `useCamelCase` (e.g. `useWorkspaceId`)
- Tests: colocated as `<file>.test.ts(x)`
- Stores (Zustand): `<feature>-store.ts`, exported as `use<Feature>Store`
### Database (Go + sqlc)
- Tables: `snake_case` singular (`user`, `workspace`, `agent_runtime`)
- Columns: `snake_case` (`workspace_id`, `created_at`, `last_seen_at`)
- Foreign keys: `<table>_id`
- Booleans: `is_<state>` or `<state>_at` (timestamp form preferred for state changes)
- Migration files: `NNN_descriptive_name.up.sql` + `.down.sql` — always provide both directions
### Go
- Standard `gofmt` + `go vet`. No exceptions.
- Handler files mirror domain: `agent.go`, `auth.go`, `runtime.go`
- Tests: `<file>_test.go` colocated
- For UUID parsing in handlers, follow the rule in the root `CLAUDE.md` — `parseUUIDOrBadRequest` for boundary input, `parseUUID` (panicking) for trusted round-trips, never `util.ParseUUID` directly without checking the error.
### TypeScript
- API responses on the wire are `snake_case`; the api client converts to `camelCase` at the boundary. Inside TS code, **always camelCase**.
- Types: `PascalCase` (`Issue`, `AgentRuntime`); never `IPrefix`, never `_t` suffix.
- Enums: prefer string literal unions; reserve `enum` for runtime-iterable cases.
- TanStack Query keys: factory functions in `<feature>/queries.ts`, e.g. `issueKeys.detail(id)`.
### Issue keys
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
### Comments in code
English only. The repo enforces this for both Go and TypeScript. If you find a Chinese comment in code, it's a bug — replace it.
### Commit messages
Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`. Atomic commits grouped by intent.
---
## 2. i18n translation glossary
This is the **mandatory** glossary for every translation PR. It used to live at `packages/views/locales/glossary.md`; that file is now a stub pointing here.
### The core distinction: entity vs concept
Multica's product nouns split into two categories:
- **Entity** — has a URL, a database row, an API type. In Chinese text, render as **lowercase English** so it visually reads like a type name and signals "this is a Multica system entity".
- **Concept** — generic noun, not a database entity. **Translate fully** so Chinese users don't see jagged English embedded in flowing text.
This rule is aligned with `apps/docs/content/docs/*.zh.mdx` — the docs are the de facto Chinese voice standard and have been battle-tested across 20+ pages.
### Don't translate — entities (lowercase English)
| Term | Render in Chinese | Example |
| --- | --- | --- |
| Issue | `issue` (lowercase) | "把 issue 分配给智能体"、"创建子 issue" |
| Skill | `skill` (lowercase) | "为智能体注入 skill" |
| Task | `task` (lowercase) | "排队中的 task" |
**Why `issue` / `skill` / `task` stay English while `project` / `autopilot` are translated**:
- **`issue` / `task`**: dev teams talk in English. The Chinese candidates ("任务" — too vague, almost synonymous with "工作"; "工单" — IT ticket connotation; "议题" — GitHub-style but doesn't match the product feel) all read worse than `issue`.
- **`skill`**: Multica-specific concept with no established Chinese term.
- **`project` → "项目"**: settled mainstream Chinese word. Feishu / Tower / Teambition / PingCode / GitHub Projects — every Chinese product translates it. No product keeps `project` in Chinese context.
- **`autopilot` → "自动化"**: in Chinese, "autopilot" associates with Tesla's "自动驾驶" and doesn't match what the feature does (run tasks on a schedule). Notion and Feishu both use "自动化"; that's the industry consensus.
### Don't translate — brands and acronyms
| Category | Terms |
| --- | --- |
| Brands | **Multica**, GitHub, Slack, Google, Anthropic, OpenAI, Claude, Codex, Cursor, Linear, Jira |
| Acronyms | API, CLI, URL, SDK, OAuth, JWT, SSO, WebSocket, HTTP, JSON, YAML, SQL |
### Translate fully — concepts
| English | Chinese |
| --- | --- |
| Workspace | **工作区** |
| Agent | **智能体** |
| Project | **项目** |
| Autopilot | **自动化** |
| Daemon | **守护进程** |
| Runtime | **运行时** |
| Inbox | **收件箱** |
| Comment | **评论** |
| Reply | **回复** |
| Notifications | **通知** |
| Member | **成员** |
| Label | **标签** |
| Settings | **设置** |
| Onboarding | **上手引导** |
### Translate fully — generic UI words
| English | Chinese |
| --- | --- |
| Invite / Invitation | 邀请 |
| Search | 搜索 |
| Email | 邮箱 (label) / 邮件 (action) |
| Password | 密码 |
| Sign in / Log in | 登录 |
| Sign up | 注册 |
| Sign out / Log out | 退出登录 |
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
| Active / Archived | 活跃 (or 启用) / 已归档 |
| Status / Priority | 状态 / 优先级 |
| Assignee / Reporter | 负责人 / 报告人 |
| Description / Title | 描述 / 标题 |
| Date / Time | 日期 / 时间 |
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
| Empty / Failed / Success | 空 / 失败 / 成功 |
| Error / Warning | 错误 / 警告 |
### Roles and status enums (lowercase English, not translated)
These are schema-level identifiers; render as lowercase English even in Chinese context.
- Roles: `owner` / `admin` / `member`
- Issue status: `backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
In UI, surface them in English (optionally `code-style` wrapped):
- "你需要 owner 权限"
- "已切换到 in_progress"
### Word combination rules
Always put **a single space** between an English word (entity / brand / acronym) and surrounding Chinese:
- "Create new issue" → "新建 issue"
- "Assign to agent" → "分配给智能体"
- "Configure runtime" → "配置运行时"
- "Stop daemon" → "停止守护进程"
### Plurals and counts
i18next uses `_one` / `_other`; Chinese has no grammatical number, only fill `_other`.
```json
// en/issues.json
{
"issue_count_one": "{{count}} issue",
"issue_count_other": "{{count}} issues"
}
// zh-Hans/issues.json
{
"issue_count_other": "{{count}} 个 issue"
}
```
Common count formats:
- `{{count}} issues` → `{{count}} 个 issue`
- `{{count}} agents` → `{{count}} 个智能体`
- `{{count}} workspaces` → `{{count}} 个工作区`
- `{{count}} comments` → `{{count}} 条评论`
- `{{count}} members` → `{{count}} 位成员`
- `{{count}} skills` → `{{count}} 个 skill`
### Interpolation
Use `{{var}}`. Chinese translations may reorder for natural sentence flow.
```json
// en
{ "welcome_message": "Welcome back, {{name}}!" }
// zh-Hans
{ "welcome_message": "欢迎回来,{{name}}" }
```
### Translation key naming
Three-level nesting: `feature.component.action`.
```json
{
"feature_or_component": {
"subcomponent_or_section": {
"action_or_label": "..."
}
}
}
```
Examples:
- `issues.toolbar.batch_update_success`
- `issues.detail.comment_form.placeholder`
- `inbox.empty.title`
- `settings.appearance.language.title`
### Web-only / desktop-only copy
- Shared copy: top level of the namespace JSON
- Web-only: `web` section
- Desktop-only: `desktop` section
See `auth.json` for the canonical example (the `web` section contains `prefer_desktop` / `desktop_handoff.*`).
---
## 3. Chinese voice and style
### Punctuation
- Full-width punctuation in Chinese: `,。:;!?`
- Quotes: straight double quotes `"..."` to match the English source. Do not use `「」` or curly quotes.
- Ellipsis: three dots `...` not the single character `…`. Match the English source.
- Mixed Chinese-English: a single space on each side of the English word (see Word combination rules).
### Style principles
- **Concise and direct.** Avoid translation-ese: "对于 X 来说"、"作为 X"、"我们的"。
- **Error messages**: gentle but clear. "无法保存修改" beats "保存修改失败了!".
- **Buttons**: verb first, 24 characters. "取消"、"保存修改"、"立即同步".
- **Tooltips**: full short sentence. "复制链接到剪贴板".
- **Placeholders**: example-style. "输入 issue 标题...".
### Where to look when in doubt
When the glossary doesn't cover a term, look at:
1. `apps/docs/content/docs/*.zh.mdx` — the de facto Chinese voice standard, 20+ pages of consistent translation
2. `packages/views/locales/zh-Hans/auth.json` and `editor.json` — JSON structure + selector API patterns
3. `packages/views/auth/login-page.tsx` — component-level selector API call site
4. `packages/views/settings/components/appearance-tab.tsx` — language switcher reference
---
## Updating this page
If you change a rule here, also:
1. Apply it in the relevant locale JSONs / CLAUDE.md / docs page
2. Note the change in the PR description so reviewers know to look for downstream sweep
This page is the contract; nothing else overrides it.

View File

@@ -0,0 +1,292 @@
---
title: 规范
description: 代码命名规范、i18n 翻译术语表、中文风格指南的唯一权威来源。
---
本页是代码命名规范、i18n 翻译术语表、中文风格指南的唯一权威来源。原本散落在 `packages/views/locales/glossary.md` 和各处注释里的规则现在都收拢到这里。
写 Multica 代码、改翻译、写中文产品文案,都从这一页查。
---
## 1. 代码命名
### 路由
工作区前置路由(用户进入工作区之前能访问的路由)必须用单个单词,或者 `/{noun}/{verb}` 格式。
- ✅ `/login`、`/inbox`、`/workspaces/new`
- ❌ `/new-workspace`、`/create-team`、`/accept-invite`
根目录的连字符词组会跟用户自选 workspace slug 冲突,逼着团队不停审保留字列表。把名词(`workspaces`)保留下来,整个 `/workspaces/*` 子树自动受保护。
### 工作区路由
永远用 `/{slug}/{section}` —— `/{slug}/issues`、`/{slug}/agents`、`/{slug}/settings`。共享代码不要复制路由逻辑,统一走 `useNavigation().push()`,不要直接用框架的 link API。
### 包与模块
monorepo 的包边界是硬约束:
| 包 | 可依赖 | 不能依赖 |
| --- | --- | --- |
| `packages/core` | 仅平台无关基础库 | `react-dom`、`localStorage`、`process.env`、`next/*`、UI 库 |
| `packages/ui` | 无业务依赖 | `@multica/core`、业务逻辑 |
| `packages/views` | `core/`、`ui/` | `next/*`、`react-router-dom`、stores |
| `apps/web/platform/` | `next/*` | 其他 app |
| `apps/desktop/.../platform/` | `react-router-dom`、electron | 其他 app |
两个 app 都有的逻辑,**必须**抽到共享包。"小段重复"也不算例外。
### 文件与组件
- 文件名:`kebab-case.tsx` / `kebab-case.ts`(如 `agent-row-actions.tsx`
- 组件:`PascalCase`(如 `AgentRowActions`
- Hook`useCamelCase`(如 `useWorkspaceId`
- 测试:与源文件同目录,命名 `<file>.test.ts(x)`
- Zustand store`<feature>-store.ts`,导出名 `use<Feature>Store`
### 数据库Go + sqlc
- 表名:`snake_case` 单数(`user`、`workspace`、`agent_runtime`
- 字段:`snake_case``workspace_id`、`created_at`、`last_seen_at`
- 外键:`<table>_id`
- 布尔:`is_<state>` 或者 `<state>_at`(状态变化优先用时间戳形式)
- 迁移文件:`NNN_descriptive_name.up.sql` + `.down.sql`**永远写双向**
### Go
- 标准 `gofmt` + `go vet`,无例外
- Handler 文件按域命名:`agent.go`、`auth.go`、`runtime.go`
- 测试:`<file>_test.go` 同目录
- handler 里 UUID 解析遵守根 `CLAUDE.md` 的规则:边界输入用 `parseUUIDOrBadRequest`,可信回环用 `parseUUID`panic 版),永远不要直接用 `util.ParseUUID` 不查 error
### TypeScript
- 网络上 API 响应是 `snake_case`api client 在边界处转成 `camelCase`。**TS 代码内部一律 camelCase**
- 类型:`PascalCase``Issue`、`AgentRuntime`),不加 `IPrefix`,不加 `_t` 后缀
- 枚举:优先用 string literal union需要 runtime 迭代时才用 `enum`
- TanStack Query key用 `<feature>/queries.ts` 里的工厂函数,例如 `issueKeys.detail(id)`
### Issue 编号
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
### 代码注释
**只允许英文**。Go 和 TypeScript 都强制。如果在代码里看到中文注释,那就是 bug替换掉。
### Commit message
Conventional 格式:`feat(scope)`、`fix(scope)`、`refactor(scope)`、`docs`、`test(scope)`、`chore(scope)`。按意图原子化分组。
---
## 2. i18n 翻译术语表
这是每个翻译 PR 都必须遵守的术语表。原本在 `packages/views/locales/glossary.md`,那个文件现在是个 stub指向这一页。
### 核心区分:实体 vs 概念
Multica 的产品名词分两类:
- **实体typed entity** —— 有 URL、有数据库 row、是 API 响应里某种 type 的东西。中文里**用小写英文**呈现,视觉上像类型名,告诉读者"这是 Multica 系统里的特定实体"。
- **概念concept** —— 不是数据库实体的普通名词。**完整翻译成中文**CN 用户看不到生硬的英文。
这套规则与 `apps/docs/content/docs/*.zh.mdx` 完全对齐 —— docs 是已经实战 20+ 篇的 CN voice 标准。
### 不翻 —— 实体(小写英文)
| 词 | 中文中的写法 | 例 |
| --- | --- | --- |
| Issue | `issue`(小写) | "把 issue 分配给智能体"、"创建子 issue" |
| Skill | `skill`(小写) | "为智能体注入 skill" |
| Task | `task`(小写) | "排队中的 task" |
**为什么 `issue` / `skill` / `task` 不翻而 `project` / `autopilot` 翻**
- **`issue` / `task`**dev 团队习惯说英文,"任务"在中文里和"工作"几乎同义太空泛,"工单"是 IT 工单语义,"议题"是 GitHub 风格但用户场景不匹配。三个候选都不如 `issue` 准确。
- **`skill`**Multica 特有概念,没有公认中文译法。
- **`project` 翻成"项目"**:这是中文里早就稳定的日常词。飞书 / Tower / Teambition / PingCode / GitHub Projects 中文版 0 例外都翻译成"项目",没有产品保留 `project`。
- **`autopilot` 翻成"自动化"**autopilot 在中文里联想到特斯拉的"自动驾驶",跟产品功能(按周期跑 task对应不上。Notion / 飞书都用"自动化",是行业共识。
### 完整翻译 —— 概念词
| 英 | 中 |
| --- | --- |
| Workspace | **工作区** |
| Agent | **智能体** |
| Project | **项目** |
| Autopilot | **自动化** |
| Daemon | **守护进程** |
| Runtime | **运行时** |
| Inbox | **收件箱** |
| Comment | **评论** |
| Reply | **回复** |
| Notifications | **通知** |
| Member | **成员** |
| Label | **标签** |
| Settings | **设置** |
| Onboarding | **上手引导** |
### 不翻 —— 品牌名 + 通用缩写
| 类别 | 词 |
| --- | --- |
| 品牌 | **Multica**、GitHub、Slack、Google、Anthropic、OpenAI、Claude、Codex、Cursor、Linear、Jira |
| 缩写 | API、CLI、URL、SDK、OAuth、JWT、SSO、WebSocket、HTTP、JSON、YAML、SQL |
### 完整翻译 —— 通用 UI 词
| 英 | 中 |
| --- | --- |
| Invite / Invitation | 邀请 |
| Search | 搜索 |
| Email | 邮箱label/ 邮件action |
| Password | 密码 |
| Sign in / Log in | 登录 |
| Sign up | 注册 |
| Sign out / Log out | 退出登录 |
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
| Active / Archived | 活跃(或 启用)/ 已归档 |
| Status / Priority | 状态 / 优先级 |
| Assignee / Reporter | 负责人 / 报告人 |
| Description / Title | 描述 / 标题 |
| Date / Time | 日期 / 时间 |
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
| Empty / Failed / Success | 空 / 失败 / 成功 |
| Error / Warning | 错误 / 警告 |
### 角色名 + 状态名(小写英文,不翻)
这些是 schema-level 标识符,中文环境也保持小写英文:
- 角色:`owner` / `admin` / `member`
- Issue 状态:`backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
UI 里展示这些值时保持英文(必要时用 code-style 包起来):
- "你需要 owner 权限"
- "已切换到 in_progress"
### 词组组合规则
英文词(实体名 + 品牌名 + 缩写)与中文之间**加单空格**
- "Create new issue" → "新建 issue"
- "Assign to agent" → "分配给智能体"
- "Configure runtime" → "配置运行时"
- "Stop daemon" → "停止守护进程"
### 复数与计数
i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`。
```json
// en/issues.json
{
"issue_count_one": "{{count}} issue",
"issue_count_other": "{{count}} issues"
}
// zh-Hans/issues.json
{
"issue_count_other": "{{count}} 个 issue"
}
```
常见计数格式:
- `{{count}} issues` → `{{count}} 个 issue`
- `{{count}} agents` → `{{count}} 个智能体`
- `{{count}} workspaces` → `{{count}} 个工作区`
- `{{count}} comments` → `{{count}} 条评论`
- `{{count}} members` → `{{count}} 位成员`
- `{{count}} skills` → `{{count}} 个 skill`
### 插值
用 `{{var}}` 形式。中文翻译可以调整位置以符合中文语序。
```json
// en
{ "welcome_message": "Welcome back, {{name}}!" }
// zh-Hans
{ "welcome_message": "欢迎回来,{{name}}" }
```
### Key 命名约定
3 层嵌套:`feature.component.action`。
```json
{
"feature_or_component": {
"subcomponent_or_section": {
"action_or_label": "..."
}
}
}
```
实例:
- `issues.toolbar.batch_update_success`
- `issues.detail.comment_form.placeholder`
- `inbox.empty.title`
- `settings.appearance.language.title`
### Web-only / Desktop-only 文案位置
- 共享文案:放 namespace JSON 顶层
- Web-only放 `web` 段
- Desktop-only放 `desktop` 段
参考 `auth.json``web` 段含 `prefer_desktop` / `desktop_handoff.*`)。
---
## 3. 中文风格
### 标点
- 中文用全角标点:`,。:;!?`
- 引号:用 `"..."`(直引号),与英文 source 保持一致。**不要**用 `「」` 或弯引号
- 省略号:用 `...`(三点)而非 `…`(单字符),与英文 source 保持一致
- 中英混排:英文词左右各加 1 个空格(详见词组组合规则)
### 风格原则
- **简洁直白**:避免翻译腔,"对于 X 来说"、"作为 X"、"我们的"
- **错误信息**:温和但明确,"无法保存修改" 优于 "保存修改失败了!"
- **按钮**动词开头2-4 字最佳。"取消"、"保存修改"、"立即同步"
- **Tooltip**:完整短句。"复制链接到剪贴板"
- **placeholder**:示例性提示。"输入 issue 标题..."
### 拿不准的时候去哪查
术语表没覆盖的词,按这个顺序查:
1. `apps/docs/content/docs/*.zh.mdx` —— CN voice 事实标准20+ 篇高度一致
2. `packages/views/locales/zh-Hans/auth.json` 和 `editor.json` —— JSON 结构 + selector API 用法参考
3. `packages/views/auth/login-page.tsx` —— 组件层 selector API 调用参考
4. `packages/views/settings/components/appearance-tab.tsx` —— 语言切换器参考
---
## 修改这一页时
改本页规则的同时还要:
1. 把规则在相关 locale JSON / CLAUDE.md / docs 页面里同步落地
2. PR 描述里写明改了什么,方便 reviewer 检查下游是否跟着改了
本页是契约,其他文档不能 override。

View File

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

View File

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

View File

@@ -0,0 +1,158 @@
---
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).
## Core server variables
These are the core variables you must think about before deploying — some have defaults that let the server start, but in production you should set the required ones 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`) |
| `FRONTEND_ORIGIN` | empty | **Yes** (self-host must set its own domain) |
| `MULTICA_DEV_VERIFICATION_CODE` | empty | No (must stay empty in production) |
<Callout type="warning">
**Keep `MULTICA_DEV_VERIFICATION_CODE` empty in production.** A fixed local test code is disabled by default, but if you opt in with `MULTICA_DEV_VERIFICATION_CODE=888888`, anyone who can request a code can sign in with that fixed value while `APP_ENV` is non-production. The shortcut is ignored when `APP_ENV=production`.
</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 | **Bucket name only** (for example `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public host from `S3_BUCKET` + `S3_REGION`. Setting this enables S3 storage |
| `S3_REGION` | `us-west-2` | AWS region. Must match the bucket's actual region — it is used both for SDK signing and for building the public URL |
| `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.
**Public URLs** are constructed in this order of priority:
1. `https://<CLOUDFRONT_DOMAIN>/<key>` if `CLOUDFRONT_DOMAIN` is set.
2. `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style) if `AWS_ENDPOINT_URL` is set.
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). When `S3_BUCKET` contains dots, the server falls back to `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style) because the AWS-issued wildcard TLS certificate does not validate dotted bucket hosts.
### 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

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