Compare commits

...

73 Commits

Author SHA1 Message Date
Naiyuan Qing
cc286d85f6 fix(issues): make comment highlight background-only 2026-07-01 15:59:03 +08:00
Bohan Jiang
bbd900635e fix(slack): allow slack_chat issue origin so /issue can create issues (MUL-3908) (#4785)
Follow-up to #4780 (the /issue slash command), which merged the code + docs. The DB migration was pushed to that branch after it had already been squash-merged, so it never landed on main — the /issue command still fails at issue creation.

The issue.origin_type CHECK constraint (migration 111) only allowed autopilot / quick_create / lark_chat. Slack stamps origin_type='slack_chat' on every /issue create, so the INSERT trips SQLSTATE 23514 and IssueService.Create fails ("Something went wrong creating the issue"). This also silently broke the pre-existing message-based Slack /issue. Extends the constraint to include 'slack_chat', mirroring 111 (lark_chat). Numbered 131 because 129/130 were taken on main since #4780 branched.

Verified against a real Postgres: full chain up to 131 applies cleanly and an issue insert with origin_type='slack_chat' passes the CHECK after 131 (fails with the pre-131 constraint).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 15:20:22 +08:00
bhirstmedia
170750242b BHI-12314: add Claude Sonnet 5 catalog and pricing support (MUL-3910) (#4783)
Co-authored-by: Ember <ember@Embers-iMac.localdomain>
2026-07-01 14:59:00 +08:00
Bohan Jiang
240ec4efd0 feat(slack): native /issue slash command over Socket Mode (MUL-3908) (#4780)
* feat(slack): native /issue slash command over Socket Mode (MUL-3908)

A message beginning with `/issue` is intercepted by the Slack client as a slash command and never delivered to the app, so the message-prefix /issue never worked on Slack (no event, no 👀, no issue).

Register /issue as a real slash command in the app manifest and handle EventTypeSlashCommand over the existing per-installation Socket Mode connection. It is a one-shot issue creation (no chat session / agent run) that reuses the shared IssueService and the same installation-routing + identity/membership checks as the message path, replying privately via the command's response_url (ephemeral) since a slash command has no message to react to.

Docs: register the command in the manifest and describe the slash-command behavior across all four locales.
Co-authored-by: multica-agent <github@multica.ai>

* docs(slack): add commands scope to /issue manifest; fix chat-run wording (MUL-3908)

Review follow-up: the manifest examples registered features.slash_commands but omitted the commands bot scope, so updating + reinstalling could still fail to grant the /issue command. Add - commands to oauth_config.scopes.bot in all four locales and document it in the permissions table.

Also correct the misleading "no agent run" wording in the slash-command header and router comment: a todo issue assigned to the agent still triggers it via maybeEnqueueOnAssign (issue-assignment), like the message /issue — the slash command only skips the chat session / chat run.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 14:47:44 +08:00
Naiyuan Qing
fc88c7720f test(onboarding): cover official source reporting controls (#4782) 2026-07-01 14:35:57 +08:00
Naiyuan Qing
ad1afdd48d fix(self-host): restore official source report endpoint (#4781) 2026-07-01 14:11:34 +08:00
Naiyuan Qing
26142d74aa feat(self-host): collect anonymous source channels mul-3878 (#4741)
* feat(self-host): collect anonymous source channels

* feat(self-host): include other source text

* feat(self-host): report source channel domains

* feat(self-host): add source domain reporting control

* fix(self-host): simplify source reporting copy

* fix(self-host): simplify source channel reporting gate

* fix(self-host): limit source reporting triggers

* chore(self-host): point source reporting at staging
2026-07-01 13:31:25 +08:00
Bohan Jiang
dbf11f0958 fix(attachments): relax frame-ancestors on local /uploads static route (MUL-3821) (#4777)
Self-hosted local-disk deployments serve document previews straight from
the public /uploads/* static route. That route inherited the global
`frame-ancestors 'none'` CSP from the middleware, so iframe-based previews
(PDF/HTML) were blocked by the browser — only the /api/attachments/*
download endpoint had been exempted (#4635 / #4679).

Serve /uploads/* through a new Handler.ServeLocalUpload that applies the
same preview security headers as the download endpoint
(setAttachmentPreviewSecurityHeaders), so the relaxed, config-aware
`frame-ancestors 'self' <configured origins>` policy applies to both
same-origin and split frontend/backend origin setups. Inline <img>
rendering is unaffected (frame-ancestors does not gate images); cloud
storage (S3/CloudFront) never hits this route.

Adds regression tests covering the relaxed CSP on /uploads and the
non-local-storage 404 guard.

Refs #4477

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 13:28:44 +08:00
Bohan Jiang
09bb3483f9 docs: document Trae CLI runtime and align provider tool counts (MUL-3860) (#4778)
Follow-up to #4724, which added the Trae CLI (traecli) ACP backend but
left the surrounding docs behind.

- install-agent-runtime: add a Trae CLI section (install, ACP transport,
  enterprise login, inline runtime brief, MULTICA_TRAECLI_MODEL)
- providers: fix the MCP paragraph — Trae also receives ACP mcpServers
- daemon-runtimes: add Qoder + Trae CLI to the built-in detection list
- README: add Trae CLI to the architecture diagram and runtime row
- bump stale English tool counts (12/13 -> 14) across cross-references;
  the '12' lists were already missing Qoder before this change

Scope: English docs only. The ja/zh localizations are separately behind
(they predate Qoder too) and need their own translation-sync pass.

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 13:27:00 +08:00
Multica Eve
f0c3b0911c MUL-3828: fix Cursor and Kiro runtime completion transcripts (#4738)
* fix: preserve cursor and kiro completion transcripts

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

* fix(kiro): tolerate env-prefix and sh -c wrapper in comment-add detection

Harden isKiroIssueCommentAddCommand so the completion-preservation guard
also recognizes 'VAR=x multica issue comment add ...' and
'sh -c "multica issue comment add ..."' invocations. Addresses review nit
on MUL-3828 PR #4738.

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <bhjiang@outlook.com>
2026-07-01 13:23:12 +08:00
Xisheng Parker Zhao
4b9ea4aa68 feat(agent): add ByteDance TRAE CLI (traecli) as an ACP backend (#4724)
Adds the official ByteDance TRAE CLI (the `traecli` binary documented at
https://docs.trae.cn/cli — the product paired with the Trae IDE, not the
open-source bytedance/trae-agent) as a built-in agent backend. traecli is
ACP-native, so it is driven over the standard ACP JSON-RPC transport via
`traecli acp serve --yolo`, reusing the shared hermesClient exactly like the
Kiro and Qoder backends.

Validated end-to-end against the real traecli v0.120.42 with a logged-in
account: initialize advertises loadSession:true + mcpCapabilities{http,sse};
session/new returns result.sessionId + models.availableModels (18 models
discovered); session/prompt streams session/update notifications with
sessionUpdate=agent_message_chunk (hermesClient already normalizes this Zed-ACP
wire shape); a real board task ran 14 tool calls and completed in ~47s.

Implementation:
- server/pkg/agent/traecli.go: ACP backend; session/load resume
  (loadSession:true), session/set_model, MCP via ACP mcpServers, --yolo
  bypass-permissions for headless runs, blocked-arg filtering (acp, serve,
  --yolo, --print, --output-format, --permission-mode)
- agent.go: New() + launch header "traecli acp serve"
- models.go: discoverTraecliModels via the shared discoverACPModels
- daemon/config.go: auto-detect the `traecli` binary
  (MULTICA_TRAECLI_PATH / MULTICA_TRAECLI_MODEL)
- daemon.go: inline the runtime brief (traecli reads .trae/rules/, not
  AGENTS.md) and surface the runtime as "Trae" (providerDisplayName)
- execenv: AGENTS.md + .traecli/skills wiring; ~/.traecli/skills local root
- packages/core mcp-support: traecli consumes mcp_config
- frontend: official Trae provider logo
- docs: providers.mdx matrix + section, CLI_AND_DAEMON.md, README

Tests: fake-ACP unit tests matching the real wire format (streaming,
blocked-arg filtering, session/set_model failure, session/load resume) plus a
gated real-binary smoke test (TestTraecliRealACPSmoke) that skips when traecli
is absent or not logged in. Built-in provider only (mirrors qoder): not in
SupportedTypes / RUNTIME_PROFILE_PROTOCOL_FAMILIES, so no migration is needed.

Resolves #4376.
2026-07-01 13:19:06 +08:00
Bohan Jiang
444c2f29a5 fix(slack): stop the chat agent narrating its history reads (MUL-3871) (#4776)
Every Slack reply was prefixed with process narration like '我先读取 Slack 频道概览,
再打开相关线程…' before the actual answer — the model announcing the history reads
the channel-awareness prompt tells it to do. That narration is internal
context-gathering, not part of the answer.

Add an instruction to the channel-awareness block: do the reads silently and
reply with the answer only, no preamble about what it is about to read or just
read.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 13:02:45 +08:00
Bohan Jiang
3a6d3522c8 feat(slack): two-command channel reads — chat history (overview) + chat thread [id] (MUL-3871) (#4762)
Replaces the single scoped `multica chat history --scope` read with two clean
noun-commands so the agent can navigate a channel with many threads (e.g. read
the specific thread a user referred to):

- `multica chat history` — the channel OVERVIEW: recent top-level messages, each
  thread tagged with thread_id + reply_count + latest_reply (it does NOT expand
  thread contents). Backed by GET /api/chat/history + slack.History.ChannelOverview
  (conversations.history).
- `multica chat thread [id]` — read one thread: no id = the thread you're in,
  an id = a specific thread IN THE SAME channel. Backed by GET /api/chat/thread +
  slack.History.Thread (conversations.replies; DM falls back to history).

The channel stays server-pinned to the session; a thread id is only a
within-channel locator, so the security boundary (no cross-channel reads) is
unchanged. `--scope` is removed.

The prompt now teaches both commands and, via a new chat_in_thread signal
(derived from the binding: last_thread_id != last_message_id), tells the agent
which to start with — `chat history` for a top-level @mention, `chat thread` for
an in-thread one.

Tests: slack ChannelOverview/Thread (current/by-id/DM-fallback/no-binding/clamp),
handler both endpoints + auth, prompt top-level vs in-thread guidance.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 12:46:47 +08:00
Bohan Jiang
a0735f70c8 fix(selfhost): pass MULTICA_SLACK_SECRET_KEY through to backend container (MUL-3897) (#4768)
The backend service environment block in docker-compose.selfhost.yml
forwarded MULTICA_LARK_SECRET_KEY but omitted MULTICA_SLACK_SECRET_KEY,
so the variable set in .env never reached the container and Slack
integration stayed disabled ("slack integration disabled
(MULTICA_SLACK_SECRET_KEY not set)"). Add the missing passthrough.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 12:46:03 +08:00
Bohan Jiang
87f9d0fdd3 refactor(autopilots): open access management as a popover from the edit modal (MUL-3893) (#4765)
The standalone 'Manage access' button on the autopilot detail header was
redundant — anyone who cannot open Edit also cannot manage access. The
first attempt folded it into the edit dialog's sidebar, which read as
cluttered. This instead surfaces it as a compact 'Manage access' button in
the edit modal header that opens a popover with the grant/revoke list.

- Extract the access UI into a reusable AutopilotAccessManager (no Dialog)
- Render it inside a header Popover in edit mode, gated on canManageAccess
- Drop the detail-page button, ManageAccessDialog, and the now-dead
  detail.manage_access i18n key (access.* keys are reused by the popover)

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 23:11:29 +08:00
Bohan Jiang
1c010d25c0 Revert "refactor(autopilots): fold access management into the edit dialog (MU…" (#4763)
This reverts commit 48f49d8abc.
2026-06-30 20:16:32 +08:00
Bohan Jiang
48f49d8abc refactor(autopilots): fold access management into the edit dialog (MUL-3893) (#4761)
Remove the standalone 'Manage access' button from the autopilot detail
header and surface the grant/revoke list as an 'Access' section inside
the Edit dialog's configuration sidebar. Anyone who cannot open Edit
already cannot manage access, so the separate affordance was redundant.

- Extract the dialog body into a reusable AutopilotAccessManager
- Render it in edit mode only, gated on canManageAccess
- Drop ManageAccessDialog and its now-dead i18n keys

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 20:15:47 +08:00
Jiayuan Zhang
c4209ec7c0 fix(issues): count active issues, not agents, in working chip (#4750)
The Issues board header 'x working' chip derived its count from the set
of distinct running agent_ids, so two agents on the same issue read as
'2 working'. Count distinct issue_ids instead so the number reflects how
many issues agents are working on — matching the filter the chip toggles,
which already narrows the list to those issues. The avatar stack still
shows the distinct agents behind that work.

Adds workspace-agent-working-chip.test.tsx covering the multi-agent /
single-issue case, multi-issue counting, scopedIssueIds filtering, and
the empty state.

Fixes MUL-3875

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 18:58:19 +08:00
Bohan Jiang
e57288ba60 feat(usage): log per-run prompt-cache hit ratio (MUL-3887) (#4759)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 18:49:41 +08:00
Multica Eve
f88544da63 docs(changelog): add v0.3.33 entry for the 2026-06-30 release (MUL-3889) (#4756)
* docs(changelog): add v0.3.33 entry for the 2026-06-30 release (MUL-3889)

Adds the v0.3.33 release entry to the /changelog page in all four
landing locales (en, zh-Hans, ja, ko), covering the 16 user-visible
changes that landed on main since v0.3.32 (2026-06-29 release).

The entry groups changes into Features / Improvements / Bug Fixes,
using product-language phrasing per the team convention (no
"modified function X" style notes). The Chinese version follows the
team's localization convention: `agent` → `智能体`, `Squad` → `小队`,
while `Issue` stays as-is as the canonical product term.

Release highlights:
- feat(autopilot): View/Write permission layer + Manage Access (MUL-3807)
- feat(slack): unified chat history backfill (MUL-3871) and typing
  reaction on inbound messages (MUL-3874)
- feat(skills): import skills from a .skill/.zip archive (MUL-3865)
- feat(cli)!: drop short UUID prefix resolution for `multica issue`
  (MUL-3838)
- feat(views): Agents page mobile friendly (MUL-3873)
- improvement: rewrite of the comment routing cascade
  (MUL-3794 + MUL-3879 follow-up)
- improvement: docs swap removed Gemini for CodeBuddy (MUL-3861) and
  remove 117 dead _one i18n keys (MUL-3877)
- improvement: self-host preflight allows newer Docker Compose
- fix(daemon): reconcile in-flight task and workspace state on WS
  reconnect (community contribution, closes #4665)
- fix(agent): recover Antigravity reply from transcript when stdout
  is empty (MUL-3726)
- fix(server): skip CLIENT SETNAME for managed Redis compatibility
  (MUL-3848, community contribution)
- fix(views): count tasks, not agents, in activity hover header
  (MUL-3872)

Verified via the existing `apps/web typecheck`, vitest landing
suite (changelog-page-client.test.ts among them), and eslint on the
i18n directory; all green.

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

* docs(changelog): tighten v0.3.33 entry wording (MUL-3889)

Per feedback that the first draft read too verbose for the public
changelog, trim every bullet of the v0.3.33 entry to one short
sentence and drop the supporting clauses that were rehashing
implementation detail (contributor handles, issue numbers, "30s
ticker" specifics, byline of what the rewrite incidentally fixed).

The net effect is a tighter list that matches the cadence of the
v0.3.32 / v0.3.31 entries already on the page.

Applied identically across en.ts / zh.ts / ja.ts / ko.ts.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 18:27:46 +08:00
Bohan Jiang
a961d63611 feat(slack): make the chat agent explicitly channel-aware (MUL-3871) (#4755)
Before this, the chat prompt only carried a generic, always-on hint ('if this
came from a chat channel...'), and the task carried no channel signal — so the
agent never definitively knew it was inside Slack. For an ambiguous ask like
'what did you just talk about', it could read Multica instead of the Slack
conversation.

- Thread a chat_channel_type ('slack') signal: the server sets it on the chat
  task response when the session has a Slack binding
  (GetChannelChatSessionBindingBySession); the daemon Task carries it.
- buildChatPrompt now emits an EXPLICIT block only when channel-backed: 'You are
  operating inside a Slack conversation … this conversation and its history live
  in Slack, NOT in Multica … read it with multica chat history, do NOT look in
  Multica.' Web-only chat sessions get no such block (their history is the
  Multica chat_session the agent already resumes).

Tests: slack-backed prompt asserts the explicit Slack/“NOT in Multica”/command
copy; web-only prompt asserts the block is absent.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 17:24:46 +08:00
Bohan Jiang
50a48cef1e feat(slack): unified multica chat history pull for channel backfill (MUL-3871) (#4747)
* feat(slack): add unified `multica chat history` pull for channel backfill (MUL-3871)

Agents @mentioned in a Slack thread/channel only saw the triggering message,
never the prior conversation (GitHub #4717). Instead of force-assembling a
recent-context block on every inbound (the Feishu approach), expose a single
channel-agnostic pull command the agent runs on demand.

- channel: normalized HistoryMessage/HistoryPage/HistoryOptions vocab so the
  agent sees one shape regardless of platform.
- slack.History: resolves session -> binding -> installation -> bot token and
  reads conversations.replies (real thread) or conversations.history (DM /
  top-level channel, capturing sibling messages). thread_ts is recorded on the
  binding config at session creation to pick the right call.
- handler GET /api/chat/history: authorized purely by the task-scoped token
  (stamped X-Task-ID -> the task's own chat session), so an agent can only read
  the conversation it is currently running for.
- multica chat history CLI command (no args; same for every channel).
- buildChatPrompt nudge so the agent discovers the command.

Feishu is intentionally untouched. Adding a platform = implement the reader.

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

* fix(slack): require task-token actor source on chat history endpoint

Niko's review caught a privilege-boundary hole: the endpoint trusted
X-Task-ID, but it is mounted under the general Auth group where a normal
JWT / mul_ PAT request does NOT strip a client-forged X-Task-ID — only the
mat_ task-token branch stamps it. A workspace member who knew a chat task id
could forge the header and read that task's Slack channel/DM/thread history.

Gate on the server-set X-Actor-Source == "task_token" (the Auth middleware
deletes any client-supplied value and re-stamps it only on the mat_ branch),
then trust X-Task-ID. Adds a regression test: a forged X-Task-ID without the
task-token actor source is rejected with 403 and never reaches the reader.

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

* fix(slack): thread-first history for follow-ups, channel for first turn (MUL-3871)

A Slack conversation has two nested histories: the surrounding channel and the
agent's own thread (the bot's first reply opens a thread on the @mention). The
first version picked replies-vs-history from a thread_ts fixed at session
creation, so a session started by a top-level @mention always read CHANNEL
history — even on follow-ups inside the bot's thread, which should read THREAD
history first.

- Add a HistoryScope (auto|thread|channel). The handler resolves auto:
  first turn (no prior bot reply) -> channel; follow-up -> thread. The agent can
  override with --scope channel|thread, and the response reports the scope read.
- The thread root is derived from the binding (last_thread_id / composite-key
  suffix), available for every engaged group session, instead of the
  creation-time thread_ts (now removed from the binding config).
- A DM degrades a thread request to channel history (DMs have no threads).
- Prompt guidance + CLI help updated to explain the policy.

Tests: scope selection (thread/channel/DM-fallback/no-root), root derivation,
and handler auto-resolution (first->channel, follow-up->thread, explicit
override).

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 16:48:13 +08:00
MeloMei
ff286dcfac MUL-3848: fix(server): skip CLIENT SETNAME for managed Redis compatibility
Closes #4627
2026-06-30 15:51:45 +08:00
LinYushen
630feff1af MUL-3879: restore agent-authored squad-leader fallback in comment cascade (#4748)
After MUL-3794 rewrote the comment routing cascade, computeCommentAgentTriggers
returned early for every non-member author, so worker-agent result comments on a
squad-assigned issue no longer woke the assigned squad leader, breaking the
leader->worker->leader coordination loop.

Restore a narrow agent-authored fallback: when the issue is squad-assigned and
the author is not a member, route to routeAssignedSquadLeaderFallback. Member/
thread routing and explicit @agent/@squad mention routing are untouched, and the
lastTaskWasLeader self-trigger suppression is preserved (it lives inside
routeAssignedSquadLeaderFallback). Explicit mentions are handled before this
branch, so a mentioned target is never double-enqueued alongside the leader.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 14:56:49 +08:00
Bohan Jiang
9ee2bd4c34 fix(agent): recover Antigravity reply from transcript when agy stdout is empty (#4744)
agy 1.0.14 print mode can complete a turn (tools executed, final reply produced) while writing zero bytes to stdout, so the daemon recorded a blank but "completed" run and the user saw no answer (MUL-3726, #4595).

When an otherwise-completed turn returns empty stdout, recover the assistant text agy durably wrote to its per-conversation transcript, bounded to the current turn (reset on each USER_INPUT, status=DONE only) so a resumed conversation never re-emits prior turns' answers. App data dir is read from the daemon-owned --log-file rather than guessing $HOME. All paths fail soft to "" so genuine no-text completions and other statuses are unchanged.

Verified against real agy 1.0.14 output plus unit + end-to-end + resume-boundary tests.
2026-06-30 14:47:16 +08:00
Bohan Jiang
b90816264e feat(skills): import skills from a .skill/.zip archive (#4735)
Import a skill from a local .skill/.zip archive: POST /api/skills/import now accepts a multipart upload (file + on_conflict) alongside the JSON URL body, and the CLI gains `multica skill import --file <path>`. Reuses the existing create + on_conflict contract, per-file/bundle/count caps, reserved-SKILL.md rule, and a zip-slip guard.

Closes #4730
MUL-3865
2026-06-30 14:46:46 +08:00
Bohan Jiang
424b02e79a chore(views): remove dead i18n _one keys in other-only locales (#4746)
ja/ko/zh-Hans resolve only the CLDR `other` plural category, so every
`_one` key in those locales is dead weight that i18next never renders.
Remove 117 such orphan keys across 25 namespaces. Each already has its
`_other` sibling, so this is behavior-preserving.

Also add a parity-test guard that fails if a locale whose CLDR plural
rules lack a `one` category ships any `_one` key, so these can't silently
accumulate again (gated on Intl.PluralRules, the same source i18next uses).

Follow-up to #4740 (MUL-3877).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 14:44:35 +08:00
Bohan Jiang
3b45f7fdf6 docs(slack): drop reinstall callout from Slack integration docs (MUL-3874) (#4745)
Slack is not officially launched yet, so the 'already created your app
with an older manifest — add reactions:write and reinstall' guidance is
unnecessary; nobody is running a pre-launch manifest in production. Remove
the warning callout from all four locales (en/zh/ja/ko).

The reactions:write scope in the manifest and the scope table stay, since
the typing indicator still depends on it.

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 14:29:56 +08:00
Anderson Shindy Oki
93a43a3b0a chore: Remove redundant i18n keys (#4740) 2026-06-30 14:22:19 +08:00
Bohan Jiang
e5995c423f feat(slack): typing reaction on inbound message (MUL-3874) (#4737)
* feat(slack): add typing reaction on inbound message (MUL-3874)

Mirror the Feishu typing indicator on Slack: react with 👀 on the user's
message when it is ingested, then remove the reaction when the agent's run
finishes (EventChatDone) or fails (EventTaskFailed).

- New slack.TypingIndicatorManager: Add on ingest, Clear on terminal run
  events; state keyed by chat_session_id, bot token re-resolved from the DB on
  clear (never held in memory), all failures logged and swallowed (best-effort).
- Wire via the channel-agnostic engine.TypingNotifier seam (slackTypingNotifier
  in the ResolverSet) — the Router already calls OnIngested off the ACK path.
- Clear subscribes to the event bus directly so a failed run also drops the
  reaction (the outbound replier only handles EventChatDone).
- Skip messages older than 2m so Socket Mode reconnect replays don't restamp.

Requires the installed Slack app to hold the reactions:write scope.

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

* fix(slack): clear typing reaction when no task runs; document reactions:write (MUL-3874)

Addresses review feedback on the typing-indicator PR.

1. Stuck reaction on offline/archived agent. The debounced flush
   (flushChatRun) enqueues no task when the agent has no runtime or is
   archived (or on any enqueue/reload error), so no task lifecycle event is
   ever published and the bus-driven clear never fires — leaving the 👀 (and
   Feishu's Typing) reaction stuck on the user's message. Fix at the shared
   engine seam: add TypingNotifier.OnSettled(ctx, sessionID), which the Router
   calls from the flush on every no-task exit (before any offline/archived
   notice). Both the Slack and Feishu notifiers route it to manager.Clear, so
   the latent Feishu case is fixed too. Adds engine coverage (offline/archived
   clear, success does not) and a Slack OnSettled test.

2. Missing reactions:write scope in docs. reactions.add/remove silently fail
   without the scope, but the BYO app manifest/docs never listed it. Add
   reactions:write to the manifest + scope table and a reinstall note across
   all four locales (en/zh/ja/ko).

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

---------

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 14:21:08 +08:00
xiawiie
93028d303b fix(daemon): reconcile in-flight task and workspace state on WS reconnect (#4718)
After a WebSocket disconnect, the daemon's view of running tasks and
workspace state can lag the server for up to 5s (per-task cancellation
poll) or 30s (workspace sync) because both loops park on coarse tickers
that do not observe the WS wakeup channel.

This change adds a small fan-out broadcaster (`reconcileBroadcaster`)
that the WS connect path fires once per (re)connect. `watchTaskCancellation`
and `workspaceSyncLoop` subscribe and re-check immediately on broadcast,
without disturbing the ticker cadence. The broadcaster is edge-triggered
with a one-slot replay so a broadcast that lands before a subscriber is
ready is not lost (closes the daemon-startup race), and back-to-back
broadcasts inside 1s are debounced so a flapping connection cannot fan
out into a request stampede.

Existing behaviour is preserved: shouldInterruptAgent still decides
whether to interrupt, the 5s/30s ticker still bounds the worst case,
and the WS heartbeat / HTTP heartbeat coordination is untouched.

Closes #4665
2026-06-30 13:50:18 +08:00
Bohan Jiang
506f2df7ad fix(views): count tasks, not agents, in activity hover header (MUL-3872) (#4734)
The agent-activity hover card renders one row per task and counts tasks.length, but it reused the agent-worded hover_header copy, so a single agent running multiple tasks made the card read '3 agents working' while the workspace chip read '2 working' (unique agents).

Add a dedicated hover_header_tasks key (en/zh-Hans/ja/ko) and point the hover card at it so the header now reads '3 tasks working'. The per-issue chip keeps hover_header since it genuinely passes the unique-agent count.

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 13:42:09 +08:00
Anderson Shindy Oki
3c61f729d4 MUL-3873: feat: Add agents page mobile friendly
Closes MUL-3873
2026-06-30 13:30:50 +08:00
Bohan Jiang
81291e334e docs: swap removed Gemini runtime for CodeBuddy in built-in lists (#4733)
Gemini CLI runtime was removed in MUL-3617 (#4503), but the canonical
"12 built-in providers" list still advertised [Gemini](/providers#gemini)
(a now-dangling anchor) and omitted CodeBuddy, which the daemon actually
auto-detects (config.go probes `codebuddy`). Swap Gemini -> CodeBuddy
across all 16 occurrences (index / how-multica-works / cloud-quickstart /
daemon-runtimes x en/zh/ja/ko); the count stays at twelve.

MUL-3861

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 12:38:30 +08:00
Janek Lasocki-Biczysko
59cb534e87 fix(selfhost): allow newer Docker Compose versions
Allow newer Docker Compose CLI plugin versions in self-host preflight while continuing to reject legacy Docker Compose v1.
2026-06-30 12:31:56 +08:00
Multica Eve
f892e03e41 feat(cli)!: drop short UUID prefix resolution for multica issue (MUL-3838) (#4732)
BREAKING CHANGE: `multica issue <command> <ref>` no longer accepts short
UUID prefixes (e.g. `1881abcd`). Pass the issue key shown by
`multica issue list` (`MUL-123`) or the full UUID instead. Other
resources without a human-readable key (autopilots, projects, labels,
task runs, workspaces) continue to accept short UUID prefixes.

The previous resolver paged the entire workspace issue list client-side
to disambiguate a short prefix, which timed out on workspaces with
~1000 issues (14–35s; reported in GH #4701). Since the issue key
(`MUL-123`) already covers every human use case for an issue reference
and the full UUID covers every machine case, supporting a third
identifier form has no real product value and forces every issue
command to carry ambiguous / min-length / hex-validation semantics
through the CLI.

Rather than pushing the prefix resolver down into the server (with a
new DB query and an expression / generated index), this change removes
the path entirely. The user-facing migration is trivial: the
`identifier` column shown by `multica issue list` is already routable.

Changes:

- `resolveIssueRef` now accepts only the issue key (`MUL-123`) or the
  full UUID. A short hex prefix returns a tailored error pointing to
  the supported forms; non-hex gibberish returns a generic guidance
  error. Neither path makes an HTTP call.
- The unused `fetchIssueCandidates` paginator is removed.
- Tests cover: full UUID succeeds via a single GET, identifier-first
  resolution does not list, and short prefix / dashed short prefix /
  bare numeric / non-hex inputs all fail fast with no HTTP traffic.

Product rationale and the first-principles discussion are recorded on
Multica issue MUL-3838.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 12:31:15 +08:00
Bohan Jiang
d970b68ce7 feat(autopilot): View/Write permission layer + member access delegation (MUL-3807) (#4695)
* feat(autopilot): add View/Write permission layer

Autopilot write and execute operations were gated only by workspace
membership, so any member could edit, delete, trigger, or rotate the
webhook of any autopilot, and GetAutopilot returned webhook tokens to
every member (a token alone can trigger the autopilot).

- Add canWriteAutopilot / requireAutopilotWrite: update, delete, trigger,
  replay-delivery, and all trigger/secret management now require the
  autopilot creator or a workspace owner/admin.
- Redact webhook_token/path/url in GetAutopilot for callers without write
  access; trigger metadata otherwise stays visible (View default = all
  members). Creating an autopilot stays open to any member.
- ANDs with the existing private-assignee-agent dispatch gate.

MUL-3807

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

* feat(autopilot): delegate write access via collaborators + manage-access UI

Adds an explicit grant primitive so an autopilot's creator/admin can
authorize specific workspace members to manage it, with a frontend entry
point — beyond the implicit creator/owner-admin set from the prior commit.

Backend:
- New autopilot_collaborator table (migration 128, members-only, app-layer
  cleanup, no FK) + sqlc queries.
- memberCanWriteAutopilot now also honors explicit collaborators; the write
  gate, webhook-secret redaction, and a new per-caller can_write flag (on
  list + detail) all flow through it.
- POST/DELETE /api/autopilots/{id}/collaborators (writer-gated); GetAutopilot
  embeds the collaborators list. Delete cleans up grants in its transaction.
- Tests: grant->write->revoke flow, non-writer can't grant, non-member rejected.

Frontend (web + desktop via packages/views):
- ManageAccessDialog: member picker to grant/revoke, current list with remove.
- 'Manage access' entry in the autopilot detail header; edit/run/add-trigger/
  delete and the list-row kebab + per-trigger rotate/delete now gate on
  can_write (absent => allowed, server stays the gate).
- can_write wired through types/schema/api client/mutations; en + zh-Hans copy.

MUL-3807

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

* fix(autopilot): add manage-access i18n keys to ja/ko locales

The locale parity test requires every non-EN bundle to cover every EN
key. The prior commit added detail.manage_access + the access.* block to
en and zh-Hans only, failing parity for ja and ko. Add the translated
keys to both.

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

* fix(autopilot): restrict access-list management to creator/admin only

Final-review fix: AddAutopilotCollaborator/RemoveAutopilotCollaborator
used requireAutopilotWrite, which counts granted collaborators as
writers — so a collaborator could in turn grant/revoke others, a
privilege escalation contradicting the 'collaborators cannot re-grant'
design.

- New requireAutopilotAccessManagement guard uses the narrower
  autopilotWriteByOwnership predicate (creator or workspace owner/admin
  only); swapped into both collaborator endpoints. Collaborators keep
  their edit/trigger/secret write-execute rights.
- GetAutopilot now also stamps can_manage_access (narrower than
  can_write); the detail page gates the 'Manage access' button on it so
  collaborators no longer see an entry that would 403.
- Tests: collaborator grant-others -> 403, revoke-peer -> 403, while
  retaining edit; can_manage_access true for owner, false for collaborator.

MUL-3807

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 12:29:11 +08:00
Multica Eve
5d79696fb5 MUL-3794: rewrite comment routing cascade 2026-06-30 12:24:57 +08:00
Multica Eve
de7f3cb9e3 docs(changelog): add v0.3.32 entry for the 2026-06-29 release (MUL-3840) (#4706)
* docs(changelog): add v0.3.32 entry for the 2026-06-29 release (MUL-3840)

Lands the daily release notes for v0.3.32 in all four landing locales (en / zh-Hans / ko / ja). Groups today's PRs by Feature / Improvement / Bug Fix with product-oriented wording, keeping technical commit jargon out of the user-facing changelog.

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

* docs(changelog): drop two fixes from v0.3.32 per release-confirmation feedback

Removes the 'deleted agents hidden from usage leaderboard' (MUL-3771, #4637) and 'Antigravity daemon-mode guidance' fix lines from all four locales, leaving five customer-facing fixes for the 2026-06-29 release. The Issue body on MUL-3840 is kept in sync separately.

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

* docs(changelog): drop reverted self-host onboarding beacon from v0.3.32

The anonymous self-host onboarding source beacon (MUL-3708, #4691) was reverted in #4712 because of issues with the collection path. Remove the corresponding feature bullet from all four locales so the v0.3.32 changelog only advertises what actually ships.

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

* docs(changelog): hold Slack BYO app feature back from v0.3.32 user changelog

Per release confirmation, the Slack bring-your-own-app feature is shipping behind a disabled frontend entry for v0.3.32 — code lands, but it is not publicly available yet. Drop the Slack feature bullet from all four locales and rewrite the entry title around what is actually exposed to users (Remove parent Issue + daemon reconnect + attachment preview improvements).

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 19:18:13 +08:00
Naiyuan Qing
b336f07617 Revert "feat(analytics): anonymous self-host onboarding source beacon (MUL-37…" (#4712)
This reverts commit 63eb6f73ad.
2026-06-29 19:01:14 +08:00
Jiayuan Zhang
10b33b14f5 fix(dashboard): reconcile deleted-agent spend in usage leaderboard (MUL-3776) (#4661)
PR #4637 (MUL-3771) dropped hard-deleted agents from the per-agent
leaderboard so they'd stop rendering as a bare UUID, but the top-line
Cost/Tokens KPIs still count their spend (those totals aggregate
task_usage_hourly without joining `agent`). The breakdown therefore no
longer reconciled with the totals (#4640).

Instead of dropping unknown-agent rows, fold them into a single
aggregated "Deleted agents" row: sum(visible rows) == KPI total again,
with no UUID exposed. Archived agents still appear as themselves (the
agent list is fetched with include_archived). The bucket carries
tokens + cost only; Time/Tasks render as "—" since the run-time rollups
inner-join `agent` and never attribute time to deleted agents.

- bucketUnknownAgentRows replaces filterKnownAgentRows in dashboard/utils
- Leaderboard renders the sentinel bucket row with a neutral placeholder
  and a "{{count}} agents · {{deleted}} deleted" caption
- i18n: deleted_agents + caption_with_deleted (en/zh-Hans/ja/ko)
- tests cover bucket reconciliation, archived-stays, null-loading passthrough

Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:21:35 +08:00
Bohan Jiang
9f1766cdb3 docs(slack): binding link uses the web app URL, not MULTICA_PUBLIC_URL (MUL-3666) (#4705)
Match the code fix (#4703): the "link your account" link is built from the web
app URL (MULTICA_APP_URL ?? FRONTEND_ORIGIN), which a normal deployment already
sets — not MULTICA_PUBLIC_URL (the backend/API URL). Updates en + zh + ja + ko.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:18:18 +08:00
Bohan Jiang
2b940046d7 fix(slack): build the binding link from the web app URL, matching Lark (MUL-3666) (#4703)
The Slack "link your account" prompt built its redeem link from
MULTICA_PUBLIC_URL, but /slack/bind is a web-app page — the link must use the
web app URL, not the backend/API URL. MULTICA_PUBLIC_URL is intentionally the
backend/API public URL (webhooks, daemon server_url, attachments); the Lark
replier already uses appURLFromEnv() (MULTICA_APP_URL ?? FRONTEND_ORIGIN).
Slack was never migrated, so on deployments that set FRONTEND_ORIGIN but not
MULTICA_PUBLIC_URL (e.g. dev) the binding prompt silently failed
("public url not configured") and @-mentions got no response.

Rename slack.OutboundReplierConfig.PublicURL -> AppURL and feed it
appURLFromEnv() in router.go, mirroring Lark. Backend/API-URL uses of
MULTICA_PUBLIC_URL (webhooks, attachments, daemon server_url) are unchanged.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:13:41 +08:00
Multica Eve
f59cb2f494 MUL-3834: harden daemon websocket reconnect (#4699)
* MUL-3834 harden daemon websocket reconnect

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

* MUL-3834 stabilize daemon websocket liveness tests

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 16:46:57 +08:00
Bohan Jiang
d2bc85e01a refactor(slack): declutter the Slack connect UI (#4700)
* refactor(slack): declutter the Slack connect UI

Trim the Slack bring-your-own-app UI to match the leaner Lark card and
stop burying the setup behind prose nobody reads:

- Drop the "Required bot scopes: …" block from the connect dialog.
- Shorten the Slack integration card description to mirror the Lark
  card; the token/admin details stay in the setup docs.
- Remove the dialog intro paragraph and the per-field token hints;
  replace the small "Read the setup guide" link with a larger,
  more prominent step-by-step guide link.

Removes the now-unused i18n keys (byo_dialog_intro, byo_bot_token_hint,
byo_app_token_hint, byo_scopes_hint) across en/zh-Hans/ja/ko.

* docs(slack): drop the users:read warning callout

The bot manifest already lists users:read as a required scope (with the
bots.info rationale in the scopes table), so the standalone warning
callout was redundant. Removed across en/zh/ja/ko.
2026-06-29 16:31:42 +08:00
Naiyuan Qing
63eb6f73ad feat(analytics): anonymous self-host onboarding source beacon (MUL-3708) (#4691)
* feat(analytics): anonymous self-host onboarding source beacon (MUL-3708)

Production self-host servers now report the anonymous onboarding "how did
you hear about us" channel to Multica's public write-only ingest, so the
self-host source distribution becomes visible alongside official cloud.
Official cloud keeps its existing PostHog capture unchanged; this is a
submit-time beacon, not a background telemetry pipeline.

- server/internal/sourcebeacon: ShouldSend gate (production + non-local +
  non-*.multica.ai app host, fail-closed — judged by the app/frontend host,
  not the backend URL, which official often leaves unset), per-instance
  salted hashing, deterministic event uuid, fire-and-forget sender.
- POST /api/telemetry/self-host-source: public, write-only, per-IP
  rate-limited, 4 KiB body cap, channel allowlist, strict unknown-field
  rejection. Lands in PostHog as self_host_source_channel with a
  deterministic uuid (best-effort dedup), $process_person_profile=false,
  and deployment=self_host — a distinct event name so it never pollutes the
  official onboarding funnel.
- Hook in PatchOnboarding fires once when the source is first set; never
  blocks onboarding. Only channel enum(s) + two per-instance hashes leave
  the box — never user_id/email/name/workspace/org/domain/role/use_case/the
  source_other free-text/IP.
- migration 128: system_settings singleton holding instance_salt.
- frontend: self-host-only anonymous-collection notice on the source step,
  gated by a new /api/config self_host_source_notice flag (en/zh-Hans/ko/ja).
- analytics.Event gains an optional top-level uuid; docs/analytics.md,
  SELF_HOSTING.md and .env.example document exactly what is/isn't sent and
  how to disable it (ANALYTICS_DISABLED). Also fixes the long-standing
  team_size→source drift in docs/analytics.md.

Verified locally: go build/vet, go test (sourcebeacon, analytics, handler),
pnpm typecheck (all packages), locale parity (157), step-source (6) + core
config/schema (69) vitest, lint (0 errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(analytics): wire self-host source beacon through metrics, guard nil pool (MUL-3708)

Addresses Howard CI blockers on #4691 (no product-direction change):

- loadInstanceSalt returns "" on nil pool; salt is only loaded when
  ShouldSendFromEnv() is true, via a bounded (5s) context — restores the
  "router constructible without a DB" invariant (nil-pool routing tests).
- Add multica_self_host_source_channel_total counter (by source) + an
  IncForEvent case, so every analytics event is paired with a Prometheus
  counter. NormalizeSourceChannel reuses sourcebeacon allowlist (no 3rd copy).
- Beacon handler now builds the event via the analytics.SelfHostSourceChannel
  helper and ships it through obsmetrics.RecordEvent (no naked Capture); not
  IsMetricsOnly, so it still reaches PostHog.
- Prime the new family in the registry-families test.

Verified: go build/vet, go test ./internal/metrics ./internal/sourcebeacon
./internal/handler ./cmd/server (incl. the 3 named blockers + registry +
record-event-helper lints) all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:56:16 +08:00
Ryan Yu
c2e8892194 fix(chat): refresh message caches on reconnect (MUL-3831) (#4677)
Invalidate per-session chat message caches (messages, messages-page,
pending-task, task-messages) on websocket reconnect / WS instance change so
a chat that missed chat/task events while disconnected recovers without a
full reload, matching the existing per-issue recovery pattern.

Co-authored-by: Ryan <1141524679@qq.com>
2026-06-29 15:34:36 +08:00
Bohan Jiang
5206d7c613 feat(slack): link the Slack integration guide from the Connect dialog (MUL-3666) (#4697)
The bring-your-own-app Connect Slack dialog only had a (hidden) video CTA, so
users had no in-product pointer to the setup instructions. Add an always-visible
"Read the setup guide" link that opens the Slack integration docs page,
localized to the viewer's language (https://multica.ai/docs[/<lang>]/slack-bot-integration),
following the existing doc-link convention in the app. Adds the byo_docs_link
string to en / zh-Hans / ja / ko.

The doc page it points to ships in the docs PR (#4693).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:33:40 +08:00
Bohan Jiang
e444698a09 docs: channel integrations + Slack bot + Slack app setup (MUL-3666) (#4693)
* docs: channel integrations overview + Slack bot page + Slack app setup guide (MUL-3666)

- channels.mdx: channel-engine overview with an architecture diagram (Mermaid),
  the inbound pipeline, the session/context model, and the authorization gates
  (account binding + workspace membership) — all shared by Lark and Slack.
- slack-bot-integration.mdx: the Slack channel page (mirrors lark-bot-integration)
  — BYO connect flow, usage (@ in channel / DM / /issue), one-bot-per-agent,
  permissions, and self-host (MULTICA_SLACK_SECRET_KEY).
- create-slack-app.mdx: standalone step-by-step — create a Slack app from a
  copy-paste manifest, install it, and grab the bot + app-level tokens.
- meta.json: list the three pages under Integrations.

English (canonical) only this pass; zh/ja/ko localization to follow.

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

* docs(slack): inline full manifest + step-by-step setup into the Slack page (MUL-3666)

The Slack page only linked out for setup, which read as too thin. Fold the
complete, code-verified app manifest and the full walkthrough (create from
manifest → install + bot token → app-level token with connections:write →
connect in Multica) directly into slack-bot-integration.mdx, plus a table
explaining what each scope/event is for.

Remove the now-redundant standalone create-slack-app.mdx (its content lives on
the Slack page) and update meta.json + the channels.mdx links accordingly, so
there's one comprehensive Slack page and no duplicated manifest to drift.

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

* docs(i18n): translate channel + Slack pages to zh/ja/ko and add to nav (MUL-3666)

Adds Simplified Chinese, Japanese, and Korean versions of channels.mdx and
slack-bot-integration.mdx, and lists both pages under Integrations in
meta.{zh,ja,ko}.json. The copy-paste manifest YAML and dotenv blocks are kept
byte-identical to the English source across all languages; in-page anchors in
the channel page point at the slug of each translated heading.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:33:23 +08:00
Bohan Jiang
658e63d9be fix: prefer local upload attachment URLs (#4686)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 14:49:04 +08:00
Bohan Jiang
11a3cf206b feat(slack): bring-your-own-app install + per-installation Socket Mode (MUL-3666) (#4566)
* feat(slack): single app-level Socket Mode connection routed by team_id (MUL-3666)

Reshape the Slack adapter from the stage-3 per-installation Socket Mode model
into the multi-tenant B2 connection model: ONE deployment-level Socket Mode
connection (app-level xapp- token, env MULTICA_SLACK_APP_TOKEN) receives the
Events API stream for every installed workspace and routes each inbound event
to its channel_installation by team_id — the existing
GetChannelInstallationByAppID routing, unchanged.

- AppConnector: the single shared connection (slack/app_connector.go). No leader
  election — per the design "one (or a few)" connections are fine: each replica
  opens one, Slack delivers each event to one of them, and the existing
  (installation, message_id) two-phase dedup guarantees exactly-once processing.
  Resolves the per-team bot user id (via the same app_id query) to detect/strip
  @-mentions, since one connection serves many workspaces.
- Inbound translation (Events API -> channel.InboundMessage) extracted to
  slack/inbound.go as free functions parameterized by the per-team bot identity.
- channel.go trimmed to the outbound Send-only sender; per-installation config
  (config.go) no longer carries an app-level token — installs hold only the
  per-workspace bot token (xoxb-) for outbound, since xapp- can't be OAuth'd.
- engine.Supervisor now skips channel types with no registered Factory, so Slack
  installs (driven by the app-level connector, not per-installation channels) no
  longer churn the lease/Build loop.
- Wiring: router.go builds the connector when MULTICA_SLACK_APP_TOKEN is set;
  main.go runs it alongside the Supervisor. Feishu untouched; channel_* schema
  unchanged.

Verified: go build ./..., go vet ./..., gofmt, and
go test ./internal/integrations/... all pass.

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

* feat(slack): OAuth self-serve install backend (MUL-3666)

Add the in-product OAuth install flow that creates Slack installations, the
keystone the B2 connector consumes.

- slack.InstallService: Begin (build authorize URL, seal workspace/agent/
  initiator into the OAuth state), Complete (verify state, exchange code via
  oauth.v2.access, upsert channel_type='slack' install with the bot token
  encrypted at rest, auto-bind the installer's Slack id so their first message
  is not dropped), plus List/Get/Revoke. State is stateless: sealed with the
  deployment secretbox + an embedded expiry, no session store.
- HTTP handlers (handler/slack.go): member-visible list, admin-only begin +
  revoke, and the public OAuth callback (recovers context from the sealed state,
  redirects the browser back to Settings → Integrations with a result flag).
- Routes + wiring: workspace-scoped list/begin/revoke mirror the Lark
  admin/member split; the callback is a public route like GitHub's. Built from
  MULTICA_SLACK_CLIENT_ID/SECRET (+ redirect derived from MULTICA_PUBLIC_URL,
  override MULTICA_SLACK_REDIRECT_URL; scopes via MULTICA_SLACK_SCOPES).
- Realtime: slack_installation:created / :revoked events.

Verified: go build ./..., go vet, gofmt, and go test ./internal/integrations/slack/...
all pass (new install_test.go covers state sign/verify/expiry/tamper, authorize
URL, code exchange + encrypted upsert + installer bind, and oauth error paths).

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

* feat(slack): in-product OAuth install UI for web + desktop (MUL-3666)

Add the "Connect Slack" self-serve install UI mirroring the Feishu/Lark
integration, completing the in-product install half of B2. Slack's OAuth flow
is a redirect (not a device-code QR poll), so the UI is simpler than Lark's.

- core: SlackInstallation / List / Begin types; api.listSlackInstallations /
  beginSlackInstall / deleteSlackInstallation; slackKeys + slackInstallationsOptions
  query; realtime invalidation on slack_installation:* events.
- views: slack-tab.tsx (SlackTab settings panel + per-agent SlackAgentBindButton
  + connected badge + disconnect confirm). Connect calls beginSlackInstall and
  hands the authorize URL to openExternal (system browser on desktop, new tab on
  web); Slack bounces to the backend callback which lands the install, and the
  realtime event refreshes the list. Wired into the Settings → Integrations tab
  and the agent-detail Integrations tab alongside Lark.
- i18n: en + zh-Hans settings.slack.* strings.

Verified: pnpm typecheck (full monorepo, 6/6) and pnpm lint (@multica/core,
@multica/views — 0 errors) pass.

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

* feat(slack): outbound Replier + user-binding redeem flow (MUL-3666)

Fill the stage-3 Replier=nil tail so non-installer Slack users can onboard and
get status feedback — completing B2 end to end.

- slack.OutboundReplier (engine.OutboundReplier): on NeedsBinding it mints a
  single-use binding token and DMs/replies a "link your account" prompt with the
  redeem URL (wrapped as <url|label> so formatMrkdwn doesn't mangle the
  base64url token); on AgentOffline/AgentArchived it posts a status notice; on an
  /issue-created Ingest it confirms the new issue. Plain chat stays silent (the
  agent's own reply lands via EventChatDone). Reuses the bot-token Send path and
  reads the installation row from ResolvedInstallation.Platform — no new transport.
- slack.BindingTokenService: Mint + transactional RedeemAndBind over the generic
  channel_binding_token / channel_user_binding queries (channel_type='slack'),
  mirroring lark.BindingTokenService. 15-min TTL, SHA256-hashed tokens, the
  three typed failure modes (invalid/expired, already-assigned, not-member).
- HTTP: POST /api/slack/binding/redeem (public, session-authed) maps the failures
  to 410/409/403. NewSlackResolverSet now takes the replier (nil disables it).
- Frontend: /slack/bind redeem page (packages/views/slack + apps/web route) +
  api.redeemSlackBindingToken + en/zh slack_bind copy.

Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/...
(new replier_test.go covers all outcome branches + the prompt URL), plus full
pnpm typecheck (6/6) and pnpm lint (0 errors).

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

* fix(slack): address review must-fixes — connector leak, team-keyed install, /issue copy (MUL-3666)

Three fixes from Niko's review:

1. AppConnector.connectOnce leaked the Socket Mode goroutine/connection on a
   handler error: it ran sm.RunContext on the long-lived ctx and returned the
   error without cancelling it, so a transient DB/router error left the old
   connection alive (consuming events into an unread channel) while Run opened a
   second one. Each connection now runs under its own cancellable context and a
   deferred cancel + join tears it down on every exit path before reconnect.

2. Slack re-install collided with the (channel_type, app_id) unique index:
   connecting the same Slack team to a different agent failed because the upsert
   conflict key was (workspace_id, agent_id, channel_type). Add a team-keyed
   UpsertChannelInstallationByAppID (ON CONFLICT on the (channel_type, app_id)
   index, updating agent_id) and use it for the Slack OAuth install, so
   re-connecting a workspace moves the bot to the chosen agent instead of
   erroring. Feishu's per-agent upsert is unchanged.

3. /issue clarified: it is not a registered Slack slash command (no `commands`
   scope), so Slack never routes one to us. Issue creation runs through the
   message path — `@bot /issue <title>` in a channel or `/issue <title>` in a
   DM — which the engine parser handles. Documented in the connector and the
   user-facing copy (en + zh).

Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/...,
make sqlc, plus pnpm typecheck (6/6) and pnpm lint (0 errors).

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

* fix(slack): make OAuth install transactional — agent-move binding consistency + cross-workspace guard (MUL-3666)

Address Elon's review: the team-keyed upsert kept the same installation row and
only flipped agent_id, but engine session reuse matches purely on
(installation_id, channel_chat_id) and each chat_session is permanently tied to
the agent it was created under — so after moving a Slack team from Agent A to
Agent B, existing DMs/threads kept routing to Agent A; only brand-new
channels/threads reached B. Cross-workspace re-install was worse: the SQL also
moved workspace_id while the application-layer user/chat-session bindings stayed
behind, inheriting the previous workspace's relations.

InstallService.Complete now runs one transaction (lookup → upsert → retire →
installer-bind), all application-layer per the no-FK rule:

- Look up the existing installation by team_id (config->>'app_id').
- Reject a silent cross-workspace ownership change (ErrTeamOwnedByAnotherWorkspace
  → callback redirects with slack_error=team_in_other_workspace). The owning
  workspace must disconnect first.
- On an agent change within the same workspace, retire the installation's
  chat-session bindings (new DeleteChannelChatSessionBindingsByInstallation) so
  the next message creates a fresh session under the new agent. The chat_session
  rows are preserved for history; user bindings stay valid (same users/workspace).
- Installer auto-bind moves into the tx; an already-bound-elsewhere id is a
  benign skip, a real DB error aborts the whole install.

InstallService now takes a TxStarter; the queries seam gains WithTx (dbInstallQueries
adapter) so Complete stays unit-testable with a fake tx.

Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...
(new tests: agent-move retire, same-agent no-retire, cross-workspace reject,
fresh-install no-retire).

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

* fix(slack): atomic cross-workspace install guard + green up frontend CI (MUL-3666)

Two things: address Elon's review and fix the failing frontend CI job.

Review (atomic cross-workspace guard): the previous guard was a SELECT before
the upsert, which loses the concurrent-OAuth race — two workspaces can both read
no rows, one inserts, the other's ON CONFLICT update then silently re-points the
team. Move the guard into the upsert itself: ON CONFLICT ... DO UPDATE ... WHERE
channel_installation.workspace_id = EXCLUDED.workspace_id, and map the empty
RETURNING (pgx.ErrNoRows) to ErrTeamOwnedByAnotherWorkspace. The pre-SELECT now
only feeds the agent-change cleanup. Also corrected the error copy: a team stays
bound to its first Multica workspace (revoke is soft, keeping the row + unique
index), so migration is an operator action, not "disconnect first".

CI (frontend vitest, @multica/views#test):
- The agent IntegrationsTab now renders the real SlackAgentBindButton, whose
  connected badge calls useQueryClient — absent from integrations-tab.test.tsx's
  react-query mock. Hoisted the owner/admin gate above the per-platform sections
  (one role notice instead of one per platform), made the agents members_note
  generic (en/zh/ja/ko), and updated the test (mock @multica/core/slack, stub
  SlackAgentBindButton, assert both platforms).
- Added slack-tab.test.tsx covering the real SlackAgentBindButton / SlackTab.
- locale parity: added the slack (settings) + slack_bind (common) blocks to ja
  and ko so every EN key has a translated counterpart.

Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...;
pnpm --filter @multica/views test (1478 pass), pnpm typecheck (6/6), pnpm lint (0 errors).

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

* fix(slack): surface agent-page Slack entry points when Lark is off (MUL-3666)

The agent-detail Integrations tab and the inspector's Integrations section
only considered Lark, so a Slack-only deployment (Lark disabled) showed neither
the Integrations tab nor a Connect-Slack button — the per-agent entry points
were unreachable.

- agent-overview-pane: gate the Integrations tab on Lark OR Slack configured
  (new slackInstallationsOptions query), not Lark alone.
- agent-detail-inspector: render SlackAgentBindButton alongside LarkAgentBindButton
  in the Integrations section.
- regression test: the Integrations tab appears when only Slack is configured.

Verified: pnpm typecheck (6/6), pnpm --filter @multica/views test (1478+ pass),
pnpm lint (0 errors).

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

* feat(slack): BYO-app install backend — paste xoxb+xapp, per-app install keyed by real app id (MUL-3666)

Adds the bring-your-own-app install path so multiple agents can each have
their own bot identity in the SAME Slack workspace (hosted B2 caps at one
agent/workspace). User pastes their app's bot token (xoxb-) + app-level
token (xapp-); we validate the bot token via auth.test, parse the real
Slack app id from the xapp- token, encrypt both tokens, and persist a
per-app installation keyed by that app id (real 'A…' ids never collide
with hosted 'T…' team ids in the existing unique index — no schema change).

- config.go: add app_token_encrypted (BYO discriminator + per-app socket token)
- install.go: extract shared persistInstall (atomic cross-ws guard + agent-move retire)
- byo_install.go: RegisterBYO + auth.test + app-id parse
- handler + route: POST /api/workspaces/{id}/slack/install/byo (admin-only)
- tests: keying, encryption, invalid tokens, auth.test failure, cross-ws, agent move

Follow-ups (separate commits): per-app Socket Mode connector that consumes
the stored app token; in-product BYO install dialog (video + paste form).

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

* refactor(slack): drop OAuth, unify on BYO per-installation model (MUL-3666)

Per product decision, Slack drops the hosted-app OAuth path entirely and
unifies on bring-your-own-app (BYO): every installation carries its OWN
app-level token and gets its OWN Socket Mode connection, so multiple agents
can each have a distinct bot identity in one Slack workspace.

- Remove OAuth install (Begin/Complete/code-exchange/sealed state/OAuthConfig/
  default scopes), the OAuth callback + begin handlers + routes, and the
  MULTICA_SLACK_CLIENT_ID/SECRET/REDIRECT/APP_TOKEN env wiring.
- Replace the single deployment-level AppConnector with a per-installation
  slackChannel (authenticated with its own xapp- token) registered as a channel
  Factory, so the engine Supervisor drives one Socket Mode connection per
  installation (exactly like Feishu). inbound/outbound/resolvers reused as-is.
- Route inbound by the event's api_app_id (== the installation's real app id),
  not team_id.
- InstallService slims to at-rest encryption + the shared persistInstall +
  list/get/revoke; install is the BYO paste path only (byo_install.go).
- Tests: drop the OAuth tests; slack + handler + engine all green.

Follow-up (frontend): replace the OAuth "Connect Slack" button with the BYO
paste dialog (the begin endpoint it calls is now gone).

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

* fix(slack): verify BYO bot + app tokens are from the same app, and the app token is live (MUL-3666)

Niko review: RegisterBYO only parsed the app id from the xapp string and
auth.test'd the bot token, so pasting app A's bot token with app B's app
token would 'connect' but be broken (inbound on B's socket, outbound with
A's identity). Now: resolve the bot's owning app id via bots.info (on the
bot_id from auth.test) and require it to equal the xapp's app id; and live-
validate the app token via apps.connections.open. Reject (no persist) on
mismatch or a dead app token.

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

* feat(slack): in-product BYO install dialog (paste bot + app tokens) (MUL-3666)

The OAuth begin endpoint was removed server-side, so the "Connect Slack"
button now opens a dialog where the admin pastes the bot token (xoxb-) and
app-level token (xapp-) of the Slack app they created, and submits to the
BYO install endpoint. Includes an optional setup-video link (URL constant,
left empty until the walkthrough is recorded).

- core: drop beginSlackInstall / BeginSlackInstallResponse; add
  registerSlackBYO + RegisterSlackBYORequest.
- views: SlackAgentBindButton opens the BYO dialog; refreshed comments and
  install_supported docs (now means "configured", no OAuth).
- i18n: new slack.byo_* keys + refreshed page_description in en/zh-Hans/ja/ko.
- tests: dialog submit path; views vitest (1479), typecheck, lint, locale
  parity all green.

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

* fix(slack): Elon review — team_id routing guard, per-agent reconnect, users:read hint (MUL-3666)

1. Inbound routing keys on api_app_id (the APP, not the Slack workspace), so
   additionally require the event's team_id to match the installation's stored
   team. A distributed BYO app installed into another Slack workspace emits the
   same app id and would otherwise mis-route to this Multica installation.
   Extracted installationServesTeam() + unit test.

2. BYO install is now agent-keyed (UpsertChannelInstallation, conflict on
   workspace_id+agent_id+channel_type): one bot per agent. Disconnect →
   reconnect a NEW app for the SAME agent now UPDATES that agent's row in place
   instead of violating the (workspace, agent, channel) unique. A unique
   violation on the (channel_type, app_id) routing index → ErrTeamOwnedByAnother-
   Workspace (the app is already connected to another agent/workspace). No
   chat-session retire is needed: a row's agent_id never changes.

3. UX: bots.info (the same-app check) needs the users:read scope — the connect
   dialog now lists the required bot scopes including it, and the error text
   says so.

Backend build/vet/gofmt/test + views vitest + typecheck + locale parity green.

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

* fix(slack): publish slack_installation:created on BYO connect; refresh stale comments (MUL-3666)

Niko final review: RegisterSlackBYO wrote the response but never published
EventSlackInstallationCreated, so only the installer's own tab refreshed —
other open clients (Settings, Agent Integrations, other tabs) did not see the
new bot in realtime, inconsistent with the revoke event and Lark. Now publishes
it on success via a small publishSlackInstallationCreated helper, with a unit
test (Bus.Publish is synchronous).

Also refreshed comments that still described the removed hosted-OAuth /
single deployment-level AppConnector model (handler SlackInstall field,
channel.go / inbound.go / outbound.go / byo_install.go). PR title updated
separately to the BYO per-installation Socket Mode model.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 14:09:34 +08:00
Naiyuan Qing
6e2d2c003c fix(issues): sync sticky comment header background with highlight fade (MUL-3759) (#4690)
The deep-link highlight tint faded out over 700ms on the comment body
layers but the sticky header's background switched instantly, and its
4px bottom `after` gradient band recolored by class-switching that
`transition-colors` cannot animate. Both desynced from the body during
the fade, showing a white header and a pale seam under it.

Add `transition-colors duration-700` to the sticky shell so the header
background fades with the body, and make the `after` band derive its
color from the header via `bg-[inherit]` + a `mask-image` fade instead
of a per-state gradient color, so all three layers are driven by the
single header background transition.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:48:49 +08:00
Bohan Jiang
4fb6c0fb0e fix(daemon): bound runtime --version probe so one wedged CLI can't block all runtimes (MUL-3812) (#4685)
* fix(daemon): bound runtime --version probe so one wedged CLI can't block all runtimes

A CLI whose `--version` never returns (e.g. a brew-installed claude wedged
by a bun regression) stalled the daemon's sequential runtime registration
loop forever. Registration runs inside the blocking preflight that gates
/health, so the daemon never flipped from "starting" to "running" and every
runtime on the host appeared disconnected — not just the broken one.

detectCLIVersion now derives a 10s timeout context and sets cmd.WaitDelay so
a node/bun shim that leaves a child holding the stdout pipe open can't defeat
the timeout. A wedged probe now fails fast; the existing per-agent error skip
isolates the broken runtime and the rest register normally.

MUL-3812

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

* test(agent): reap the hang script's orphaned child instead of leaking it

The MUL-3812 regression test spawned a background `sleep 60` that outlived
the killed parent and lingered for up to 60s on CI. The hang script now
records the child's PID and t.Cleanup reaps it, so no temporary process is
left behind.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 12:43:32 +08:00
Dmitry
0c2f93bcd1 fix: allow same-origin attachment previews (#4679) 2026-06-29 12:10:52 +08:00
Bohan Jiang
78d668a2f2 fix(agent): clarify Antigravity daemon mode
Fixes MUL-3726
2026-06-28 13:13:36 +08:00
Bohan Jiang
e2103a240d fix(server): emit issue:updated when failed-task handler resets stuck issue (#4662)
HandleFailedTasks resets a stuck in_progress issue back to todo via a direct UpdateIssueStatus, bypassing the HTTP handler that emits issue:updated. Without that event the frontend realtime reconcile never runs, so status-filtered board columns/lists stay stale until the next write. Publish issue:updated (status_changed + prev_status) after the reset. Fixes #4648 (MUL-3782).
2026-06-28 13:01:00 +08:00
Jiayuan Zhang
ff0979008b fix(dashboard): hide deleted agents from usage leaderboard (MUL-3771) (#4637)
* fix(dashboard): hide deleted agents from usage leaderboard (MUL-3771)

The usage leaderboard fell back to rendering the raw agent UUID when an
agent was no longer in the workspace agent list (`agent?.name ?? row.agentId`).
Hard-deleted agents only survive as legacy usage rollup rows, so they showed
up as a bare UUID.

Filter the leaderboard rows down to agents still present in the workspace.
The agent list is fetched with `include_archived: true`, so archived agents
keep their names and stay; only hard-deleted agents drop out. Filtering is
skipped until the agent list has loaded so a slow fetch doesn't transiently
blank the board. Top-line KPI totals are unchanged — only the per-agent list
is affected.

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

* fix(dashboard): stabilize empty agent list

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <lambda@multica.ai>
2026-06-27 01:09:54 +08:00
Bohan Jiang
24754f091b fix: allow framed attachment redirects (#4635)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 23:35:47 +08:00
Bohan Jiang
37d9fafda6 feat(issues): add Remove parent issue action (MUL-3764) (#4630)
* feat(issues): add Remove parent issue action to promote a sub-issue to standalone (MUL-3764)

Surfaces a discoverable UI affordance for clearing an issue's parent — the
backend and CLI (multica issue update --parent "") already support it, but
the Official App only exposed Set parent. Adds:

- A 'Remove parent issue' item in the issue actions menu (dropdown +
  right-click), shown only when the issue has a parent.
- A hover unlink button on the parent card in the issue detail sidebar.
- A removeParent handler that clears parent_issue_id and stage in one
  write (stage only orders sub-issues under a parent) with a success toast.

Closes #4629

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

* fix(issues): toast remove-parent on success only, prune old parent's children cache (MUL-3764)

Addresses review feedback on #4630:

- use-issue-actions.ts: the remove-parent success toast fired eagerly after
  mutate(), so a request that failed on permission/network/validation would
  flash "removed" before the error toast and optimistic rollback. Move it to
  onSuccess so only a server-confirmed detach is announced.

- mutations.ts: when a write re-parents an issue away from its current parent,
  prune it from the old parent's children cache instead of patching it to
  parent_issue_id: null in place. The parent's sub-issues list renders that
  array directly, so the orphaned row used to linger until the settle refetch.
  onError still restores prevChildren, so the prune rolls back on failure.

Adds cache-prune coverage (optimistic remove / rollback / non-reparenting
no-op) and onSuccess-vs-onError toast coverage.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 23:13:44 +08:00
Bohan Jiang
a252f47337 fix(scheduler): advance autopilot next_run_at after each scheduled dispatch (MUL-3749) (#4618)
* fix(scheduler): advance autopilot next_run_at after each scheduled dispatch

The display-only autopilot_trigger.next_run_at column was written only on
trigger create/update and never advanced afterward, so for a recurring
schedule it froze at a past slot and the list rendered it as a 'next run'
in the past (e.g. '53m ago'). The intended AdvanceTriggerNextRun query was
dead code with zero callers.

Wire it up at the scheduler's existing post-dispatch seam (replacing the
last_fired_at-only TouchAutopilotTriggerFiredAt bump, which AdvanceTrigger-
NextRun already supersets). The advanced value is computed on the app local
clock via ComputeNextRun — the same path create/update use — so the whole
next_run_at display column is owned by one clock and stays consistent;
scheduling itself is untouched and still runs off DB time via
NextOccurrencesUTC. On a cron/timezone parse failure we fall back to the
last_fired_at-only bump.

Adds a deterministic regression test for the reported scenario (hourly
cron in America/New_York) and documents the local-clock ownership on
ComputeNextRun.

MUL-3749

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

* fix(scheduler): floor next_run_at advance at plan_time to survive clock skew

Addresses review feedback on the next_run_at write-back (MUL-3749):

- The post-dispatch advance computed the value from time.Now() alone. The
  handler is entered only after DB time judged the plan due, so if this app
  instance's clock lags the DB clock at a period boundary, time.Now() could
  recompute the slot that just fired and next_run_at would not advance —
  the original staleness bug, at the boundary. Extract advancedNextRun,
  which anchors at max(now, plan_time) via NextOccurrenceAfterUTC so the
  written value is always strictly after the fired plan_time while still
  tracking the local clock in the normal case.
- Add scheduler-layer tests asserting the written value is strictly after
  plan_time across skew / on-slot / normal cases. The previous service-layer
  test only exercised the helper with an explicit after, not this path.
- Sync the stale ListSchedulableAutopilotTriggers comment: the scheduler
  now writes last_fired_at via AdvanceTriggerNextRun (sqlc regenerated).

MUL-3749

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 18:54:51 +08:00
Multica Eve
bbf758e1af docs(changelog): add v0.3.31 entry for the 2026-06-26 release (MUL-3748) (#4616)
* docs(changelog): add v0.3.31 entry for the 2026-06-26 release (MUL-3748)

Covers the cross-workspace inbox unread dot in the switcher (MUL-3695), the
Composio Go SDK foundation (MVP), per-worktree desktop dev isolation
(MUL-3724), and the new reusable VideoEmbed with the zh docs intro video.
Bug fixes include the editor Tab list-indent / focus-keeping behavior
(MUL-3697), squad leader briefing now keyed by task flag (MUL-3730) plus
the inherited @mention reply skip (MUL-3744), code-block selection
stability during background re-renders (MUL-3621), local handoff-note
version gate for direct agent assigns, comment-edit save loading state
(MUL-3709), search API response parsing, and an actionable error when
self-host hosts are missing Docker Compose v2.

Localized into en / zh-Hans / ko / ja with product-language wording per the
`Issue`-only exception (`agent` -> "agent" / 智能体, `Squad` -> "squad" / 小队).

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

* docs(changelog): tighten v0.3.31 entries per review (MUL-3748)

Per Bohan's review on MUL-3748: the v0.3.31 copy was too wordy. Shorten

every bullet to a single user-facing sentence and drop the internal

details (worktree mechanics, signed webhooks, hljs spans, account-level

summary call, etc.). en / zh / ko / ja all updated; product-language

wording and the Issue / 智能体 / 小队 rule are preserved.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 17:57:04 +08:00
Multica Eve
256a0a9b27 fix(squad): skip leader on reply that inherits parent @mention (MUL-3744)
Refs MUL-3744.
2026-06-26 16:53:22 +08:00
Jiayuan Zhang
8c84415864 docs(claude): note pnpm dev:desktop self-isolates per worktree (#4610)
Clarify that desktop dev worktree isolation (renderer port + app name) is
automatic and independent of .env.worktree, which only covers backend/
frontend DB names/ports. Follow-up to MUL-3724 (#4598).

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 16:02:27 +08:00
LinYushen
3692b6a862 fix(squad): inject leader briefing by task flag, not issue assignee (MUL-3730) (#4606)
* fix(squad): inject leader briefing by task flag, not issue assignee

Key squad-leader briefing injection off task.IsLeaderTask + task.SquadID
instead of issue.AssigneeType=='squad'. The old gate missed the most common
path — an @squad mention in a comment on an issue assigned to a plain agent
(MUL-3724) — so the leader booted with zero squad context and did the work
itself instead of orchestrating.

- migration 127: add agent_task_queue.squad_id (no FK) + partial index
- sqlc: CreateAgentTask stamps squad_id; CreateRetryTask inherits it
- service: thread squadID through EnqueueTaskForSquadLeader(+WithHandoff),
  enqueueMentionTask, and the rerun path; all 5 call sites pass the squad id
- daemon claim: unified injection keyed on leader-task + squad_id, with a
  defensive leader-identity re-check; quick-create block retained (it serves
  issue-less tasks and sets resp.SquadID/SquadName)
- briefing: strengthen leader Operating Protocol opening
- tests: claim-time injection (comment-mention/non-leader/null-squad),
  squad_id enqueue stamping, retry inheritance; existing fixture updated

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

* test+docs(squad): dangling squad_id regression + clarify quick-create path

Address review nits on #4606:
- Add TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing: squad hard-deleted
  after enqueue leaves task.squad_id dangling (no FK); claim still 200 and
  skips injection via the err!=nil guard. This is the load-bearing contract
  for dropping the FK.
- Rewrite the daemon.go injection comment to state quick-create does NOT use
  the is_leader_task/squad_id columns — it routes squad via the context JSON
  branch (qc.SquadID) and must not be folded into the column-based path.

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

---------

Co-authored-by: 魏和尚 <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 16:01:33 +08:00
Jiayuan Zhang
9c1d8d2659 fix(selfhost): fail early when Docker Compose v2 is missing (#4354)
* fix(selfhost): fail early with actionable message when Docker Compose v2 is missing

The selfhost make targets hardcoded 'docker compose' (Compose v2). On hosts with only the legacy v1 'docker-compose' (e.g. apt install docker-compose) the plugin is absent, so 'docker compose -f ... pull' fell through to the top-level docker CLI and failed with the cryptic 'unknown shorthand flag: f in -f', which users mistook for a Docker version problem (MUL-3458).

Add a REQUIRE_COMPOSE preflight that checks 'docker compose version' and prints an actionable English install hint, and route every selfhost invocation through $(COMPOSE). v1 fallback is intentionally not supported: docker-compose.selfhost.yml uses compose-spec syntax (top-level name:, no version:) that v1 cannot parse.

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

* fix(selfhost): shorten Compose v2 hint and reject legacy v1

Address review on PR #4354:
- Drop Linux-specific install commands from the runtime error; point to
  the OS-agnostic https://docs.docker.com/compose/install/ and keep the
  message short and stable. Per-OS steps belong in docs, not the Makefile.
- Reject Compose v1 explicitly: the preflight now also requires the
  reported version to be v2, so COMPOSE=docker-compose no longer passes
  and then fails later on the compose-spec file.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <agent@multica.ai>
2026-06-26 09:40:05 +02:00
Jiayuan Zhang
6dcf82a58a feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724) (#4598)
* feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724)

Two worktrees could not run pnpm dev:desktop at once: both grabbed the
renderer port 5173 and the single-instance lock keyed by the app name
"Multica Canary". The env hooks to override each already existed
(DESKTOP_RENDERER_PORT in electron.vite.config.ts, DESKTOP_APP_SUFFIX in
src/main/index.ts) but nothing derived per-worktree values.

A new dev launcher (scripts/dev.mjs) derives both from the worktree path
for linked worktrees only — reusing the same cksum%1000 offset as
scripts/init-worktree-env.sh, so renderer port is 5173+offset and the app
becomes "Multica Canary <folder>" with its own userData/lock. The primary
checkout is untouched; explicit env vars still win. Backend targeting is
unchanged (apps/desktop/.env*). Also: brand-dev-electron honors the suffix,
turbo globalEnv passes it through, and CONTRIBUTING documents the flow.

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

* fix(desktop): make worktree dev port/suffix collision-safe (MUL-3724)

Addresses code review on #4598:

- Renderer port base 5173 → 5174 so a worktree whose offset is 0 (e.g.
  cksum("/tmp/multica-3494") % 1000 === 0) no longer collides with the
  primary checkout's default 5173.
- DESKTOP_APP_SUFFIX is now "<folder>-<offset>" instead of just the folder
  name, so worktrees that share a basename at different paths (or names that
  slug to the same fallback) get distinct single-instance locks. Without it
  the second Electron was still blocked by the shared lock.
- Tests: offset-0 port guard, and same-basename-different-path disambiguation.

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

---------

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 15:11:28 +08:00
Multica Eve
8d0ea04fb0 feat(composio): add standalone Go SDK client (MVP) (#4603)
* feat(composio): add standalone Go SDK client (MVP)

Adds server/pkg/composio — a self-contained Go SDK for the Composio v3.1
REST API. Built on go-resty/resty v2; zero coupling to other Multica
packages so it can be vendored or extracted later without surgery.

MVP surface (just the endpoints Stage 2 needs):

- POST /connected_accounts/link        Client.CreateLink
- POST /tool_router/session            Client.CreateSession
- GET  /connected_accounts             Client.ListConnectedAccounts
- POST /connected_accounts/{id}/revoke Client.RevokeConnection
- DELETE /connected_accounts/{id}      Client.DeleteConnectedAccount
                                       (404 -> nil, idempotent)
- GET  /toolkits                       Client.ListToolkits
- GET  /toolkits/{slug}                Client.GetToolkit
- POST /tools/execute/{slug}           Client.ExecuteTool
- Webhook HMAC-SHA256 verification     composio.VerifyWebhook /
                                       VerifyHTTPRequest + ParseEvent

Other notes:

- Auth via x-api-key header (Composio v3.1 contract).
- Typed *APIError envelope with IsNotFound / IsUnauthorized /
  IsRateLimited helpers; falls back to raw body when upstream returns
  non-JSON.
- Webhook signature accepts the official "v1,<sig>" format and any
  comma-separated multi-version list; 300s replay tolerance by default,
  honors an injectable clock for tests; RFC3339 timestamps tolerated.
- README.md documents all public APIs and design choices.

Tests:

- All exercise httptest.NewServer - no real Composio calls.
- 36 tests covering happy paths, validation, 404 idempotence, error
  decoding, signature verify (good / tampered / stale / multi-version /
  bare / RFC3339 / missing headers / empty secret).
- go test ./pkg/composio/... -cover -> 82.2%, exceeds the >=80% bar.

Follow-ups (separate PRs):

- server/internal/integrations/composio - DB schema, REST handlers,
  registration_service (CSRF), dispatch hook (MUL-3720 remainder).
- Pagination iterators, retry middleware, proxy execute, triggers.

Refs: MUL-3720, MUL-3715
Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): align SDK with v3.1 wire contract (PR #4603 review)

Addresses GPT-Boy's review on PR #4603:

Must-fix
- ListConnectedAccountsRequest: switch UserID/AuthConfigID singular fields
  to plural slices (UserIDs, AuthConfigIDs, ToolkitSlugs, ConnectedAccountIDs,
  Statuses). The Composio v3.1 spec ships these as `*_ids` array params;
  our singular form silently dropped the user-filter on the wire. Also
  surfaces order_by / order_direction / account_type from the same spec.
- ExecuteToolRequest: rename ToolkitVersions -> Version with json tag
  `version` (the actual v3.1 body field). Marks AllowTracing as
  deprecated per the spec.

Nits
- ListToolkitsRequest.SortBy comment: `popular | alphabetical` -> the
  real enum `usage | alphabetically`.
- Client constructor: when Options.HTTPClient is provided, use
  resty.NewWithClient(hc) so the caller's Transport, Jar, CheckRedirect,
  and Timeout all carry through — the prior code only forwarded
  Transport + Timeout despite the field comment promising the full
  *http.Client.

Tests
- TestListConnectedAccounts_QueryString now asserts plural query keys
  (user_ids, auth_config_ids, connected_account_ids, statuses) and
  explicitly guards that the legacy singular keys do not leak.
- TestExecuteTool_Success decodes the body as a raw map and asserts the
  wire key is `version` (not `toolkit_versions`).
- New TestExecuteToolRequest_VersionSerialization locks the json tag.
- New TestNewClient_HonorsInjectedHTTPClient drives a request through a
  recordingTransport and asserts the inbound *http.Client actually
  handled it.

Verification
- go test ./pkg/composio/... -cover -> 85.1% (38 tests; was 82.2% / 36).
- go vet, go build, gofmt -l all clean.

Refs: PR #4603 review, MUL-3720
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 15:07:47 +08:00
Naiyuan Qing
714f9b1ab7 fix(editor): keep Tab inside lists instead of escaping focus (MUL-3697) (#4605)
In a list item that cannot indent (first child / max depth), Tab was
returned unhandled, so the browser's native Tab moved focus out of the
editor onto adjacent controls. Decouple "swallow the key" from "did the
indent move anything": best-effort indent, then swallow whenever the
caret is inside the list (editor.isActive(name)) and only fall through
to focus navigation when not in a list. Covers bullet, ordered and task
lists via the shared keymap.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:06:28 +08:00
Naiyuan Qing
553419f8ef fix(editor): indent multi-item list selection on Tab (MUL-3697) (#4587)
Stock prosemirror-schema-list `sinkListItem` returns false without
dispatching whenever `range.startIndex === 0`, so selecting a list from
the top and pressing Tab did nothing. Bullet, ordered, and task lists
all routed through the same command and were equally affected.

Wrap the shared Tab keymap (PatchedListItem + PatchedTaskItem) with
`sinkListItemRange`: try stock sink first, and when it bails on a
multi-item range whose first item is the list's first child, re-run the
stock command on a selection narrowed to start inside the second selected
item. The first item stays as an anchor and the rest nest under it
(Notion/GitHub nested-list behaviour), in a single undoable transaction.

Shift-Tab / liftListItem already handles ranges and the first-item case,
so it is unchanged. No schema change.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 14:31:56 +08:00
Naiyuan Qing
73b9a41260 feat(docs): reusable VideoEmbed + Chinese intro video on zh docs homepage (#4597)
* feat(docs): add reusable VideoEmbed and embed intro video on zh homepage

Add a provider-agnostic, click-to-load <VideoEmbed> component (Bilibili now,
YouTube reserved) and embed the Chinese intro video (BV1cv7Y6gEg7) at the top
of the Chinese docs homepage. The facade renders on first paint; the
third-party player iframe only mounts on user click, so first paint pays
nothing for an external player or its trackers. Registered in both docs MDX
component maps so it is reusable on any docs page.

Scope is zh docs only — no usecase, no other locales, no analytics, no video
hosting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs(zh): drop duration from intro video title

Use the duration-free title "Multica 中文介绍视频" for the homepage VideoEmbed
instead of a minute-count phrasing. Copy accuracy only; no component, layout,
or provider changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 14:25:49 +08:00
Naiyuan Qing
cd6cd9dcd1 fix(editor): keep code-block selection stable during background re-renders (MUL-3621) (#4594)
Selecting text in a readonly code block (comment/issue markdown) lost the
selection within seconds, making copy impossible, whenever the surrounding
view re-rendered — most reliably while a sibling agent task streamed over
WebSocket (a re-render roughly every ~100ms).

Root cause: the `code` renderer emits highlighted HTML via
`dangerouslySetInnerHTML={{ __html }}`, a fresh prop object every render. Each
unrelated parent re-render re-ran react-markdown, and React rewrote the
`<code>` innerHTML even though the HTML string was byte-identical, tearing down
and rebuilding all 161 hljs `<span>` nodes. The native selection is anchored to
those nodes, so it collapsed.

Fix: memoize the entire `<ReactMarkdown>` subtree on its only real inputs
(`processed` + `components`). A stable element reference lets React bail out of
the subtree on unrelated re-renders, so the code-block DOM is never rebuilt
while content is unchanged. Confirmed via an instrumentation probe: zero
`<code>` DOM mutations during streaming after the fix.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:49:50 +08:00
Bohan Jiang
9e807efc62 feat(sidebar): per-workspace switcher dot + count unread per issue (MUL-3695) (#4591)
* feat(sidebar): mark which workspace has unread in the switcher dropdown (MUL-3695)

The aggregate avatar dot only says "some other workspace has unread". When
the user opens the workspace switcher they couldn't tell which one. Add a
per-row brand dot next to each OTHER workspace that has unread inbox items,
in the same right-edge slot as the active-workspace check (the active
workspace is excluded — its unread is the Inbox nav count — so dot and
check never collide on one row).

Reuses the existing cross-workspace summary data; no backend change. New
pure helper unreadWorkspaceIds() + unit tests, and AppSidebar dropdown
tests covering: dot only on the other unread workspace, no dot at count 0,
and never on the active workspace.

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

* fix(inbox): count switcher unread per issue, matching the inbox dedup (MUL-3695)

The unread-summary that drives the workspace-switcher dot counted raw
unread inbox_item rows, but the inbox UI deduplicates notifications per
issue and treats an issue as read when its NEWEST non-archived item is
read. Opening an issue marks only that newest item read (markInboxRead is
per-item; only archive cascades to siblings), so older siblings stay
unread in the DB. Result: a workspace whose inbox the user sees as empty
still lit the dot (reported on bohan-personal showing a dot for Multica AI
with no unread).

Rewrite CountUnreadInboxByWorkspace to pick the newest non-archived item
per (workspace, issue-or-id group) via DISTINCT ON and count only groups
whose newest item is unread — the exact semantics of
deduplicateInboxItems(...).filter(!read) on the client. No schema/handler
change; query-only. Adds TestInboxUnreadSummaryDedupesByIssue covering the
read-newest / unread-older case and its inverse.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 13:28:45 +08:00
Bohan Jiang
54145ad72e feat(sidebar): dot the workspace switcher when other workspaces have unread inbox (MUL-3695) (#4577)
Adds a cross-workspace unread summary so the workspace switcher shows the
existing brand dot when a workspace OTHER than the active one has unread
inbox items. The active workspace's own unread stays on the Inbox nav
count to avoid a duplicate signal, and the dot is shared with the pending-
invitation indicator.

Backend: new GET /api/inbox/unread-summary returns per-workspace unread
counts for the user, scoped via a member join so a left workspace can't
light the dot. One account-level query instead of N per-workspace inbox
fetches.

Frontend: schema-guarded api.getInboxUnreadSummary, a single account-level
TanStack Query, and a derived "other workspace has unread" boolean in
AppSidebar (shared by web + desktop). Inbox WS events (new/read/archived/
batch) and reconnect invalidate the summary, so the dot appears and clears
in realtime even for events from a non-active workspace.

Closes multica-ai/multica#3773

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 12:09:15 +08:00
351 changed files with 22404 additions and 2112 deletions

View File

@@ -198,6 +198,11 @@ CORS_ALLOWED_ORIGINS=
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
# the PAT cache, and the daemon-token cache.
# REDIS_URL=redis://localhost:6379/0
# Set to "true" to skip the CLIENT SETNAME handshake on every Redis
# connection. Required for managed Redis providers that block the CLIENT
# command (e.g. GCP Memorystore, AWS ElastiCache with restricted ACLs).
# Default is false (client naming enabled for connection observability).
# REDIS_DISABLE_CLIENT_NAME=true
# Max requests per IP per minute. Defaults are 5 for send-code/google
# and 20 for verify-code.
# RATE_LIMIT_AUTH=5

View File

@@ -90,7 +90,7 @@ pnpm exec playwright test
pnpm ui:add badge # shadcn/Base UI component into packages/ui
```
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`.
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`. `pnpm dev:desktop` additionally self-isolates per worktree (its own renderer port + app name) automatically, independent of `.env.worktree`.
CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.

View File

@@ -150,6 +150,7 @@ The daemon auto-detects these AI CLIs on your PATH:
| Kimi | `kimi` | Moonshot coding agent |
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
| [Qoder CLI](https://docs.qoder.com/) | `qodercli` | Qoder ACP coding agent |
| [Trae](https://docs.trae.cn/cli) | `traecli` | ByteDance TRAE CLI (ACP via `traecli acp serve`) |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -223,6 +224,8 @@ Agent-specific overrides:
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
| `MULTICA_QODER_PATH` | Custom path to the `qodercli` binary |
| `MULTICA_QODER_MODEL` | Override the Qoder model used |
| `MULTICA_TRAECLI_PATH` | Custom path to the `traecli` binary |
| `MULTICA_TRAECLI_MODEL` | Override the Trae model used (a model id from your logged-in traecli catalog, e.g. `Doubao-Seed-2.1-Pro`) |
The daemon launches Qoder as `qodercli --yolo --acp`, matching Qoders ACP “bypass permissions” mode so tool runs do not block on interactive approval in headless runs.

View File

@@ -489,6 +489,25 @@ VITE_API_URL=http://localhost:<backend-port>
VITE_WS_URL=ws://localhost:<backend-port>/ws
```
#### Running multiple worktrees side-by-side
`pnpm dev:desktop` auto-isolates a worktree so several worktrees can run their
own desktop dev instance at once — no extra setup. From a linked worktree it
derives, from the worktree path (same `cksum % 1000` offset as the backend /
frontend ports in `.env.worktree`):
- `DESKTOP_RENDERER_PORT` = `5174 + offset` — its own Vite dev server (`5174`
base leaves `5173` for the primary checkout, even when `offset` is `0`)
- `DESKTOP_APP_SUFFIX` = `<folder>-<offset>` — its own single-instance lock /
`userData`, and an app named `Multica Canary <folder>-<offset>` so it is
distinguishable in Cmd+Tab. The offset keeps it unique across worktrees that
share a folder name at different paths.
The primary checkout is left untouched (`5173`, `Multica Canary`). Set either
env var explicitly to override the derived value. Which backend each instance
talks to is still controlled only by `apps/desktop/.env*` above — point each
worktree's desktop at its own backend to also isolate the daemon profile.
### Isolation Guarantee
Nothing in this flow touches the system-installed `multica` or the default

View File

@@ -37,6 +37,29 @@ define REQUIRE_ENV
fi
endef
# Self-hosting requires the Docker Compose CLI plugin (`docker compose`).
# The self-host compose files use compose-spec syntax (top-level `name:`, no
# `version:`) that the legacy v1 `docker-compose` standalone cannot parse, so we
# fail early with an actionable message instead of a cryptic CLI parse error
# (e.g. "unknown shorthand flag: 'f' in -f") when the plugin is missing or v1.
# Keep the message short and OS-agnostic: per-OS install steps belong in docs.
define REQUIRE_COMPOSE
@if ! compose_version=$$($(COMPOSE) version --short 2>/dev/null); then \
echo "Docker Compose ('docker compose') was not found."; \
echo "Self-hosting requires the Compose CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
fi; \
case "$$compose_version" in \
1.*|v1.*) \
echo "'$(COMPOSE)' is legacy Docker Compose v1 ($$compose_version)."; \
echo "Self-hosting requires the Compose CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
;; \
esac
endef
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL := help
@@ -54,6 +77,7 @@ makehelp: help ## Alias for `make help`
##@ Self-hosting
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
$(REQUIRE_COMPOSE)
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -71,7 +95,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
@if ! $(COMPOSE) -f docker-compose.selfhost.yml pull; then \
echo ""; \
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
echo "If this is before the first GHCR release, build from the current checkout:"; \
@@ -79,7 +103,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
exit 1; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d
$(COMPOSE) -f docker-compose.selfhost.yml up -d
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -105,10 +129,11 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
fi
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
$(REQUIRE_COMPOSE)
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -126,7 +151,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@echo "==> Building Multica from the current checkout..."
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
$(COMPOSE) -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -152,12 +177,13 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
fi
selfhost-stop: ## Stop the self-hosted Docker Compose stack
$(REQUIRE_COMPOSE)
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
$(COMPOSE) -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------

View File

@@ -31,7 +31,7 @@ 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**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, **Kiro CLI**, and **Qoder CLI**.
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**, **Kiro CLI**, **Qoder CLI**, and **Trae**.
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
@@ -115,7 +115,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`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`, `qodercli`) 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`, `agy`, `qodercli`, `traecli`) on your PATH.
### 2. Verify your runtime
@@ -125,7 +125,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, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, Antigravity, or Qoder CLI). 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, Kiro CLI, Antigravity, Qoder CLI, or Trae). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -165,8 +165,8 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
┌──────┴───────┐
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
OpenCode, OpenClaw, Hermes, Gemini,
Pi, Cursor Agent, Kimi, Kiro CLI, Qoder CLI)
OpenCode, OpenClaw, Hermes, Gemini, Pi,
Cursor Agent, Kimi, Kiro CLI, Qoder CLI, Trae CLI)
```
| Layer | Stack |
@@ -174,7 +174,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, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Qoder CLI |
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, Qoder CLI, or Trae CLI |
## Development

View File

@@ -19,8 +19,8 @@
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
"dev": "node scripts/dev.mjs",
"dev:staging": "node scripts/dev.mjs --mode staging",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",

View File

@@ -9,6 +9,10 @@
// matches. The patch is isolated to this worktree's node_modules — we
// unlink the file before rewriting so we never mutate a pnpm-store inode
// shared with another project.
//
// In a worktree, scripts/dev.mjs sets DESKTOP_APP_SUFFIX so the name becomes
// "Multica Canary <suffix>" — distinguishable in Cmd+Tab and matching the app
// name src/main/index.ts derives from the same env var.
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
@@ -17,7 +21,9 @@ import { resolve } from "node:path";
if (process.platform !== "darwin") process.exit(0);
const DESIRED_NAME = "Multica Canary";
const DESIRED_NAME = process.env.DESKTOP_APP_SUFFIX
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
: "Multica Canary";
const require = createRequire(import.meta.url);
// `require('electron')` returns the path to the executable

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env node
// Dev launcher for `pnpm dev:desktop`.
//
// Derives per-worktree isolation env (renderer port + app name) so multiple
// worktrees can run `pnpm dev:desktop` side-by-side, then runs the same chain
// as before — bundle the CLI, brand the dev Electron, start electron-vite —
// inheriting the augmented env. A plain `&&` chain in package.json can't do
// this: each `&&` step is its own process, so an env tweak in step 1 wouldn't
// reach electron-vite in step 3. Args (e.g. `--mode staging`) pass through to
// electron-vite.
import { spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
applyWorktreeDevEnv,
repoRootFromScriptDir,
} from "./worktree-dev-env.mjs";
const here = dirname(fileURLToPath(import.meta.url));
applyWorktreeDevEnv(process.env, {
root: repoRootFromScriptDir(here),
log: true,
});
function run(command, args, { shell = false } = {}) {
const result = spawnSync(command, args, {
stdio: "inherit",
env: process.env,
shell,
});
if (result.error) {
console.error(`[dev:desktop] failed to run ${command}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) process.exit(result.status ?? 1);
}
const node = process.execPath;
run(node, [join(here, "bundle-cli.mjs")]);
run(node, [join(here, "brand-dev-electron.mjs")]);
const isWin = process.platform === "win32";
const electronVite = join(
here,
"..",
"node_modules",
".bin",
isWin ? "electron-vite.cmd" : "electron-vite",
);
run(electronVite, ["dev", ...process.argv.slice(2)], { shell: isWin });

View File

@@ -0,0 +1,116 @@
// Per-worktree dev isolation for `pnpm dev:desktop`.
//
// Two `pnpm dev:desktop` instances from two different git worktrees collide on
// the renderer Vite port (5173) and the single-instance lock / userData dir
// (keyed by the app name "Multica Canary"). The env hooks to override both
// already exist — electron.vite.config.ts reads DESKTOP_RENDERER_PORT and
// src/main/index.ts reads DESKTOP_APP_SUFFIX — but nothing derives unique
// values per worktree. This module does, mirroring the offset scheme that
// scripts/init-worktree-env.sh already uses for backend/frontend ports.
//
// Backend targeting is deliberately NOT touched here: which backend the desktop
// connects to stays driven by apps/desktop/.env* (VITE_API_URL / VITE_WS_URL),
// exactly as documented. This module only adds the two knobs needed for two
// Electron processes to coexist.
import { statSync } from "node:fs";
import { basename, join } from "node:path";
// Worktree renderer ports start at 5174 so they never reuse 5173 — the primary
// checkout's default — even when a worktree's offset is 0 (e.g. POSIX cksum of
// "/tmp/multica-3494" is 1189739000, and 1189739000 % 1000 === 0). Range 51746173.
const RENDERER_PORT_BASE = 5174;
const OFFSET_MODULO = 1000;
// POSIX cksum (CRC-32), kept byte-compatible with `cksum(1)` so the offset
// matches scripts/init-worktree-env.sh — a worktree's backend (18080+offset),
// frontend (13000+offset) and desktop renderer (5174+offset) ports all share
// one offset. Verified against coreutils: cksum of "/tmp/foo" → 427878967.
function cksumTable() {
const table = new Uint32Array(256);
const POLY = 0x04c11db7;
for (let i = 0; i < 256; i++) {
let crc = i << 24;
for (let bit = 0; bit < 8; bit++) {
crc = crc & 0x80000000 ? (crc << 1) ^ POLY : crc << 1;
}
table[i] = crc >>> 0;
}
return table;
}
const TABLE = cksumTable();
export function cksum(buf) {
let crc = 0;
for (const byte of buf) {
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ byte) & 0xff]) >>> 0;
}
// POSIX appends the byte length, least-significant byte first.
let len = buf.length;
while (len > 0) {
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ (len & 0xff)) & 0xff]) >>> 0;
len = Math.floor(len / 256);
}
return (~crc) >>> 0;
}
export function offsetForPath(path) {
return cksum(Buffer.from(path)) % OFFSET_MODULO;
}
export function rendererPortForPath(path) {
return RENDERER_PORT_BASE + offsetForPath(path);
}
// Worktree → a readable, unique, filesystem-safe suffix "<folder>-<offset>".
// The dev app then shows e.g. "Multica Canary mul-3724-194" in Cmd+Tab and gets
// its own userData / single-instance lock under that name. The offset is what
// makes the lock unique: the folder name alone collides for worktrees that share
// a basename at different paths (e.g. /a/multica vs /b/multica) or whose names
// slug to the same fallback — those would share one lock and the second Electron
// would still be blocked.
export function appSuffixForPath(path) {
const slug =
basename(path)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "worktree";
return `${slug}-${offsetForPath(path)}`;
}
// A linked git worktree has a `.git` FILE (a "gitdir:" pointer); the primary
// checkout has a `.git` DIRECTORY. We only auto-isolate linked worktrees, so
// the primary checkout keeps the unchanged 5173 / "Multica Canary" defaults.
export function isLinkedWorktree(root) {
try {
return statSync(join(root, ".git")).isFile();
} catch {
return false;
}
}
// scripts live at <root>/apps/desktop/scripts
export function repoRootFromScriptDir(scriptDir) {
return join(scriptDir, "..", "..", "..");
}
// Populate DESKTOP_RENDERER_PORT / DESKTOP_APP_SUFFIX on `env` for a worktree
// checkout, without overriding values the caller set explicitly. Returns `env`.
export function applyWorktreeDevEnv(env, { root, log = false } = {}) {
const hasPort = Boolean(env.DESKTOP_RENDERER_PORT);
const hasSuffix = Boolean(env.DESKTOP_APP_SUFFIX);
if (hasPort && hasSuffix) return env; // explicit overrides win outright
if (!isLinkedWorktree(root)) return env; // primary checkout → keep defaults
if (!hasPort) env.DESKTOP_RENDERER_PORT = String(rendererPortForPath(root));
if (!hasSuffix) env.DESKTOP_APP_SUFFIX = appSuffixForPath(root);
if (log) {
console.log(
`[dev:desktop] worktree isolation → renderer port ${env.DESKTOP_RENDERER_PORT}, ` +
`app "Multica Canary ${env.DESKTOP_APP_SUFFIX}"`,
);
}
return env;
}

View File

@@ -0,0 +1,101 @@
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
appSuffixForPath,
applyWorktreeDevEnv,
cksum,
offsetForPath,
rendererPortForPath,
} from "./worktree-dev-env.mjs";
const cleanups = [];
afterEach(() => {
while (cleanups.length) cleanups.pop()();
});
function tmpRoot(kind /* "file" | "dir" | "none" */) {
const root = mkdtempSync(join(tmpdir(), "wt-"));
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
if (kind === "file") writeFileSync(join(root, ".git"), "gitdir: /elsewhere\n");
else if (kind === "dir") mkdirSync(join(root, ".git"));
return root;
}
describe("worktree-dev-env", () => {
it("cksum is byte-compatible with coreutils cksum(1)", () => {
// `printf '%s' "/tmp/foo" | cksum` → 427878967 8
expect(cksum(Buffer.from("/tmp/foo"))).toBe(427878967);
// `printf '' | cksum` → 4294967295 0
expect(cksum(Buffer.from(""))).toBe(4294967295);
});
it("derives the offset from the path, mod 1000", () => {
expect(offsetForPath("/tmp/foo")).toBe(427878967 % 1000);
});
it("renderer port is 5174 + offset (5173 reserved for the primary checkout)", () => {
expect(rendererPortForPath("/tmp/foo")).toBe(5174 + (427878967 % 1000));
});
it("never reuses 5173 even when the offset is 0", () => {
// POSIX cksum("/tmp/multica-3494") === 1189739000, % 1000 === 0
expect(offsetForPath("/tmp/multica-3494")).toBe(0);
expect(rendererPortForPath("/tmp/multica-3494")).toBe(5174);
expect(rendererPortForPath("/tmp/multica-3494")).not.toBe(5173);
});
it("suffix is '<folder>-<offset>' so it stays recognizable and unique", () => {
expect(appSuffixForPath("/work/MUL-3724_Desktop")).toBe(
`mul-3724-desktop-${offsetForPath("/work/MUL-3724_Desktop")}`,
);
expect(appSuffixForPath("/work/feat/some thing")).toBe(
`some-thing-${offsetForPath("/work/feat/some thing")}`,
);
// empty/non-ascii slug falls back to "worktree", still disambiguated by offset
expect(appSuffixForPath("/work/___")).toBe(`worktree-${offsetForPath("/work/___")}`);
});
it("disambiguates worktrees that share a folder name at different paths", () => {
// Same basename "multica", different parent dirs → different offsets/suffixes,
// so each gets its own single-instance lock.
expect(offsetForPath("/tmp/a/multica")).not.toBe(offsetForPath("/tmp/b/multica"));
expect(appSuffixForPath("/tmp/a/multica")).not.toBe(
appSuffixForPath("/tmp/b/multica"),
);
});
it("auto-isolates a linked worktree (.git is a file)", () => {
const root = tmpRoot("file");
const env = {};
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe(String(rendererPortForPath(root)));
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
});
it("leaves the primary checkout untouched (.git is a dir)", () => {
const root = tmpRoot("dir");
const env = {};
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBeUndefined();
expect(env.DESKTOP_APP_SUFFIX).toBeUndefined();
});
it("respects explicit env overrides", () => {
const root = tmpRoot("file");
const env = { DESKTOP_RENDERER_PORT: "9999", DESKTOP_APP_SUFFIX: "manual" };
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
expect(env.DESKTOP_APP_SUFFIX).toBe("manual");
});
it("fills only the missing knob when one is set explicitly", () => {
const root = tmpRoot("file");
const env = { DESKTOP_RENDERER_PORT: "9999" };
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
});
});

View File

@@ -11,6 +11,7 @@ import type { Metadata } from "next";
import { docsAlternates } from "@/lib/site";
import { i18n, type Lang } from "@/lib/i18n";
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
import { VideoEmbed } from "@/components/video-embed";
import { docsSlugStaticParams } from "@/lib/static-params";
function asLang(lang: string): Lang {
@@ -35,7 +36,9 @@ export default async function Page(props: {
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<DocsLocaleProvider lang={lang}>
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
<MDX
components={{ ...defaultMdxComponents, a: LocaleLink, VideoEmbed }}
/>
</DocsLocaleProvider>
</DocsBody>
</DocsPage>

View File

@@ -5,6 +5,7 @@ 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 { VideoEmbed } from "@/components/video-embed";
import { i18n, type Lang } from "@/lib/i18n";
import { homeCopy } from "@/lib/translations";
import { docsAlternates } from "@/lib/site";
@@ -62,6 +63,7 @@ export default async function Page({
NumberedCard,
NumberedSteps,
Step,
VideoEmbed,
}}
/>
</DocsLocaleProvider>

View File

@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Play } from "lucide-react";
/**
* VideoEmbed — provider-agnostic, click-to-load video embed for docs MDX.
*
* Renders a lightweight facade (no third-party iframe on first paint) and only
* mounts the real player after a user click, so the docs first paint never
* pays for an external player or its trackers. `provider` is abstracted so a
* future English-docs YouTube embed is a one-line MDX change, not a second
* component.
*
* Usage in MDX (registered in the docs MDX components map):
* <VideoEmbed provider="bilibili" id="BV1cv7Y6gEg7" title="Multica 介绍视频" />
*/
type Provider = "bilibili" | "youtube";
interface ProviderConfig {
/** Embeddable player URL. Autoplay is only requested after a user gesture. */
embedUrl: (id: string, autoplay: boolean) => string;
/** Canonical watch page — the load-failure / slow-network fallback link. */
watchUrl: (id: string) => string;
/** Human label for the fallback link ("在 Bilibili 观看"). */
siteName: string;
/** Validates the id shape so a typo renders a notice, not a broken frame. */
isValidId: (id: string) => boolean;
}
const PROVIDERS: Record<Provider, ProviderConfig> = {
bilibili: {
embedUrl: (id, autoplay) =>
`https://player.bilibili.com/player.html?bvid=${id}&autoplay=${autoplay ? 1 : 0}&high_quality=1&danmaku=0`,
watchUrl: (id) => `https://www.bilibili.com/video/${id}/`,
siteName: "Bilibili",
isValidId: (id) => /^BV[0-9A-Za-z]+$/.test(id),
},
// Reserved for a future English-docs YouTube embed. Not wired into any page
// yet, but kept here so the second provider is config, not a new component.
youtube: {
embedUrl: (id, autoplay) =>
`https://www.youtube-nocookie.com/embed/${id}?autoplay=${autoplay ? 1 : 0}&rel=0`,
watchUrl: (id) => `https://www.youtube.com/watch?v=${id}`,
siteName: "YouTube",
isValidId: (id) => /^[0-9A-Za-z_-]{11}$/.test(id),
},
};
export function VideoEmbed({
provider = "bilibili",
id,
title,
}: {
provider?: Provider;
id: string;
title?: string;
}) {
const [active, setActive] = useState(false);
const config = PROVIDERS[provider];
// Bad / missing id → a calm inline notice, never a broken or blank iframe.
if (!config || !id || !config.isValidId(id)) {
return (
<div className="not-prose my-7 rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{title ? `${title}` : ""}
</div>
);
}
const watchUrl = config.watchUrl(id);
const label = title ?? "观看视频";
return (
<figure className="not-prose my-7">
<div className="relative aspect-video w-full overflow-hidden rounded-lg border border-border bg-muted/40">
{active ? (
<iframe
src={config.embedUrl(id, true)}
title={label}
loading="lazy"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
allowFullScreen
className="absolute inset-0 size-full"
/>
) : (
<button
type="button"
onClick={() => setActive(true)}
aria-label={`播放:${label}`}
className="group absolute inset-0 flex size-full flex-col items-center justify-center gap-3 bg-gradient-to-b from-muted/20 to-muted/60 transition-colors hover:from-muted/30 hover:to-muted/70"
>
<span className="flex size-16 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)] shadow-lg transition-transform group-hover:scale-105">
<Play className="size-7 translate-x-0.5 fill-current" />
</span>
<span className="px-6 text-center text-sm font-medium text-foreground">
{label}
</span>
</button>
)}
</div>
<figcaption className="mt-2 text-xs text-muted-foreground">
<a
href={watchUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
{config.siteName}
</a>
</figcaption>
</figure>
);
}

View File

@@ -21,7 +21,7 @@ The form has only two required fields: **name** (unique within the workspace) an
## Pick an AI coding tool
Each runtime is backed by a specific AI coding tool. Multica supports 12 of them. The most common choices:
Each runtime is backed by a specific AI coding tool. Multica supports 14 of them. The most common choices:
| Tool | Good for |
|---|---|
@@ -123,5 +123,5 @@ 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 12 tools
- [AI coding tools comparison](/providers) — full capability matrix across all 14 tools
- [Assigning issues to agents](/assigning-issues) — put your new agent to work

View File

@@ -0,0 +1,93 @@
---
title: Chat 連携channels
description: Multica がどのようにエージェントをチャットプラットフォームに接続するか——1 つのチャンネルエンジンと、Lark飞书および Slack 向けのプラットフォーム別アダプター——受信パイプライン、セッション、認可までを解説します。
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
**チャンネル**は、Multica の[エージェント](/agents)をチャットプラットフォームに接続し、チームが普段やり取りしている場所でそのまま使えるようにします。現在チャンネルは 2 つあり——[Lark飞书](/lark-bot-integration)と [Slack](/slack-bot-integration)——どちらも**同じエンジン**で動いています。プラットフォームに依存しないコアと、薄いプラットフォーム別アダプターの組み合わせです。プラットフォームを追加するということは「アダプターを実装する」ことであり、「パイプラインを作り直す」ことではありません。
**インストール**は、それらを結びつける単位です。1 つの Bot が 1 つの `(workspace, agent)` に紐づきます。受信メッセージはまずインストールにルーティングされ、その後共有パイプラインを通り、エージェントの返信は同じチャットに送り返されます。
## アーキテクチャ
<Mermaid chart={`
flowchart LR
subgraph P["チャットプラットフォーム"]
LK["Lark / 飞书"]
SL["Slack"]
end
subgraph ENG["チャンネルエンジン(プラットフォーム非依存)"]
direction TB
SUP["Supervisor<br/>インストールごとに 1 本のライブ接続"]
ROU["Router パイプライン:<br/>route → dedup → auth → session → trigger"]
end
LK -->|長時間接続| SUP
SL -->|Socket Mode| SUP
SUP -->|生イベント| ADP["プラットフォーム別アダプター<br/>変換 + ResolverSet"]
ADP --> ROU
ROU -->|エージェントタスク| RUN["デーモンがエージェントを実行"]
RUN -->|返信| OUT["プラットフォーム別の送信<br/>bot token → プラットフォーム API"]
OUT --> P
`} />
## 受信パイプライン(共通)
すべての受信メッセージは——Lark でも Slack でも——エンジンの `Router` 内で同じ順序のステップを通ります。プラットフォームアダプターが供給するのはプラットフォーム別の部品(`ResolverSet`)だけで、ポリシーはエンジンの中にあります。
1. **インストールへのルーティング** —— イベントを `channel_installation`(→ ワークスペース + エージェントに対応づけます。Lark は `app_id` でルーティングし、Slack はイベントに含まれる app id でルーティングします。
2. **宛先フィルター** —— グループ/チャンネルでは、**Bot を @ メンション**したメッセージだけが先へ進みます。アイドル状態のグループの雑談は破棄されます(読み取られません)。
3. **重複排除dedup** —— 2 フェーズの `(installation, message_id)` クレームにより、サーバーのレプリカをまたいでも厳密に 1 回だけ処理されることを保証します。
4. **アイデンティティ + 認可** —— 送信者のプラットフォームユーザー id を Multica ユーザーに解決し([アカウントの紐づけ](#認可))、その上でワークスペースのメンバーシップを再チェックします。紐づいていない送信者には「アカウントを紐づける」プロンプトが返され、メンバーでない場合は破棄されます。
5. **セッション** —— この会話に対応する[chat セッション](/chat)を見つけるか作成し、メッセージを追加します([セッション](#セッションとコンテキスト)を参照)。
6. **トリガー** —— エージェントの[タスク](/tasks)をエンキューします。[デーモン](/daemon-runtimes)がエージェントを実行し、返信がチャットに送り返されます。
## セッションとコンテキスト
エージェントのコンテキストは、**chat セッションのトランスクリプト**——そのセッションに時間をかけて取り込まれてきたメッセージ——です。このトランスクリプトのモデルは共通です(すべてのチャンネルで共有されます)。プラットフォームごとに異なるのは、アダプターが組み立てる**セッション分離キー**です。
| プラットフォーム | 分離キー | 効果 |
|---|---|---|
| **Lark / 飞书** | チャット id | チャット/グループごとに 1 セッション——同じチャット内の連続したやり取りが 1 つのトランスクリプトに蓄積されます(複数ターンの記憶)。 |
| **Slack** | DM: チャンネル/チャンネル: `channel + thread root` | 各 DM が 1 セッション。**各 @bot スレッドがそれぞれ独立したセッション**になるので、同じチャンネル内の 2 つのスレッドが混ざりません。 |
<Callout type="info">
グループでは、**Bot を @ メンション**したメッセージだけが取り込まれます。どちらのチャンネルも、現時点ではチャンネルの他の(@ されていない)メッセージや過去ログを読まないため、エージェントは自分が宛先になっていないメッセージを見ることはありません。前後の履歴をコンテキストとして取得することは、今後の拡張として計画されています。
</Callout>
## 認可
共有グループ内で Bot を守るために、2 つの独立したゲートがあります——どちらもエンジンであらゆるメッセージに対し、Lark と Slack で同一に適用されます。
- **アカウントの紐づけ(認証)** —— 送信者のプラットフォームユーザー id が Multica ユーザーにリンクされている必要があります。誰かが初めて Bot にメッセージを送ると、**自分自身の** Multica アカウントにアイデンティティを紐づけるための使い切りリンクを受け取ります。それまではエージェントは実行されません。
- **ワークスペースのメンバーシップ(認可)** —— 紐づいた Multica ユーザーが、そのインストールのワークスペースのメンバーである必要があり、これはメッセージごとに再チェックされます。メンバーでない場合は黙って破棄されます。
そのため、Bot を公開チャンネルに追加しても安全です。アイデンティティを紐づけたワークスペースメンバーだけがエージェントを動かせ、各送信者は独立してチェックされます。ユーザー向けのプロンプトについては、各プラットフォームのページを参照してください。
## 2 つのチャンネル
<Callout type="info">
**Lark飞书 — スキャンしてインストール。** ワークスペースの admin が Lark アプリで QR をスキャンするだけでエージェントを紐づけられます。開発者コンソールでの操作は不要です。エージェントごとに 1 つの Bot。[Lark Bot 連携](/lark-bot-integration)を参照してください。
</Callout>
<Callout type="info">
**Slack — 自分のアプリを持ち込む。** ワークスペースの admin が Slack アプリを作成し、自分の Slack ワークスペースにインストールして、その bot token + app-level token を Multica に貼り付けます。エージェントごとに専用の Slack アプリを持つため、1 つの Slack ワークスペース内で複数のエージェントがそれぞれ異なる Bot を持てます。マニフェストと手順は [Slack Bot 連携](/slack-bot-integration)を参照してください。
</Callout>
## セルフホスト
各チャンネルは、**保存時の暗号化キーを設定するまでオフ**です(このキーは、各 Bot のトークンがデータベースに触れる前にそれを暗号化します)。
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
```
Multica Cloud では両方ともすでに設定済みです。完全なリファレンスは[環境変数](/environment-variables)を参照してください。
## 次に
- [Lark Bot 連携](/lark-bot-integration) — スキャンしてインストール、DM / @ メンション / `/issue`
- [Slack Bot 連携](/slack-bot-integration) — 自分のアプリを持ち込むセットアップ(マニフェスト + トークン)、エージェントごとの Bot
- [エージェント](/agents) · [Chat](/chat) · [タスク](/tasks)

View File

@@ -0,0 +1,93 @@
---
title: Chat 연동 (channels)
description: Multica가 에이전트를 채팅 플랫폼에 어떻게 연결하는지 — 하나의 channel 엔진과 Lark(飞书) 및 Slack을 위한 플랫폼별 어댑터 — 인바운드 파이프라인, 세션, 권한을 다룹니다.
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
**channel**은 Multica [에이전트](/agents)를 채팅 플랫폼에 연결하여, 팀이 이미 대화하고 있는 곳에서 그 에이전트와 함께 일할 수 있게 합니다. 현재 두 개의 channel이 있습니다 — [Lark (飞书)](/lark-bot-integration)와 [Slack](/slack-bot-integration) — 그리고 둘 다 **같은 엔진** 위에서 동작합니다: 플랫폼 중립적인 코어에 얇은 플랫폼별 어댑터가 더해진 구조입니다. 플랫폼을 추가하는 일은 "어댑터를 구현하는 것"이지, "파이프라인을 다시 만드는 것"이 아닙니다.
**installation**은 이 모든 것을 하나로 묶는 단위입니다: 하나의 봇이 하나의 `(workspace, agent)`에 바인딩됩니다. 인바운드 메시지는 installation으로 라우팅된 다음 공유 파이프라인을 거치며, 에이전트의 답변은 동일한 채팅으로 돌아갑니다.
## 아키텍처
<Mermaid chart={`
flowchart LR
subgraph P["채팅 플랫폼"]
LK["Lark / 飞书"]
SL["Slack"]
end
subgraph ENG["Channel 엔진 (플랫폼 중립적)"]
direction TB
SUP["Supervisor<br/>installation당 하나의 활성 연결"]
ROU["Router 파이프라인:<br/>route → dedup → auth → session → trigger"]
end
LK -->|long connection| SUP
SL -->|Socket Mode| SUP
SUP -->|raw event| ADP["플랫폼별 어댑터<br/>변환 + ResolverSet"]
ADP --> ROU
ROU -->|agent task| RUN["Daemon이 에이전트를 실행"]
RUN -->|reply| OUT["플랫폼별 아웃바운드<br/>(bot token → platform API)"]
OUT --> P
`} />
## 인바운드 파이프라인 (공통)
모든 인바운드 메시지는 — Lark든 Slack이든 — 엔진의 `Router`에서 동일하게 정해진 순서의 단계를 거칩니다. 플랫폼 어댑터는 플랫폼별 조각(`ResolverSet`)만 공급하며, 정책은 엔진 안에 있습니다.
1. **Route to installation** — 이벤트를 `channel_installation`(→ workspace + agent)에 매핑합니다. Lark는 `app_id`로 라우팅하고, Slack은 이벤트에 실린 app id로 라우팅합니다.
2. **Addressing filter** — 그룹/채널에서는 **봇을 @로 멘션한** 메시지만 계속 진행되며, 한가한 그룹 잡담은 폐기됩니다(읽지 않음).
3. **Dedup** — 두 단계로 이루어진 `(installation, message_id)` 클레임이 서버 레플리카가 여러 개여도 정확히 한 번만 처리됨을 보장합니다.
4. **Identity + authorization** — 보낸 사람의 플랫폼 사용자 id를 Multica 사용자([계정 바인딩](#권한))로 해석한 다음, 워크스페이스 멤버십을 다시 확인합니다. 바인딩되지 않은 발신자에게는 "계정을 연결하세요" 안내가 표시되고, 멤버가 아닌 사람은 폐기됩니다.
5. **Session** — 이 대화에 대한 [chat 세션](/chat)을 찾거나 생성하고 메시지를 추가합니다([세션](#세션과-컨텍스트) 참조).
6. **Trigger** — 에이전트 [task](/tasks)를 큐에 넣습니다. [daemon](/daemon-runtimes)이 에이전트를 실행하고 그 답변이 채팅으로 돌아갑니다.
## 세션과 컨텍스트
에이전트의 컨텍스트는 **chat 세션 트랜스크립트**입니다 — 시간이 지나며 그 세션에 수집된 메시지들입니다. 이 트랜스크립트 모델은 공통(모든 channel이 공유)입니다. 플랫폼마다 다른 것은 어댑터가 구성하는 **세션 격리 키**입니다:
| 플랫폼 | 격리 키 | 효과 |
|---|---|---|
| **Lark / 飞书** | 채팅 id | 채팅/그룹당 하나의 세션 — 같은 채팅에서의 연속된 턴이 하나의 트랜스크립트로 쌓입니다(멀티턴 메모리). |
| **Slack** | DM: 채널; 채널: `channel + thread root` | 각 DM이 하나의 세션이고, **각 @bot 스레드가 자체 세션**이므로, 한 채널의 두 스레드는 섞이지 않습니다. |
<Callout type="info">
그룹에서는 **봇을 @로 멘션한** 메시지만 수집됩니다. 어느 channel도 현재 채널의 다른(멘션되지 않은) 메시지나 스크롤백을 읽지 않으므로, 에이전트는 자신이 호출되지 않은 메시지를 보지 못합니다. 주변 기록을 컨텍스트로 가져오는 기능은 향후 개선 사항으로 계획되어 있습니다.
</Callout>
## 권한
공유 그룹에서 봇을 보호하는 두 개의 독립적인 관문이 있으며 — 둘 다 모든 메시지에 대해 엔진에서, Lark와 Slack에 동일하게 적용됩니다:
- **계정 바인딩(인증)** — 보낸 사람의 플랫폼 사용자 id가 Multica 사용자에 연결되어 있어야 합니다. 누군가 봇에게 처음 메시지를 보내면 **자기 자신의** Multica 계정에 신원을 바인딩하는 일회용 링크를 받으며, 그 전까지는 어떤 에이전트도 실행되지 않습니다.
- **워크스페이스 멤버십(권한)** — 바인딩된 Multica 사용자는 installation의 워크스페이스 멤버여야 하며, 이는 모든 메시지마다 다시 확인됩니다. 멤버가 아닌 사람은 조용히 폐기됩니다.
따라서 공개 채널에 봇을 추가해도 안전합니다: 신원을 바인딩한 워크스페이스 멤버만 에이전트를 움직일 수 있고, 각 발신자는 독립적으로 확인됩니다. 사용자에게 표시되는 안내는 플랫폼별 페이지를 참고하세요.
## 두 개의 channel
<Callout type="info">
**Lark (飞书) — 스캔하여 설치.** 워크스페이스 admin이 Lark 앱으로 QR을 스캔하여 에이전트를 바인딩합니다. 개발자 콘솔 작업이 없습니다. 에이전트당 하나의 Bot. [Lark Bot 연동](/lark-bot-integration)을 참고하세요.
</Callout>
<Callout type="info">
**Slack — 자체 앱 사용.** 워크스페이스 admin이 Slack 앱을 만들고, 자신의 Slack 워크스페이스에 설치한 다음, bot token과 app-level token을 Multica에 붙여넣습니다. 각 에이전트가 자체 Slack 앱을 갖기 때문에, 하나의 Slack 워크스페이스에서 여러 에이전트가 각각 별개의 봇을 가질 수 있습니다. 매니페스트와 단계별 설정은 [Slack Bot 연동](/slack-bot-integration)을 참고하세요.
</Callout>
## 자체 호스팅
각 channel은 **at-rest 암호화 키를 설정하기 전까지 꺼져 있습니다**(이 키는 각 봇의 토큰이 데이터베이스에 닿기 전에 암호화합니다):
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
```
Multica Cloud에서는 둘 다 이미 구성되어 있습니다. 전체 참조는 [환경 변수](/environment-variables)를 참고하세요.
## 다음
- [Lark Bot 연동](/lark-bot-integration) — 스캔하여 설치, DM / @-멘션 / `/issue`
- [Slack Bot 연동](/slack-bot-integration) — 자체 앱 사용 설정(매니페스트 + 토큰), 에이전트별 봇
- [에이전트](/agents) · [Chat](/chat) · [Tasks](/tasks)

View File

@@ -0,0 +1,93 @@
---
title: Chat integrations (channels)
description: How Multica connects agents to chat platforms — one channel engine, per-platform adapters for Lark (飞书) and Slack — covering the inbound pipeline, sessions, and authorization.
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
A **channel** connects a Multica [agent](/agents) to a chat platform so your team can work with it where they already talk. Today there are two channels — [Lark (飞书)](/lark-bot-integration) and [Slack](/slack-bot-integration) — and both run on the **same engine**: a platform-neutral core plus a thin per-platform adapter. Adding a platform is "implement the adapter," not "rebuild the pipeline."
An **installation** is the unit that ties it together: one bot bound to one `(workspace, agent)`. Inbound messages are routed to an installation, then through a shared pipeline; the agent's reply is sent back to the same chat.
## Architecture
<Mermaid chart={`
flowchart LR
subgraph P["Chat platforms"]
LK["Lark / 飞书"]
SL["Slack"]
end
subgraph ENG["Channel engine (platform-neutral)"]
direction TB
SUP["Supervisor<br/>one live connection per installation"]
ROU["Router pipeline:<br/>route → dedup → auth → session → trigger"]
end
LK -->|long connection| SUP
SL -->|Socket Mode| SUP
SUP -->|raw event| ADP["Per-platform adapter<br/>translate + ResolverSet"]
ADP --> ROU
ROU -->|agent task| RUN["Daemon runs the agent"]
RUN -->|reply| OUT["Per-platform outbound<br/>(bot token → platform API)"]
OUT --> P
`} />
## The inbound pipeline (generic)
Every inbound message — Lark or Slack — runs through the same ordered steps in the engine `Router`. A platform adapter only supplies the per-platform pieces (the `ResolverSet`); the policy lives in the engine.
1. **Route to installation** — map the event to a `channel_installation` (→ workspace + agent). Lark routes by `app_id`; Slack routes by the app id carried on the event.
2. **Addressing filter** — in a group/channel, only messages that **@-mention the bot** continue; idle group chatter is dropped (not read).
3. **Dedup** — a two-phase `(installation, message_id)` claim guarantees exactly-once processing, even across server replicas.
4. **Identity + authorization** — resolve the sender's platform user id to a Multica user (the [account binding](#authorization)), then re-check workspace membership. Unbound senders get a "link your account" prompt; non-members are dropped.
5. **Session** — find or create a [chat session](/chat) for this conversation and append the message (see [Sessions](#sessions-and-context)).
6. **Trigger** — enqueue an agent [task](/tasks); a [daemon](/daemon-runtimes) runs the agent and the reply is sent back into the chat.
## Sessions and context
The agent's context is the **chat-session transcript** — the messages that have been ingested into that session over time. This transcript model is generic (shared by every channel). What differs per platform is the **session-isolation key** the adapter composes:
| Platform | Isolation key | Effect |
|---|---|---|
| **Lark / 飞书** | the chat id | One session per chat/group — consecutive turns in the same chat accumulate into one transcript (multi-turn memory). |
| **Slack** | DM: the channel; channel: `channel + thread root` | Each DM is one session; **each @bot thread is its own session**, so two threads in one channel don't mix. |
<Callout type="info">
In a group, only messages that **@-mention the bot** are ingested. Neither channel reads the channel's other (un-@'d) messages or scrollback today, so the agent won't see messages it wasn't addressed in. Fetching surrounding history as context is a planned enhancement.
</Callout>
## Authorization
Two independent gates protect a bot in a shared group — both enforced in the engine for every message, identically for Lark and Slack:
- **Account binding (authentication)** — the sender's platform user id must be linked to a Multica user. The first time someone messages the bot they get a one-time link to bind their identity to **their own** Multica account; until then no agent runs.
- **Workspace membership (authorization)** — the bound Multica user must be a member of the installation's workspace, re-checked on every message. Non-members are silently dropped.
So adding a bot to a public channel is safe: only workspace members who have bound their identity can drive the agent, and each sender is checked independently. See the per-platform pages for the user-facing prompts.
## The two channels
<Callout type="info">
**Lark (飞书) — scan to install.** A workspace admin binds an agent by scanning a QR with the Lark app; no developer console steps. One Bot per agent. See [Lark Bot integration](/lark-bot-integration).
</Callout>
<Callout type="info">
**Slack — bring your own app.** A workspace admin creates a Slack app, installs it to their Slack workspace, and pastes its bot token + app-level token into Multica. Each agent gets its own Slack app, so several agents can each have a distinct bot in one Slack workspace. See [Slack Bot integration](/slack-bot-integration) for the manifest and step-by-step setup.
</Callout>
## Self-host
Each channel is **off until you set its at-rest encryption key** (the key encrypts each bot's tokens before they touch the database):
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
```
On Multica Cloud both are already configured. See [Environment variables](/environment-variables) for the full reference.
## Next
- [Lark Bot integration](/lark-bot-integration) — scan-to-install, DM / @-mention / `/issue`
- [Slack Bot integration](/slack-bot-integration) — bring-your-own-app setup (manifest + tokens), per-agent bots
- [Agents](/agents) · [Chat](/chat) · [Tasks](/tasks)

View File

@@ -0,0 +1,93 @@
---
title: 聊天集成channels
description: Multica 如何把智能体接入聊天平台——一个统一的 channel 引擎加上针对飞书Lark和 Slack 的各平台适配器——涵盖入站流水线、会话与授权。
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
**channel** 把一个 Multica [智能体](/agents)接入聊天平台,团队就能在他们日常沟通的地方直接使用它。目前有两个 channel——[Lark飞书](/lark-bot-integration) 和 [Slack](/slack-bot-integration)——两者都跑在**同一个引擎**上:一个平台无关的内核,加上一层很薄的各平台适配器。新增一个平台是「实现适配器」,而不是「重建流水线」。
**安装installation** 是把这一切串起来的单元:一个 Bot 绑定到一个 `(workspace, agent)`。入站消息被路由到某个安装,再经过共享的流水线;智能体的回复会被发回同一个聊天里。
## 架构
<Mermaid chart={`
flowchart LR
subgraph P["聊天平台"]
LK["Lark / 飞书"]
SL["Slack"]
end
subgraph ENG["Channel 引擎(平台无关)"]
direction TB
SUP["Supervisor<br/>每个安装一条实时连接"]
ROU["路由流水线:<br/>路由 → 去重 → 鉴权 → 会话 → 触发"]
end
LK -->|长连接| SUP
SL -->|Socket Mode| SUP
SUP -->|原始事件| ADP["各平台适配器<br/>转换 + ResolverSet"]
ADP --> ROU
ROU -->|智能体任务| RUN["守护进程运行智能体"]
RUN -->|回复| OUT["各平台出站<br/>bot token → 平台 API"]
OUT --> P
`} />
## 入站流水线(通用)
每一条入站消息——无论来自 Lark 还是 Slack——都会走引擎 `Router` 里同一套有序步骤。平台适配器只提供各平台特有的部分(即 `ResolverSet`);策略本身住在引擎里。
1. **路由到安装** —— 把事件映射到一个 `channel_installation`(→ workspace + agent。Lark 按 `app_id` 路由Slack 按事件携带的 app id 路由。
2. **寻址过滤** —— 在群 / 频道里,只有 **@ 了 Bot** 的消息才会继续往下走;无关的群聊闲谈会被丢弃(不读取)。
3. **去重** —— 一个两阶段的 `(installation, message_id)` 认领机制保证恰好处理一次,即便跨多个服务器副本也成立。
4. **身份 + 授权** —— 把发送者的平台用户 id 解析成一个 Multica 用户(即[账号绑定](#账号绑定)),然后再次校验 workspace 成员身份。未绑定的发送者会收到一条「绑定你的账号」提示;非成员会被丢弃。
5. **会话** —— 为这段对话找到或创建一个 [chat 会话](/chat),并把消息追加进去(见[会话](#会话与上下文))。
6. **触发** —— 入队一个智能体[任务](/tasks);一个[守护进程](/daemon-runtimes)运行智能体,回复会被发回聊天里。
## 会话与上下文
智能体的上下文就是**这段 chat 会话的对话记录**——也就是随时间被纳入该会话的那些消息。这套对话记录模型是通用的(每个 channel 共用)。各平台不同的地方在于适配器拼出来的**会话隔离键**
| 平台 | 隔离键 | 效果 |
|---|---|---|
| **Lark / 飞书** | 聊天 id | 每个聊天 / 群一个会话——同一个聊天里连续的几轮会累积成一份对话记录(多轮记忆)。 |
| **Slack** | 私聊:频道;频道:`channel + thread root` | 每段私聊是一个会话;**每个 @bot 的 thread 是它自己的会话**,所以同一个频道里的两个 thread 不会混在一起。 |
<Callout type="info">
在群里,只有 **@ 了 Bot** 的消息才会被纳入。目前两个 channel 都不会读取频道里其他(没 @ 的)消息或历史滚动记录,所以智能体看不到那些没有点名它的消息。把周边历史作为上下文拉取进来,是计划中的增强功能。
</Callout>
## 账号绑定
在共享群里,有两道相互独立的关卡保护着 Bot——两者都在引擎里对每一条消息强制执行且 Lark 和 Slack 一视同仁:
- **账号绑定(认证)** —— 发送者的平台用户 id 必须关联到一个 Multica 用户。某人第一次给 Bot 发消息时,会拿到一个一次性链接,把自己的身份绑定到**他自己的** Multica 账号;在那之前不会有任何智能体运行。
- **Workspace 成员身份(授权)** —— 绑定后的 Multica 用户必须是该安装所属 workspace 的成员,每条消息都会重新校验。非成员会被静默丢弃。
所以把 Bot 加进一个公开频道是安全的:只有已绑定身份的 workspace 成员才能驱动智能体,而且每个发送者都会被独立校验。面向用户的提示文案请见各平台的页面。
## 两个 channel
<Callout type="info">
**Lark飞书—— 扫码安装。** workspace 管理员用飞书 App 扫一个二维码就能绑定一个智能体;无需任何开发者后台步骤。一个智能体一个 Bot。见 [Lark Bot 接入](/lark-bot-integration)。
</Callout>
<Callout type="info">
**Slack —— 自带应用。** workspace 管理员创建一个 Slack app把它安装到自己的 Slack workspace再把它的 bot token + app-level token 粘贴进 Multica。每个智能体都有自己的 Slack app所以多个智能体可以在同一个 Slack workspace 里各自拥有一个独立的 Bot。manifest 和分步设置见 [Slack Bot 接入](/slack-bot-integration)。
</Callout>
## 自部署
每个 channel 在**你设置好它的静态加密密钥之前都是关闭的**(这个密钥会在每个 Bot 的 token 落库之前对其加密):
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
```
在 Multica Cloud 上两者都已配置好。完整参考见[环境变量](/environment-variables)。
## 下一步
- [Lark Bot 接入](/lark-bot-integration) —— 扫码安装,私聊 / @ 提及 / `/issue`
- [Slack Bot 接入](/slack-bot-integration) —— 自带应用的设置manifest + token每个智能体一个 Bot
- [智能体](/agents) · [Chat](/chat) · [任务](/tasks)

View File

@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
このページは Multica Cloud を最初から最後まで案内します — **サインアップ → [CLI](/cli) のインストール → [デーモン](/daemon-runtimes)の起動 → [エージェント](/agents)の作成 → 最初の[タスク](/tasks)の割り当て**。約 5 分かかります。
前提条件は 1 つだけです: ローカルに [AI コーディングツール](/providers)[Antigravity](/providers#antigravity)、[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 つ、すでにインストールしておくこと。デーモンは起動時にこれらを自動検出し、1 つもなければ起動を拒否します。
前提条件は 1 つだけです: ローカルに [AI コーディングツール](/providers)[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) のいずれか)を少なくとも 1 つ、すでにインストールしておくこと。デーモンは起動時にこれらを自動検出し、1 つもなければ起動を拒否します。
## 1. アカウントを作成する

View File

@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
이 페이지는 Multica Cloud를 처음부터 끝까지 안내합니다 — **가입 → [CLI](/cli) 설치 → [데몬](/daemon-runtimes) 시작 → [에이전트](/agents) 생성 → 첫 [작업](/tasks) 할당**. 약 5분이 걸립니다.
전제 조건은 하나뿐입니다: 로컬에 [AI 코딩 도구](/providers)([Antigravity](/providers#antigravity), [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) 중 하나)를 이미 최소 하나는 설치해 두어야 합니다. 데몬은 시작할 때 이들을 자동으로 감지하며, 하나도 없으면 시작을 거부합니다.
전제 조건은 하나뿐입니다: 로컬에 [AI 코딩 도구](/providers)([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi) 중 하나)를 이미 최소 하나는 설치해 두어야 합니다. 데몬은 시작할 때 이들을 자동으로 감지하며, 하나도 없으면 시작을 거부합니다.
## 1. 계정 만들기

View File

@@ -7,7 +7,7 @@ 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 ([Antigravity](/providers#antigravity), [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.
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [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
@@ -114,6 +114,6 @@ The web UI updates in **real time** (via WebSocket) — no refresh needed.
- [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 12 tools
- [AI coding tools compared](/providers) — capability differences across the 14 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

@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)[Antigravity](/providers#antigravity)、[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))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
## 1. 注册账号

View File

@@ -21,7 +21,7 @@ multica daemon start
起動時にデーモンは 4 つのことを行います。
1. ログイン時に保存された認証情報を読み込みます
2. `PATH` にインストールされた AI コーディングツールを検出します(内蔵 12 種: [Antigravity](/providers#antigravity)、[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)
2. `PATH` にインストールされた AI コーディングツールを検出します(内蔵 12 種: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
3. 検出した各ツールに対するランタイムとともに、自身をサーバーに登録します
4. **3 秒ごと**に取得すべきタスクがないかポーリングし、**15 秒ごとにハートビートを送信**し続けます

View File

@@ -21,7 +21,7 @@ multica daemon start
시작 시 데몬은 네 가지 일을 합니다.
1. 로그인할 때 저장된 인증 정보를 읽습니다
2. `PATH`에 설치된 AI 코딩 도구를 감지합니다(내장 12종: [Antigravity](/providers#antigravity), [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))
2. `PATH`에 설치된 AI 코딩 도구를 감지합니다(내장 12종: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
3. 감지된 각 도구에 대한 런타임과 함께 자신을 서버에 등록합니다
4. **3초마다** 가져올 작업이 있는지 폴링하고, **15초마다 하트비트를 전송**합니다

View File

@@ -21,7 +21,7 @@ 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` (12 built-in: [Antigravity](/providers#antigravity), [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))
2. Detects AI coding tools installed on your `PATH` (14 built-in: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi), [Qoder](/providers#qoder), [Trae CLI](/providers#trae))
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**
@@ -146,4 +146,4 @@ 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 12 AI coding tools
- [Providers Matrix](/providers) — capability differences across the 14 AI coding tools

View File

@@ -21,7 +21,7 @@ multica daemon start
启动后它会做四件事:
1. 读取你登录时保存的凭证
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[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)
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**

View File

@@ -157,6 +157,7 @@ S3 の前段に CloudFront を置く場合、3 つの変数が適用されます
| 変数 | デフォルト | 説明 |
|---|---|---|
| `REDIS_URL` | 空 | Redis 接続 URL例: `redis://localhost:6379/0`)。設定しないと auth エンドポイントのレート制限が無効になります。同じ Redis はリアルタイムハブの fan-out、PAT キャッシュ、デーモントークンキャッシュでも使われます — 設定しない場合はすべてインメモリ / 直接 DB モードにフォールバックします |
| `REDIS_DISABLE_CLIENT_NAME` | `false` | `true` に設定すると、すべての Redis 接続で `CLIENT SETNAME` ハンドシェイクをスキップします。`CLIENT` コマンドをブロックするマネージド Redis プロバイダーGCP Memorystore や ACL 制限付きの AWS ElastiCache など)を使用する場合に**必須**です。有効にすると `CLIENT LIST` 出力で接続の説明的な名前が失われますが、制限付きプロバイダーとの互換性が得られます |
| `RATE_LIMIT_AUTH` | `5` | `/auth/send-code` および `/auth/google` に対する IP あたり毎分の最大リクエスト数 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | `/auth/verify-code` に対する IP あたり毎分の最大リクエスト数 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | リミッターがその `X-Forwarded-For` ヘッダーを信頼することを許可する、カンマ区切りの CIDR。空デフォルトは **XFF を決して信頼しない**ことを意味します — リミッターは直接接続の `RemoteAddr` のみを使用します |
@@ -230,11 +231,11 @@ S3 の前段に CloudFront を置く場合、3 つの変数が適用されます
## 使用量分析
デフォルトでは、サーバーは Multica の公式 PostHog インスタンスにレポートします。オプトアウトするには `ANALYTICS_DISABLED=true` を設定してください。
デフォルトでは、サーバーは製品分析イベントを Multica の公式 PostHog インスタンスにレポートします。PostHog イベント送信を無効にするには `ANALYTICS_DISABLED=true` を設定してください。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `ANALYTICS_DISABLED` | `false` | バックエンド分析を完全に無効にするには `true` に設定 |
| `ANALYTICS_DISABLED` | `false` | バックエンドの PostHog 分析イベントを無効にするには `true` に設定 |
| `POSTHOG_API_KEY` | 組み込みのデフォルトキー | 自身の PostHog インスタンスを指す場合に設定 |
| `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog をセルフホストする場合は自身のホストに変更 |

View File

@@ -157,6 +157,7 @@ S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLO
| 변수 | 기본값 | 설명 |
|---|---|---|
| `REDIS_URL` | 비어 있음 | Redis 연결 URL (예: `redis://localhost:6379/0`). 설정하지 않으면 auth 엔드포인트의 속도 제한이 비활성화됩니다. 동일한 Redis는 실시간 허브 fan-out, PAT 캐시, 데몬 토큰 캐시에서도 사용됩니다 — 설정하지 않으면 모두 인메모리 / 직접 DB 모드로 폴백합니다 |
| `REDIS_DISABLE_CLIENT_NAME` | `false` | `true`로 설정하면 모든 Redis 연결에서 `CLIENT SETNAME` 핸드셰이크를 건너뜁니다. `CLIENT` 명령을 차단하는 관리형 Redis 제공자(GCP Memorystore 또는 ACL이 제한된 AWS ElastiCache 등)를 사용할 때 **필수**입니다. 활성화하면 `CLIENT LIST` 출력에서 연결의 설명 이름이 사라지지만, 제한된 제공자와의 호환성을 얻을 수 있습니다 |
| `RATE_LIMIT_AUTH` | `5` | `/auth/send-code` 및 `/auth/google`에 대한 IP당 분당 최대 요청 수 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | `/auth/verify-code`에 대한 IP당 분당 최대 요청 수 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 비어 있음 | 리미터가 그 `X-Forwarded-For` 헤더를 신뢰하도록 허용하는, 쉼표로 구분된 CIDR. 비어 있음(기본값)은 **XFF를 절대 신뢰하지 않음**을 의미합니다 — 리미터는 직접 연결의 `RemoteAddr`만 사용합니다 |
@@ -230,11 +231,11 @@ S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLO
## 사용량 분석
기본적으로 서버는 Multica의 공식 PostHog 인스턴스로 보고합니다. 옵트아웃하려면 `ANALYTICS_DISABLED=true`로 설정하세요.
기본적으로 서버는 제품 분석 이벤트를 Multica의 공식 PostHog 인스턴스로 보고합니다. PostHog 이벤트 전송을 비활성화하려면 `ANALYTICS_DISABLED=true`로 설정하세요.
| 변수 | 기본값 | 설명 |
|---|---|---|
| `ANALYTICS_DISABLED` | `false` | 백엔드 분석을 완전히 비활성화하려면 `true`로 설정 |
| `ANALYTICS_DISABLED` | `false` | 백엔드 PostHog 분석 이벤트를 비활성화하려면 `true`로 설정 |
| `POSTHOG_API_KEY` | 내장 기본 키 | 자신의 PostHog 인스턴스를 가리킬 때 설정 |
| `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog를 자체 호스팅하는 경우 자신의 호스트로 변경 |

View File

@@ -157,6 +157,7 @@ Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google`
| Variable | Default | Description |
|---|---|---|
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
| `REDIS_DISABLE_CLIENT_NAME` | `false` | Set to `true` to skip the `CLIENT SETNAME` handshake on every Redis connection. **Required** for managed Redis providers that block the `CLIENT` command, such as GCP Memorystore or AWS ElastiCache with restricted ACLs. When enabled, connections lose their descriptive name in `CLIENT LIST` output but gain compatibility with restricted providers |
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |
@@ -232,11 +233,11 @@ The [GitHub PR ↔ issue integration](/github-integration) needs two variables.
## Usage analytics
By default, the server reports to Multica's official PostHog instance. To opt out, set `ANALYTICS_DISABLED=true`.
By default, the server reports product analytics events to Multica's official PostHog instance. To disable PostHog event shipping, set `ANALYTICS_DISABLED=true`.
| Variable | Default | Description |
|---|---|---|
| `ANALYTICS_DISABLED` | `false` | Set `true` to disable backend analytics entirely |
| `ANALYTICS_DISABLED` | `false` | Set `true` to disable backend PostHog analytics events |
| `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 |

View File

@@ -157,6 +157,7 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `REDIS_URL` | 空 | Redis 连接 URL例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
| `REDIS_DISABLE_CLIENT_NAME` | `false` | 设为 `true` 可跳过每次 Redis 连接时的 `CLIENT SETNAME` 握手。使用托管 Redis如 GCP Memorystore 或限制了 ACL 的 AWS ElastiCache等封禁 `CLIENT` 命令的服务时**必须开启**。启用后连接在 `CLIENT LIST` 输出中会失去描述性名称,但能兼容受限的托管服务 |
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |
@@ -235,11 +236,11 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap
## 用量统计
默认上报到 Multica 官方 PostHog 实例。不想上报就把 `ANALYTICS_DISABLED=true`。
默认会把产品分析事件上报到 Multica 官方 PostHog 实例。要关闭 PostHog 事件上报,请设置 `ANALYTICS_DISABLED=true`。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `ANALYTICS_DISABLED` | `false` | 设 `true` 完全关闭后端上报 |
| `ANALYTICS_DISABLED` | `false` | 设 `true` 关闭后端 PostHog 分析事件 |
| `POSTHOG_API_KEY` | 内置默认 key | 换成你自己的 PostHog 实例时填 |
| `POSTHOG_HOST` | `https://us.i.posthog.com` | 自建 PostHog 的话改成你自己的地址 |

View File

@@ -13,7 +13,7 @@ Multica は**分散型**プラットフォームです。あなたが目にす
- **Multica サーバー** — あなたが目にするワークスペース、イシュー一覧、コメントスレッドは、すべてここのデータベースに保存されます。また、あなたと同僚の間でリアルタイム更新をプッシュする WebSocket ハブでもあります。エージェントのタスクは**実行しません**。
- **デーモン** — Multica CLI の一部であり、あなた自身のマシンで実行されます。起動時にローカルにインストールされた AI コーディングツールを検出し、サーバーに登録したうえで、3 秒ごとにタスクをポーリングし、15 秒ごとにハートビートを送信し始めます。
- **AI コーディングツール** — 次の 12 種類のうちの 1 つ(または複数を並列で): [Antigravity](/providers#antigravity)、[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)。デーモンがタスクを取得した後は、これらのツールを使って実際の作業を行います。
- **AI コーディングツール** — 次の 12 種類のうちの 1 つ(または複数を並列で): [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。デーモンがタスクを取得した後は、これらのツールを使って実際の作業を行います。
ツールチェーンがローカルに留まるため、**あなたの API キー、コードディレクトリ、認可されたツール**は、あなたのマシン上でのみ使用されます。Multica サーバーはそのいずれも目にすることはありません。これはセルフホストでも Cloud でも同じように適用されます。

View File

@@ -13,7 +13,7 @@ Multica는 **분산형** 플랫폼입니다. 여러분이 보는 웹 인터페
- **Multica 서버** — 여러분이 보는 워크스페이스, 이슈 목록, 댓글 스레드는 모두 이곳의 데이터베이스에 저장됩니다. 또한 여러분과 동료 사이의 실시간 업데이트를 푸시하는 WebSocket 허브이기도 합니다. 에이전트 작업은 **실행하지 않습니다.**
- **데몬** — Multica CLI의 일부로, 여러분 자신의 기기에서 실행됩니다. 시작 시 로컬에 설치된 AI 코딩 도구를 감지하고, 서버에 등록한 다음, 3초마다 작업을 폴링하고 15초마다 하트비트를 전송하기 시작합니다.
- **AI 코딩 도구** — 다음 열두 가지 중 하나(또는 여러 개를 병렬로): [Antigravity](/providers#antigravity), [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). 데몬이 작업을 가져온 뒤에는 이러한 도구를 사용해 실제 작업을 수행합니다.
- **AI 코딩 도구** — 다음 열두 가지 중 하나(또는 여러 개를 병렬로): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). 데몬이 작업을 가져온 뒤에는 이러한 도구를 사용해 실제 작업을 수행합니다.
도구 체인이 로컬에 유지되므로 **여러분의 API 키, 코드 디렉터리, 인증된 도구**는 오직 여러분의 기기에서만 사용됩니다. Multica 서버는 그중 어떤 것도 보지 못합니다. 이는 자체 호스팅을 하든 Cloud를 사용하든 동일하게 적용됩니다.

View File

@@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro
- **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks.
- **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds.
- **AI coding tools** — one of the twelve (or several in parallel): [Antigravity](/providers#antigravity), [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). Once the daemon has picked up a task, it uses these tools to actually do the work.
- **AI coding tools** — one of the twelve (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud.

View File

@@ -13,7 +13,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
- **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。
- **守护进程**daemon——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server开始每 3 秒领一次任务、每 15 秒发一次心跳。
- **AI 编程工具**——[Antigravity](/providers#antigravity)、[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) 12 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
- **AI 编程工具**——[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 12 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。

View File

@@ -13,7 +13,7 @@ Multica は、人間と AI [エージェント](/agents)が同じ[ワークス
エージェントは Multica のサーバー上でタスクを実行**しません**。現在 Multica は 1 つのランタイムモデルをサポートしています。
- **ローカル[デーモン](/daemon-runtimes)** — 自分のマシンで `multica daemon` を実行すると、デーモンがローカルにインストールされた [AI コーディングツール](/providers)を駆動します。現在 12 種類が標準で組み込まれています: [Antigravity](/providers#antigravity)、[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)。API キー、ツールチェーン、コードディレクトリはすべて自分のマシンに留まります。
- **ローカル[デーモン](/daemon-runtimes)** — 自分のマシンで `multica daemon` を実行すると、デーモンがローカルにインストールされた [AI コーディングツール](/providers)を駆動します。現在 12 種類が標準で組み込まれています: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。API キー、ツールチェーン、コードディレクトリはすべて自分のマシンに留まります。
<Callout type="info">
**クラウドランタイムが近日提供予定です。** 現在はウェイトリストのみで運用されています。提供が開始されればローカルデーモンは不要になり、エージェントのタスクは Multica Cloud 上で直接実行されます。[ダウンロード](https://multica.ai/download)ページで登録すると通知を受け取れます。

View File

@@ -13,7 +13,7 @@ Multica는 인간과 AI [에이전트](/agents)가 같은 [워크스페이스](/
에이전트는 Multica 서버에서 작업을 실행하지 **않습니다**. 현재 Multica는 하나의 런타임 모델을 지원합니다:
- **로컬 [데몬](/daemon-runtimes)** — 자신의 기기에서 `multica daemon`을 실행하면, 데몬이 로컬에 설치된 [AI 코딩 도구](/providers)를 구동합니다. 현재 열두 가지가 기본 내장되어 있습니다: [Antigravity](/providers#antigravity), [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). API 키, 툴체인, 코드 디렉터리는 모두 자신의 기기에 머뭅니다.
- **로컬 [데몬](/daemon-runtimes)** — 자신의 기기에서 `multica daemon`을 실행하면, 데몬이 로컬에 설치된 [AI 코딩 도구](/providers)를 구동합니다. 현재 열두 가지가 기본 내장되어 있습니다: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). API 키, 툴체인, 코드 디렉터리는 모두 자신의 기기에 머뭅니다.
<Callout type="info">
**클라우드 런타임이 곧 제공됩니다.** 현재는 대기 명단으로만 운영됩니다. 출시되면 로컬 데몬이 필요 없어지며 — 에이전트 작업이 Multica Cloud에서 직접 실행됩니다. [다운로드](https://multica.ai/download) 페이지에서 등록하면 알림을 받을 수 있습니다.

View File

@@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica.
Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model:
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Twelve are built in today: [Antigravity](/providers#antigravity), [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). Your API keys, toolchain, and code directories stay on your machine.
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Twelve are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
<Callout type="info">
**Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified.

View File

@@ -7,13 +7,15 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在同一个 [工作区](/workspaces) 里共同工作。你可以像给同事派活一样,[把一个任务分配给智能体](/assigning-issues) ——由它去执行、汇报进展、在评论里回复你;也可以[打开聊天窗口直接和它对话](/chat),让它帮你起草任务、回答问题、或完成一次性请求。
<VideoEmbed provider="bilibili" id="BV1cv7Y6gEg7" title="Multica 中文介绍视频" />
这一页讲清楚智能体在哪里运行,以及你有哪几种方式开始使用 Multica。
## 智能体在哪里运行
智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式:
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 12 款:[Antigravity](/providers#antigravity)、[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)。你的 API 密钥、工具链、代码目录都保留在本地。
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
<Callout type="info">
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。

View File

@@ -1,11 +1,11 @@
---
title: Install an agent runtime
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 13 supported tools so the daemon can detect them.
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 14 supported tools so the daemon can detect them.
---
import { Callout } from "fumadocs-ui/components/callout";
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 13 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 14 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
This page is the install-side companion to:
@@ -31,9 +31,9 @@ multica daemon restart
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
## The 13 supported tools
## The 14 supported tools
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 13.
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 14.
### Claude Code (Anthropic)
@@ -157,16 +157,27 @@ Agentic coding CLI using the ACP protocol over stdio (shares the transport with
| Install | See the official CLI docs at [qoder.com/cli](https://qoder.com/cli). |
| Authentication | Per the vendor's docs. |
### Trae CLI (ByteDance)
ByteDance's official TRAE CLI (`traecli`, paired with the Trae IDE — **not** the open-source `bytedance/trae-agent`). It is ACP-native, so Multica drives it over stdio via `traecli acp serve --yolo`, sharing the transport with Hermes, Kimi, Kiro CLI, and Qoder. Session resumption works through ACP `session/load`, MCP config is passed through ACP `mcpServers`, model selection is discovered dynamically (switched per task via `session/set_model`), and skills are copied into `.traecli/skills/`.
| | |
|---|---|
| Daemon looks for | `traecli` |
| Install | See the official CLI docs at [docs.trae.cn/cli](https://docs.trae.cn/cli). |
| Authentication | Run `traecli` once interactively to complete the browser-based enterprise login (the token persists in `~/.trae` plus the OS keyring). Logging into the Trae **IDE** does not log in the CLI — they are separate. |
| Notes | traecli reads project rules from `.trae/rules/` and has no `--system-prompt` flag, so Multica delivers its runtime brief inline in the prompt. Set a daemon-wide default model with `MULTICA_TRAECLI_MODEL`. |
### Antigravity (Google)
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Multica launches it with `agy -p`, the daemon-compatible non-interactive mode; current Antigravity CLI releases can execute tools from that mode, while `agy -i` requires an attached TTY. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
| | |
|---|---|
| Daemon looks for | `agy` |
| Install | Follow the official guide at [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview). The CLI ships pre-built — run `agy install` once to wire up PATH and shell aliases. |
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text. |
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text, and per-tool telemetry is not available today. |
## After installing

View File

@@ -159,14 +159,14 @@ ACP 协议 agent和 Kimi 共享传输层。会话续接可用MCP 配置
### AntigravityGoogle
Google 的 Antigravity CLI`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
Google 的 Antigravity CLI`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动它,这是适合 daemon 后台任务的一次性非交互模式;当前 Antigravity CLI 在这个模式下仍可执行工具,而 `agy -i` 需要连接 TTY不适合 daemon 驱动。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
| | |
|---|---|
| 守护进程扫描 | `agy` |
| 安装 | 看官方指引 [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview)。CLI 是预编译的,跑一次 `agy install` 配好 PATH 和 shell 别名即可。 |
| 认证 | 交互式跑一次 `agy` 走 Google 账号登录流程;或者通过 Antigravity 桌面端登录——CLI 会复用 GUI 写入 keyring 的凭据。 |
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 思考过程和最终回复都会作为 text 消息送回 Multica。 |
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 过程和最终回复都会作为 text 消息送回 Multica,目前无法展示 Antigravity 的逐工具 telemetry。 |
## 装完之后

View File

@@ -30,8 +30,10 @@
"---インボックス---",
"inbox",
"---連携---",
"channels",
"github-integration",
"lark-bot-integration",
"slack-bot-integration",
"---セルフホスト & 運用---",
"environment-variables",
"auth-setup",

View File

@@ -30,8 +30,10 @@
"---Inbox---",
"inbox",
"---Integrations---",
"channels",
"github-integration",
"lark-bot-integration",
"slack-bot-integration",
"---Self-hosting & ops---",
"environment-variables",
"auth-setup",

View File

@@ -30,8 +30,10 @@
"---인박스---",
"inbox",
"---연동---",
"channels",
"github-integration",
"lark-bot-integration",
"slack-bot-integration",
"---자체 호스팅 & 운영---",
"environment-variables",
"auth-setup",

View File

@@ -30,8 +30,10 @@
"---收件箱---",
"inbox",
"---集成---",
"channels",
"github-integration",
"lark-bot-integration",
"slack-bot-integration",
"---自部署运维---",
"environment-variables",
"auth-setup",

View File

@@ -1,11 +1,11 @@
---
title: AI coding tools matrix
description: Multica supports 13 AI coding tools; they implement the same interface, but the capability details diverge significantly.
description: Multica supports 14 AI coding tools; they implement the same interface, but the capability details diverge significantly.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica ships with built-in support for **13 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
Multica ships with built-in support for **14 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create).
@@ -26,12 +26,13 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
| **OpenClaw** | Open source | ✅ | ✅ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | Dynamic discovery |
| **Trae** | ByteDance | ✅ (ACP `session/load`) | ✅ | `.traecli/skills/` | Dynamic discovery |
## What each tool is for
### Antigravity
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. Multica launches Antigravity with `agy -p` because that is the daemon-compatible non-interactive mode; `agy -i` needs an attached TTY and is not suitable for background task execution. Current Antigravity CLI releases can still execute tools from this mode, but stdout is plain assistant text rather than a structured event stream, so Multica relays the transcript as text and cannot show per-tool telemetry for Antigravity today. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
### Claude Code
@@ -89,15 +90,28 @@ From Inflection AI, minimalist. **Session resumption is unusual** — the sessio
From Alibaba. An agentic coding CLI. Uses the ACP protocol over stdio (shares a transport with Hermes, Kimi, and Kiro CLI). Session resumption works through ACP `session/resume`, MCP config is passed through ACP `mcpServers`, model selection is discovered dynamically, and skills are copied into `.qoder/skills/` for native discovery.
### Trae
From ByteDance — the official **TRAE CLI** (`traecli`, documented at [docs.trae.cn/cli](https://docs.trae.cn/cli); this is the product paired with the Trae IDE, **not** the open-source `bytedance/trae-agent`). traecli is ACP-native, so Multica drives it over the standard ACP JSON-RPC transport via `traecli acp serve --yolo`, exactly like Kiro and Qoder — `--yolo` puts it in bypass-permissions mode so headless runs don't block on tool-approval prompts.
- **Session resumption works** through ACP `session/load` (traecli advertises `loadSession: true` from `initialize`).
- **Model selection is dynamic**: the model catalog is discovered from `session/new` (`models.availableModels`) and switched per task via `session/set_model`. Set the daemon-wide default with `MULTICA_TRAECLI_MODEL`.
- **MCP is supported** — config is passed through ACP `mcpServers`; traecli advertises `mcpCapabilities: {http, sse}`, and unsupported transports are filtered out before `session/new`.
- **Authentication** is traecli's own enterprise login: run `traecli` once interactively to complete the browser login (the token persists in `~/.trae` + the OS keyring). The IDE login is separate — logging into the Trae IDE does **not** log in the CLI.
- **Skills** are written to `.traecli/skills/` (project) for native discovery; global skills live in `~/.traecli/skills`.
- The Multica runtime brief is also inlined into the prompt (traecli has no `--system-prompt` flag and reads project rules from `.trae/rules/`, not `AGENTS.md`), so the workflow instructions reach the agent regardless.
> The capabilities above were captured from the real `traecli` v0.120.42 binary: `initialize` → `loadSession:true` + `mcpCapabilities{http,sse}`; `session/new` → `result.sessionId` + `models.availableModels`; `session/prompt` streams `session/update` notifications with `sessionUpdate: agent_message_chunk`.
## Session resumption: who really supports it
The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continue-from-the-previous-context). **Every supported tool resumes sessions** — pass the resume id and the task continues from the previous context. The one quirk is Pi, whose resume id is a session file path on disk rather than a string id (see [Pi](#pi) above).
## MCP configuration: provider-specific support
**Of the 13 tools, ten consume `mcp_config`: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Qoder**. The other three accept the field but **ignore it** — no error, no warning, the config just has no effect.
**Of the 14 tools, eleven consume `mcp_config`: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Qoder, and Trae**. The other three (Antigravity, Copilot, Pi) accept the field but **ignore it** — no error, no warning, the config just has no effect.
The runtime paths are provider-specific: Claude Code and CodeBuddy receive it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, Kiro CLI, and Qoder receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
The runtime paths are provider-specific: Claude Code and CodeBuddy receive it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, Kiro CLI, Qoder, and Trae receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
<Callout type="warning">
If you set `mcp_config` in an agent configuration but pick a tool not marked ✅ in the MCP column, your MCP servers have **no effect** on that agent. MCP integration is provider-specific.
@@ -134,4 +148,4 @@ For creating and using skills, see [Skills](/skills).
- [Creating and configuring agents](/agents-create) — pick a tool for your agent
- [Tasks](/tasks) — task lifecycle and session-resumption mechanics
- [Daemon and runtimes](/daemon-runtimes) — where the tools run and how they connect to Multica
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 13 supported tools
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 14 supported tools

View File

@@ -31,7 +31,7 @@ Multica 内置支持 **13 款 AI 编程工具**。它们都实现了同一套接
### Antigravity
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flagagy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug而且 agy 遇到无法识别的值会静默空跑所以优先从发现列表里挑选不要手填。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动 Antigravity因为这是适合 daemon 后台任务的一次性非交互模式;`agy -i` 需要连接 TTY不适合后台执行。当前 Antigravity CLI 在 `agy -p` 下仍可执行工具,但 stdout 是纯文本而非结构化事件流,所以 Multica 会把 transcript 作为 text 转发,暂时无法展示逐工具 telemetry。**会话恢复真用**——通过 `--conversation <id>`,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flagagy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug而且 agy 遇到无法识别的值会静默空跑所以优先从发现列表里挑选不要手填。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
### Claude Code

View File

@@ -70,4 +70,4 @@ By now you know what an agent is, how to create one, and how to attach skills. T
- [Daemon and runtimes](/daemon-runtimes) — where agents actually run, and how to tell online from offline
- [Executing tasks](/tasks) — the full lifecycle of one "agent work session"
- [AI coding tools comparison](/providers) — full comparison of all 12 tools (including each one's skill injection path)
- [AI coding tools comparison](/providers) — full comparison of all 14 tools (including each one's skill injection path)

View File

@@ -0,0 +1,184 @@
---
title: Slack Bot 連携
description: Multica エージェントをあなた自身の Slack アプリに接続します——マニフェストからアプリを作成し、インストールして、bot トークンと app-level トークンを貼り付ければ、Slack の中から @ メンションしたり、DM したり、/issue と入力したりできます。
---
import { Callout } from "fumadocs-ui/components/callout";
任意の[エージェント](/agents)を Slack Bot に接続すれば、チームは Slack の中から直接それを使えます——Bot に DM したり、チャンネルで @ メンションしたり、`/issue` と入力してアプリを開かずに [Multica イシュー](/issues)を起票したりできます。
Slack は**自分のアプリを持ち込むBYO: bring-your-own-app**モデルを採用しています。ワークスペースの admin が Slack アプリを作成し、自分の Slack ワークスペースにインストールして、そのトークンを Multica に貼り付けます。エージェントごとに**専用の** Slack アプリを持つため、同じ Slack ワークスペース内で複数のエージェントがそれぞれ別個に @ メンションできる異なる Bot を持てます。(これは紐づけがスキャンしてインストールするフローである [Lark](/lark-bot-integration) とは異なります。)
セットアップ全体は以下のとおりで、所要時間は約 5 分です。最終的に、Multica に貼り付ける 2 つのトークンが得られます。
- **Bot トークン** —— `xoxb-` で始まります
- **App-level トークン** —— `xapp-` で始まります
## Slack アプリをセットアップする
### 1. マニフェストからアプリを作成する
1. [https://api.slack.com/apps](https://api.slack.com/apps) を開き、**Create New App** をクリックします。
2. **From a manifest** を選びます。
3. アプリをインストールする Slack ワークスペースを選びます。
4. **YAML** タブに切り替え、下記のマニフェストを貼り付けて、内容を確認しアプリを作成します。
```yaml
display_information:
name: Multica
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: Multica
always_online: true
slash_commands:
- command: /issue
description: Create a Multica issue
usage_hint: "[title]"
oauth_config:
scopes:
bot:
- app_mentions:read
- channels:history
- groups:history
- im:history
- mpim:history
- chat:write
- reactions:write
- users:read
- commands
settings:
event_subscriptions:
bot_events:
- app_mention
- message.im
- message.channels
- message.groups
- message.mpim
interactivity:
is_enabled: false
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
```
このマニフェストは Multica が必要とするものをすべて設定するので、手作業で何かを設定する必要はありません。
| セクション | なぜそこにあるか |
|---|---|
| `app_home.messages_tab_enabled: true` | メンバーが Bot を開いて **DM** できるようにします。これがないと、Bot に直接メッセージを送れません。 |
| `bot_user` | @ メンションされ、返信を投稿する Bot のアイデンティティを作成します。 |
| `chat:write` | エージェントの返信を Slack に投稿し返します。 |
| `reactions:write` | エージェントの処理中、あなたのメッセージに 👀 リアクションを付け、返信時に外します。このスコープがないとインジケーターは黙ってスキップされます——他の機能はすべて動作します。 |
| `app_mentions:read` + `app_mention` イベント | チャンネルでの @ メンションを受け取ります。 |
| `im:history` + `message.im` | Bot への **DM** を受け取ります(すべての DM メッセージが読み取られます)。 |
| `channels:history` / `groups:history` / `mpim:history` + 対応する `message.*` イベント | パブリックチャンネル、プライベートチャンネル、グループ DM のメッセージを受け取ります。これらの中では、Bot は自分を **@ メンション**したメッセージにのみ反応します。 |
| `users:read` | Multica が(`bots.info` を介して)あなたの 2 つのトークンが同じアプリのものであることを検証するために必要です。 |
| `commands` | `/issue` スラッシュコマンドを有効にする bot スコープです(`features.slash_commands` と対になります)。これがないと、マニフェストを更新して再インストールしてもコマンドが付与されません。 |
| `socket_mode_enabled: true` | Bot は Socket Mode 経由で外向きに接続します——**公開 URLリクエスト URL は不要**です。 |
| `interactivity.is_enabled: false` | Multica のプロンプトはボタンではなくプレーンなリンクなので、インタラクティビティは不要です。 |
| `slash_commands``/issue` | `/issue` スラッシュコマンドを登録し、誰でもメッセージ入力欄から Multica イシューを起票できるようにします。Socket Mode 経由で配信され、リクエスト URL は不要です。 |
**OAuth リダイレクト URL はありません**。BYO は OAuth を使わないからです。
<Callout type="info">
Slack で特定の名前を表示したいですか? 作成前に `display_information.name` と `features.bot_user.display_name`(たとえばエージェントの名前に)を変更するか、あとで **App Home** で編集してください。Slack は Bot をその **bot display name** で表示しますが、これはアプリ名と異なる場合があります。
</Callout>
### 2. アプリをインストールして Bot トークンをコピーする
1. アプリの左ナビで **Install App**(または **OAuth & Permissions**)を開きます。
2. **Install to Workspace** をクリックして承認します。
3. **Bot User OAuth Token** をコピーします——`xoxb-` で始まります。これがあなたの **Bot トークン**です。
### 3. App-level トークンを作成する
app-level トークンは Socket Mode 接続を認可します。これはコンソールでしか作成できませんOAuth の一部ではありません)。
1. **Basic Information → App-Level Tokens** を開き、**Generate Token and Scopes** をクリックします。
2. 任意の名前を付けます。
3. **Add Scope** をクリックし、リストから **`connections:write`** を選びます(これはピッカーなので、入力せずに選択してください)。
4. **Generate** をクリックし、トークンをコピーします——`xapp-` で始まります。これがあなたの **App-level トークン**です。
### 4. Multica で接続する
1. **Agents → _あなたのエージェント_** からそのエージェントを開き、**Integrations** タブ(または左サイドバーの **Integrations** 区画)を開きます。
2. **Connect Slack** をクリックします。
3. **Bot トークン**`xoxb-`)と **App-level トークン**`xapp-`)を貼り付け、**Connect** をクリックします。
4. エージェントに **Connected to Slack** と表示されます。Bot はこれで、自身の Socket Mode 接続を通じて待ち受けています。
<Callout type="warning">
2 つのトークンは**同じ** Slack アプリのものでなければならず、そのアプリはちょうど **1 つ**のエージェントに対応します。すでに別のエージェントやワークスペースに接続されているアプリを接続しようとすると拒否されます。アプリを別のエージェントへ移すには、まず切断してください。**新しい**アプリでエージェントを再接続すると、そのエージェントの Bot がその場で更新されます。
</Callout>
<Callout type="info">
**複数のエージェント**でこれを設定しますか? フロー全体をエージェントごとに 1 回ずつ繰り返してください——各エージェントが専用の Slack アプリと専用のトークンのペアを持ち、Slack ワークスペース内で別々の Bot として表示されます。
</Callout>
## この連携でできること
| 場所 | 動作 |
|---|---|
| **エージェント → Integrations** | owner と admin には **Connect Slack** が表示され、接続すると **Connected to Slack** バッジと **Disconnect** コントロールに切り替わります。 |
| **Bot に DM** | ワークスペースメンバーが Bot に直接メッセージを送ります。会話はそのエージェントとの Multica [chat](/chat) セッションになり、すべての DM メッセージが読み取られます。 |
| **チャンネルで @ メンション** | Bot を招待し(`/invite @your-bot`)、@ メンションします。読み取られるのはメンションしたメッセージだけで、Bot はチャンネル全体を聞いているわけではありません。各 @bot **スレッド**がそれぞれ独立したセッションになります。 |
| **`/issue` スラッシュコマンド** | `/issue <タイトル>`(チャンネルでも DM でも)と入力すると、ワークスペースに新しい Multica イシューが作られ、あなたの名義になります。確認は本人にだけ返信されます——@ メンションは不要です。 |
| **返信** | エージェントの回答は、同じ DM またはスレッドに投稿し返されます。 |
## Bot を使う(メンバー)
### 最初のメッセージ:アカウントを紐づける
初めて Bot を @ メンションするか DM すると、Bot は **アカウントを紐づける** プロンプトで返信します。リンクをタップして Multica にサインインすると、あなたの Slack アイデンティティがあなたの Multica メンバーシップに紐づきます——これによって、エージェントがあなたとして振る舞えるようになります(たとえば `/issue` はあなたの名義でイシューを起票します)。このリンクは使い切りで、約 15 分で失効します。新しいものが必要なら、もう一度 Bot にメッセージを送るだけです。
<Callout type="warning">
Bot を使えるのは **ワークスペースのメンバー** だけです。メンバーでない場合や、アイデンティティの紐づけをスキップした場合、Bot は実行されません——あなたのメッセージは破棄されます(内容は保存せず、監査のために記録されます)。
</Callout>
### 対話と `/issue`
- **チャンネルで** —— Bot は自動では参加しません。一度 `/invite @your-bot` を実行してから、`@your-bot <あなたのメッセージ>` とします。フォローアップのたびに再度メンションしてくださいBot は自分をメンションしたメッセージだけを読みます)。
- **DM で** —— Slack サイドバーの **Apps** 区画から Bot を開いて直接メッセージを送ります。メンションは不要です。
- **イシューを起票する** —— `/issue` スラッシュコマンドを使います(例: `/issue Fix the login redirect`)。チャンネルでも DM でも使え(@ メンション不要)、確認は本人にだけ返信されます。初回のユーザーには、まずアカウント連携の使い切りリンクが届きます。
## 管理と切断
ワークスペース全体の管理は **Settings → Integrations** にあります。
- **Connected bots** は、ワークスペース内のすべての Bot と、それぞれが紐づくエージェントを一覧表示します(すべてのメンバーから見えます)。
- **Disconnect** は **owner / admin 専用** です。切断すると Bot は Slack メッセージの受信を停止し、その接続が破棄されます。インストール記録は監査のために保持され、あとで再接続できます。
## 権限
- **接続 / 切断** にはワークスペースの **owner** または **admin** が必要です。
- **Bot との対話** には、Slack アイデンティティを紐づけたワークスペースメンバーであることが必要です。それ以外の人は一律に破棄されます。
- 破棄されたメッセージの本文が保存されることはありません——監査のために破棄理由だけが記録されます。
## セルフホストのセットアップ
Multica Cloud では連携はすでに利用可能です——このセクションは飛ばしてください。
セルフホストの場合、Slack は**保存時の暗号化キーを設定するまでオフ**です。このキーは、各アプリの bot トークン + app-level トークンがデータベースに触れる前にそれを暗号化します。BYO には OAuth の client id/secret は**不要**で、デプロイレベルの app トークンも**不要**です——各インストールは admin が貼り付けたトークンを使います。
1. 32 バイトのキーを生成し、API サーバーに設定します。
```dotenv
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
```
たとえば: `openssl rand -base64 32`。
2. API を再起動します。キーを設定するまで、**Settings → Integrations** には「Slack integration not enabled」という通知が表示され、**Connect Slack** のエントリポイントは非表示のままになります。
<Callout type="info">
キーはちょうど 32 バイトにデコードされなければなりません——`openssl rand -base64 32` はそれを満たします。これは長く使い続けるシークレットとして扱ってください。ローテーションしたり紛失したりすると、すでに保存済みのトークンが復号できなくなり、すべての Bot を再接続せざるを得なくなります。「アカウントを紐づける」リンクは、Web アプリの URL`MULTICA_APP_URL`、未設定時は `FRONTEND_ORIGIN` にフォールバック)から生成されます。通常のデプロイではこれは既に設定されているため、追加で設定するものはありません。
</Callout>
## 次に
- [Chat 連携](/channels) — チャンネルエンジン、セッション、認可の仕組み
- [エージェント](/agents) · [Chat](/chat) · [イシュー](/issues)
- [環境変数](/environment-variables) — セルフホスト構成の完全なリファレンス

View File

@@ -0,0 +1,184 @@
---
title: Slack Bot 연동
description: Multica 에이전트를 자체 Slack 앱에 연결하세요 — 매니페스트로 앱을 만들고, 설치한 다음, bot + app-level 토큰을 붙여넣고, Slack 안에서 @로 멘션하거나 DM하거나 /issue를 입력하세요.
---
import { Callout } from "fumadocs-ui/components/callout";
아무 [에이전트](/agents)나 Slack 봇에 연결하면, 팀이 Slack 안에서 바로 그 에이전트와 함께 일할 수 있습니다 — 봇에게 DM을 보내거나, 채널에서 `@`로 멘션하거나, `/issue`를 입력해 앱을 열지 않고도 [Multica 이슈](/issues)를 생성하세요.
Slack은 **자체 앱 사용(BYO)** 모델을 따릅니다: 워크스페이스 admin이 Slack 앱을 만들고, 자신의 Slack 워크스페이스에 설치한 다음, 토큰을 Multica에 붙여넣습니다. 각 에이전트가 **자체** Slack 앱을 갖습니다 — 그래서 하나의 Slack 워크스페이스 안에서 여러 에이전트가 각각 별개로 `@`로 멘션할 수 있는 봇을 가질 수 있습니다. (바인딩이 스캔하여 설치하는 방식인 [Lark](/lark-bot-integration)와는 다릅니다.)
전체 설정은 아래에 있으며 약 5분이 걸립니다. 마지막에는 Multica에 붙여넣을 두 개의 토큰을 얻게 됩니다:
- **Bot token** — `xoxb-`로 시작
- **App-level token** — `xapp-`로 시작
## Slack 앱 설정하기
### 1. 매니페스트로 앱 만들기
1. [https://api.slack.com/apps](https://api.slack.com/apps)로 이동해 **Create New App**을 클릭합니다.
2. **From a manifest**를 선택합니다.
3. 앱을 설치할 Slack 워크스페이스를 고릅니다.
4. **YAML** 탭으로 전환해 아래 매니페스트를 붙여넣고, 검토한 뒤 앱을 생성합니다.
```yaml
display_information:
name: Multica
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: Multica
always_online: true
slash_commands:
- command: /issue
description: Create a Multica issue
usage_hint: "[title]"
oauth_config:
scopes:
bot:
- app_mentions:read
- channels:history
- groups:history
- im:history
- mpim:history
- chat:write
- reactions:write
- users:read
- commands
settings:
event_subscriptions:
bot_events:
- app_mention
- message.im
- message.channels
- message.groups
- message.mpim
interactivity:
is_enabled: false
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
```
이 매니페스트는 Multica에 필요한 모든 것을 구성하므로, 직접 손으로 설정할 것이 없습니다:
| 섹션 | 이유 |
|---|---|
| `app_home.messages_tab_enabled: true` | 멤버가 봇을 열어 **DM**할 수 있게 합니다. 이것이 없으면 봇에게 직접 메시지를 보낼 수 없습니다. |
| `bot_user` | `@`로 멘션되고 답변을 게시하는 봇 신원을 생성합니다. |
| `chat:write` | 에이전트의 답변을 Slack으로 다시 게시합니다. |
| `reactions:write` | 에이전트가 처리하는 동안 메시지에 👀 반응을 추가하고, 답변하면 제거합니다. 이 스코프가 없으면 표시가 조용히 건너뛰어집니다 — 나머지 기능은 모두 정상 동작합니다. |
| `app_mentions:read` + `app_mention` 이벤트 | 채널에서 `@`-멘션을 받습니다. |
| `im:history` + `message.im` | 봇에게 보내는 **DM**을 받습니다(모든 DM 메시지를 읽습니다). |
| `channels:history` / `groups:history` / `mpim:history` + 대응하는 `message.*` 이벤트 | 공개 채널, 비공개 채널, 그룹 DM의 메시지를 받습니다. 이런 곳에서 봇은 자신을 **@로 멘션한** 메시지에만 반응합니다. |
| `users:read` | Multica가 두 토큰이 같은 앱에 속하는지 (`bots.info`를 통해) 확인하는 데 필요합니다. |
| `commands` | `/issue` 슬래시 명령을 활성화하는 bot 스코프입니다(`features.slash_commands`와 짝을 이룹니다). 이것이 없으면 매니페스트를 업데이트하고 재설치해도 명령이 부여되지 않습니다. |
| `socket_mode_enabled: true` | 봇이 Socket Mode로 밖으로 연결합니다 — **공개 URL / request URL이 필요 없습니다**. |
| `interactivity.is_enabled: false` | Multica의 안내는 버튼이 아니라 일반 링크라서, interactivity가 필요 없습니다. |
| `slash_commands`(`/issue`) | `/issue` 슬래시 명령을 등록해, 누구나 메시지 입력창에서 Multica 이슈를 생성할 수 있게 합니다. Socket Mode로 전달되며 요청 URL이 필요 없습니다. |
**OAuth redirect URL은 없습니다.** BYO는 OAuth를 사용하지 않기 때문입니다.
<Callout type="info">
Slack에서 특정 이름을 쓰고 싶나요? 생성하기 전에 `display_information.name`과 `features.bot_user.display_name`을 (예: 에이전트 이름으로) 변경하거나, 나중에 **App Home**에서 편집하세요. Slack은 봇을 **bot display name**으로 표시하며, 이는 앱 이름과 다를 수 있습니다.
</Callout>
### 2. 앱 설치하고 Bot token 복사하기
1. 앱의 왼쪽 내비게이션에서 **Install App**(또는 **OAuth & Permissions**)을 엽니다.
2. **Install to Workspace**를 클릭하고 승인합니다.
3. **Bot User OAuth Token**을 복사합니다 — `xoxb-`로 시작합니다. 이것이 당신의 **Bot token**입니다.
### 3. App-level token 생성하기
app-level token은 Socket Mode 연결을 인가합니다. 콘솔에서만 생성할 수 있습니다(OAuth의 일부가 아닙니다).
1. **Basic Information → App-Level Tokens**를 열고 **Generate Token and Scopes**를 클릭합니다.
2. 아무 이름이나 지정합니다.
3. **Add Scope**를 클릭하고 목록에서 **`connections:write`**를 고릅니다(선택기이므로 — 입력하지 말고 선택하세요).
4. **Generate**를 클릭한 다음 토큰을 복사합니다 — `xapp-`로 시작합니다. 이것이 당신의 **App-level token**입니다.
### 4. Multica에서 연결하기
1. **Agents → _당신의 에이전트_** → **Integrations** 탭(또는 왼쪽 사이드바의 **Integrations** 섹션)에서 에이전트를 엽니다.
2. **Connect Slack**을 클릭합니다.
3. **Bot token**(`xoxb-`)과 **App-level token**(`xapp-`)을 붙여넣은 다음 **Connect**를 클릭합니다.
4. 에이전트에 **Connected to Slack**이 표시됩니다. 봇은 이제 자체 Socket Mode 연결로 수신 대기합니다.
<Callout type="warning">
두 토큰은 **같은** Slack 앱에서 와야 하며, 그 앱은 정확히 **하나의** 에이전트에 매핑됩니다. 이미 다른 에이전트나 워크스페이스에 연결된 앱을 연결하는 것은 거부됩니다. 앱을 다른 에이전트로 옮기려면 먼저 연결을 해제하세요. **새** 앱으로 에이전트를 다시 연결하면 그 에이전트의 봇이 그 자리에서 갱신됩니다.
</Callout>
<Callout type="info">
**여러 에이전트**에 설정하나요? 에이전트당 전체 과정을 한 번씩 반복하세요 — 각 에이전트가 자체 Slack 앱과 자체 토큰 한 쌍을 가지며, Slack 워크스페이스에 별개의 봇으로 나타납니다.
</Callout>
## 연동이 하는 일
| 위치 | 동작 |
|---|---|
| **Agent → Integrations** | owner와 admin에게는 **Connect Slack**이 보이며, 연결되면 **Connected to Slack** 배지와 **Disconnect** 컨트롤로 바뀝니다. |
| **봇에게 DM** | 워크스페이스 멤버가 봇에게 직접 메시지를 보냅니다. 그 대화는 에이전트와의 Multica [chat](/chat) 세션이 되며, 모든 DM 메시지를 읽습니다. |
| **채널에서 `@`-멘션** | 봇을 초대(`/invite @your-bot`)하고 `@`로 멘션하세요. 멘션한 메시지만 읽으며, 봇이 채널 전체를 듣지는 않습니다. 각 @bot **스레드**가 자체 세션입니다. |
| **`/issue` 슬래시 명령** | `/issue <제목>`을(채널이나 DM에서) 입력하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. 확인은 본인에게만 비공개로 답하며 — @ 멘션이 필요 없습니다. |
| **답변** | 에이전트의 답변은 같은 DM 또는 스레드로 다시 게시됩니다. |
## 봇 사용하기 (멤버)
### 첫 메시지: 계정 연결하기
봇을 처음 `@`로 멘션하거나 DM하면, **계정을 연결하라**는 안내로 답합니다. 링크를 탭하고 Multica에 로그인하면, 당신의 Slack 신원이 Multica 멤버십에 바인딩됩니다 — 바로 이 단계가 에이전트로 하여금 당신을 대신해 행동하게 합니다(예: `/issue`는 당신 이름으로 이슈를 생성합니다). 이 링크는 일회용이며 약 15분 후에 만료됩니다. 새 링크가 필요하면 봇에게 다시 메시지를 보내세요.
<Callout type="warning">
**워크스페이스 멤버**만 봇을 사용할 수 있습니다. 멤버가 아니거나 신원 연결을 건너뛰면 봇은 실행되지 않으며, 메시지는 폐기됩니다(감사 목적으로 기록되며, 내용은 저장하지 않습니다).
</Callout>
### 대화와 `/issue`
- **채널에서** — 봇은 자동으로 참여하지 않습니다. `/invite @your-bot`을 한 번 실행한 다음 `@your-bot <당신의 메시지>`로 보내세요. 후속 메시지마다 다시 멘션하세요(봇은 자신을 멘션한 메시지만 읽습니다).
- **DM에서** — Slack 사이드바의 **Apps** 섹션에서 봇을 열고 직접 메시지를 보내세요. 멘션이 필요 없습니다.
- **이슈 생성** — `/issue` 슬래시 명령을 사용하세요(예: `/issue Fix the login redirect`). 채널이나 DM에서 모두 쓸 수 있고(@ 멘션 불필요), 확인은 본인에게만 비공개로 답합니다. 처음 사용하는 사람은 먼저 일회용 계정 연결 링크를 받습니다.
## 관리 및 연결 해제
워크스페이스 전체 관리는 **Settings → Integrations**에 있습니다:
- **Connected bots**는 워크스페이스 내 모든 봇과 각 봇이 바인딩된 에이전트를 나열합니다(모든 멤버에게 보입니다).
- **Disconnect**는 **owner / admin 전용**입니다. 봇이 Slack 메시지 수신을 멈추고 연결이 해체됩니다. 설치 기록은 감사용으로 유지되며, 이후 다시 연결할 수 있습니다.
## 권한
- **연결 / 연결 해제**에는 워크스페이스 **owner** 또는 **admin**이 필요합니다.
- **봇과 대화하기**에는 Slack 신원이 연결된 워크스페이스 멤버여야 합니다. 그 외의 사람은 모두 폐기됩니다.
- 폐기된 메시지의 본문은 절대 저장되지 않으며 — 감사용 폐기 사유만 기록됩니다.
## 자체 호스팅 설정
Multica Cloud에서는 연동이 이미 사용 가능합니다 — 이 섹션은 건너뛰세요.
자체 호스팅의 경우, Slack은 **at-rest 암호화 키를 설정하기 전까지 꺼져 있습니다**. 이 키는 각 앱의 bot + app-level 토큰이 데이터베이스에 닿기 전에 암호화합니다. BYO에는 OAuth client id/secret이 **필요 없고**, 배포 수준의 app token도 **필요 없습니다** — 각 installation은 admin이 붙여넣은 토큰을 사용합니다.
1. 32바이트 키를 생성해 API 서버에 설정합니다:
```dotenv
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
```
예를 들면: `openssl rand -base64 32`.
2. API를 재시작하세요. 키가 설정되기 전까지 **Settings → Integrations**에는 "Slack integration not enabled" 안내가 표시되고, **Connect Slack** 진입점은 숨겨진 채로 유지됩니다.
<Callout type="info">
키는 정확히 32바이트로 디코딩되어야 하며 — `openssl rand -base64 32`가 이를 충족합니다. 오래 유지되는 시크릿으로 다루세요: 키를 회전하거나 잃으면 이미 저장된 토큰을 복호화할 수 없게 되어, 모든 봇이 다시 연결해야 합니다. "계정을 연결하세요" 링크는 웹 앱 URL(`MULTICA_APP_URL`, 없으면 `FRONTEND_ORIGIN`으로 폴백)에서 만들어집니다. 일반적인 배포에서는 이미 설정되어 있으므로 추가로 구성할 것은 없습니다.
</Callout>
## 다음
- [Chat 연동](/channels) — channel 엔진, 세션, 권한이 어떻게 동작하는지
- [에이전트](/agents) · [Chat](/chat) · [이슈](/issues)
- [환경 변수](/environment-variables) — 전체 자체 호스팅 구성 참조

View File

@@ -0,0 +1,184 @@
---
title: Slack Bot integration
description: Connect a Multica agent to your own Slack app — create the app from a manifest, install it, paste the bot + app-level tokens, then @-mention it, DM it, or type /issue from inside Slack.
---
import { Callout } from "fumadocs-ui/components/callout";
Connect any [agent](/agents) to a Slack bot and your team can work with it from inside Slack — DM the bot, @-mention it in a channel, or type `/issue` to file a [Multica issue](/issues) without opening the app.
Slack uses a **bring-your-own-app (BYO)** model: a workspace admin creates a Slack app, installs it to their Slack workspace, and pastes its tokens into Multica. Each agent gets **its own** Slack app — so several agents can each have a distinct, separately @-mentionable bot in the same Slack workspace. (This differs from [Lark](/lark-bot-integration), where binding is a scan-to-install flow.)
The whole setup is below and takes about five minutes. You'll end up with two tokens to paste into Multica:
- a **Bot token** — starts with `xoxb-`
- an **App-level token** — starts with `xapp-`
## Set up your Slack app
### 1. Create the app from a manifest
1. Go to [https://api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**.
2. Choose **From a manifest**.
3. Pick the Slack workspace to install the app into.
4. Switch to the **YAML** tab, paste the manifest below, review, and create the app.
```yaml
display_information:
name: Multica
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: Multica
always_online: true
slash_commands:
- command: /issue
description: Create a Multica issue
usage_hint: "[title]"
oauth_config:
scopes:
bot:
- app_mentions:read
- channels:history
- groups:history
- im:history
- mpim:history
- chat:write
- reactions:write
- users:read
- commands
settings:
event_subscriptions:
bot_events:
- app_mention
- message.im
- message.channels
- message.groups
- message.mpim
interactivity:
is_enabled: false
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
```
This manifest configures everything Multica needs, so you don't set anything by hand:
| Section | Why it's there |
|---|---|
| `app_home.messages_tab_enabled: true` | Lets members open the bot and **DM** it. Without it, the bot can't be messaged directly. |
| `bot_user` | Creates the bot identity that gets @-mentioned and posts replies. |
| `chat:write` | Post the agent's replies back into Slack. |
| `reactions:write` | Add a 👀 reaction to your message while the agent is working, removed when it replies. Without this scope the indicator is silently skipped — everything else still works. |
| `app_mentions:read` + `app_mention` event | Receive @-mentions in channels. |
| `im:history` + `message.im` | Receive **DMs** to the bot (every DM message is read). |
| `channels:history` / `groups:history` / `mpim:history` + the matching `message.*` events | Receive messages in public channels, private channels, and group DMs. In these, the bot only acts on messages that **@-mention** it. |
| `users:read` | Required so Multica can verify (via `bots.info`) that your two tokens belong to the same app. |
| `commands` | The bot scope that enables the `/issue` slash command (pairs with `features.slash_commands`). Without it, updating the manifest and reinstalling won't grant the command. |
| `socket_mode_enabled: true` | The bot connects out over Socket Mode — **no public URL / request URL needed**. |
| `interactivity.is_enabled: false` | Multica's prompts are plain links, not buttons, so interactivity isn't needed. |
| `slash_commands` (`/issue`) | Registers the `/issue` slash command so anyone can file a Multica issue from the message box. Delivered over Socket Mode — no request URL. |
There is **no OAuth redirect URL**, because BYO doesn't use OAuth.
<Callout type="info">
Want a specific name in Slack? Change `display_information.name` and `features.bot_user.display_name` (e.g. to your agent's name) before creating, or edit it later under **App Home**. Slack shows the bot by its **bot display name**, which can differ from the app name.
</Callout>
### 2. Install the app and copy the Bot token
1. In the app's left nav, open **Install App** (or **OAuth & Permissions**).
2. Click **Install to Workspace** and approve.
3. Copy the **Bot User OAuth Token** — it starts with `xoxb-`. This is your **Bot token**.
### 3. Create the App-level token
The app-level token authorizes the Socket Mode connection. It can only be created in the console (it isn't part of OAuth).
1. Open **Basic Information → App-Level Tokens** and click **Generate Token and Scopes**.
2. Give it any name.
3. Click **Add Scope** and pick **`connections:write`** from the list (it's a picker — select it, don't type it).
4. Click **Generate**, then copy the token — it starts with `xapp-`. This is your **App-level token**.
### 4. Connect it in Multica
1. Open the agent in **Agents → _your agent_** → the **Integrations** tab (or the **Integrations** section in the left sidebar).
2. Click **Connect Slack**.
3. Paste the **Bot token** (`xoxb-`) and the **App-level token** (`xapp-`), then click **Connect**.
4. The agent shows **Connected to Slack**. The bot is now listening over its own Socket Mode connection.
<Callout type="warning">
The two tokens must be from the **same** Slack app, and that app maps to exactly **one** agent. Connecting an app that's already connected to a different agent or workspace is refused. To move an app to another agent, disconnect it first; re-connecting an agent with a **new** app updates that agent's bot in place.
</Callout>
<Callout type="info">
Setting this up for **multiple agents**? Repeat the whole flow once per agent — each agent gets its own Slack app and its own pair of tokens, and they show up as separate bots in your Slack workspace.
</Callout>
## What the integration does
| Surface | Behavior |
|---|---|
| **Agent → Integrations** | Owners and admins see **Connect Slack**; once connected it flips to a **Connected to Slack** badge with a **Disconnect** control. |
| **DM the bot** | A workspace member messages the bot directly. The conversation becomes a Multica [chat](/chat) session with the agent; every DM message is read. |
| **@-mention in a channel** | Invite the bot (`/invite @your-bot`) and @-mention it. Only the mentioning message is read — the bot does not listen to the whole channel. Each @bot **thread** is its own session. |
| **`/issue` slash command** | Type `/issue <title>` (in a channel or a DM) to create a Multica issue in the workspace, attributed to you. It replies privately with a confirmation — no @-mention needed. |
| **Reply** | The agent's answer is posted back into the same DM or thread. |
## Use the bot (members)
### First message: link your account
The first time you @-mention or DM the bot, it replies with a **link your account** prompt. Tap the link, sign in to Multica, and your Slack identity is bound to your Multica membership — this is what lets the agent act as you (e.g. `/issue` files under your name). The link is single-use and expires in about 15 minutes; just message the bot again for a fresh one.
<Callout type="warning">
Only **members of the workspace** can use the bot. If you aren't a member, or you skip the identity link, the bot won't run — your message is dropped (recorded for audit, without its contents).
</Callout>
### Chat and `/issue`
- **In a channel** — the bot isn't auto-joined. Run `/invite @your-bot` once, then `@your-bot <your message>`. Re-mention it for each follow-up (the bot only reads messages that mention it).
- **In a DM** — open the bot from the Slack sidebar's **Apps** section and message it directly; no mention needed.
- **File an issue** — use the `/issue` slash command, e.g. `/issue Fix the login redirect`. It works in a channel or a DM (no @-mention needed) and replies with a private confirmation. First-time users get a one-time link to connect their account.
## Manage and disconnect
Workspace-wide management lives in **Settings → Integrations**:
- **Connected bots** lists every bot in the workspace and the agent each is bound to (visible to all members).
- **Disconnect** is **owner / admin only**. It stops the bot from receiving Slack messages and tears down its connection; the installation record is kept for audit, and you can re-connect later.
## Permissions
- **Connect / disconnect** require workspace **owner** or **admin**.
- **Talking to the bot** requires being a workspace member with a linked Slack identity. Everyone else is dropped.
- Message bodies for dropped messages are never stored — only a drop reason, for audit.
## Self-host setup
On Multica Cloud the integration is already available — skip this section.
For self-host, Slack is **off until you set an at-rest encryption key**. The key encrypts each app's bot + app-level tokens before they touch the database. BYO needs **no** OAuth client id/secret and **no** deployment-level app token — each installation uses the tokens the admin pastes.
1. Generate a 32-byte key and set it on the API server:
```dotenv
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
```
For example: `openssl rand -base64 32`.
2. Restart the API. Until the key is set, **Settings → Integrations** shows a "Slack integration not enabled" notice and the **Connect Slack** entry points stay hidden.
<Callout type="info">
The key must decode to exactly 32 bytes — `openssl rand -base64 32` does this. Treat it as a long-lived secret: rotating or losing it makes already-stored tokens undecryptable, forcing every bot to reconnect. The "link your account" link is built from your web app URL (`MULTICA_APP_URL`, falling back to `FRONTEND_ORIGIN`) — a normal deployment already sets this, so there's nothing extra to configure.
</Callout>
## Next
- [Chat integrations](/channels) — how the channel engine, sessions, and authorization work
- [Agents](/agents) · [Chat](/chat) · [Issues](/issues)
- [Environment variables](/environment-variables) — full self-host configuration reference

View File

@@ -0,0 +1,184 @@
---
title: Slack Bot 接入
description: 把 Multica 智能体接入你自己的 Slack app——用 manifest 创建 app、安装它、粘贴 bot token 与 app-level token然后就能在 Slack 里 @ 它、私聊它,或输入 /issue。
---
import { Callout } from "fumadocs-ui/components/callout";
把任意[智能体](/agents)接入一个 Slack Bot团队就能在 Slack 里直接使用它——私聊 Bot、在频道里 @ 它,或者输入 `/issue` 直接创建一个 [Multica issue](/issues),不用打开应用。
Slack 走的是**自带应用bring-your-own-appBYO**模式workspace 管理员创建一个 Slack app把它安装到自己的 Slack workspace再把它的 token 粘贴进 Multica。每个智能体都有**它自己的** Slack app——所以多个智能体可以在同一个 Slack workspace 里各自拥有一个独立、可单独 @ 的 Bot。这一点和 [Lark](/lark-bot-integration) 不同Lark 的绑定是扫码安装流程。)
整个设置流程在下面,大约五分钟。最后你会得到两个 token 粘贴进 Multica
- 一个 **Bot token** —— 以 `xoxb-` 开头
- 一个 **App-level token** —— 以 `xapp-` 开头
## 设置你的 Slack app
### 1. 用 manifest 创建 app
1. 打开 [https://api.slack.com/apps](https://api.slack.com/apps),点击 **Create New App**。
2. 选择 **From a manifest**。
3. 选定要把 app 安装进去的那个 Slack workspace。
4. 切到 **YAML** tab粘贴下面的 manifest检查一遍然后创建这个 app。
```yaml
display_information:
name: Multica
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: Multica
always_online: true
slash_commands:
- command: /issue
description: Create a Multica issue
usage_hint: "[title]"
oauth_config:
scopes:
bot:
- app_mentions:read
- channels:history
- groups:history
- im:history
- mpim:history
- chat:write
- reactions:write
- users:read
- commands
settings:
event_subscriptions:
bot_events:
- app_mention
- message.im
- message.channels
- message.groups
- message.mpim
interactivity:
is_enabled: false
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
```
这个 manifest 已经把 Multica 所需的一切都配好了,所以你不用手动设置任何东西:
| 配置项 | 为什么需要它 |
|---|---|
| `app_home.messages_tab_enabled: true` | 让成员能打开 Bot 并**私聊**它。没有它Bot 就无法被直接发消息。 |
| `bot_user` | 创建被 @ 和发回复用的那个 Bot 身份。 |
| `chat:write` | 把智能体的回复发回 Slack。 |
| `reactions:write` | 智能体处理期间在你的消息上加一个 👀 表情,回复后再移除。没有这个权限时,该提示会被静默跳过——其他功能都不受影响。 |
| `app_mentions:read` + `app_mention` 事件 | 接收频道里的 @ 提及。 |
| `im:history` + `message.im` | 接收发给 Bot 的**私聊**(每一条私聊消息都会被读取)。 |
| `channels:history` / `groups:history` / `mpim:history` + 对应的 `message.*` 事件 | 接收公开频道、私有频道和群组私聊里的消息。在这些场景里Bot 只对 **@ 了**它的消息做出响应。 |
| `users:read` | 必需,这样 Multica 才能(通过 `bots.info`)核实你的两个 token 属于同一个 app。 |
| `commands` | 启用 `/issue` 斜杠命令所需的 bot 权限(和 `features.slash_commands` 搭配)。少了它,即使更新 manifest 并重装,也拿不到这个命令。 |
| `socket_mode_enabled: true` | Bot 通过 Socket Mode 向外连接——**无需任何公网 URL / request URL**。 |
| `interactivity.is_enabled: false` | Multica 的提示是纯链接,不是按钮,所以不需要交互性。 |
| `slash_commands``/issue` | 注册 `/issue` 斜杠命令,让任何人都能从消息框直接创建一个 Multica issue。通过 Socket Mode 下发——无需 request URL。 |
这里**没有 OAuth 重定向 URL**,因为 BYO 不使用 OAuth。
<Callout type="info">
想在 Slack 里用一个特定的名字?在创建之前改 `display_information.name` 和 `features.bot_user.display_name`(比如改成你智能体的名字),或者之后在 **App Home** 里编辑。Slack 是按 Bot 的**显示名bot display name**来展示它的,这个名字可以和 app 名不一样。
</Callout>
### 2. 安装 app 并复制 Bot token
1. 在 app 的左侧导航里,打开 **Install App**(或 **OAuth & Permissions**)。
2. 点击 **Install to Workspace** 并批准。
3. 复制 **Bot User OAuth Token**——它以 `xoxb-` 开头。这就是你的 **Bot token**。
### 3. 创建 App-level token
App-level token 用来授权 Socket Mode 连接。它只能在控制台里创建(它不属于 OAuth
1. 打开 **Basic Information → App-Level Tokens**,点击 **Generate Token and Scopes**。
2. 随便起个名字。
3. 点击 **Add Scope**,从列表里选 **`connections:write`**(这是一个选择器——选中它,不要手打)。
4. 点击 **Generate**,然后复制这个 token——它以 `xapp-` 开头。这就是你的 **App-level token**。
### 4. 在 Multica 里连接它
1. 在 **Agents → _你的智能体_** 打开该智能体 → **Integrations** tab或左侧栏的 **Integrations** 区块)。
2. 点击 **Connect Slack**。
3. 粘贴 **Bot token**`xoxb-`)和 **App-level token**`xapp-`),然后点击 **Connect**。
4. 智能体显示 **Connected to Slack**。Bot 现在通过它自己的 Socket Mode 连接在监听了。
<Callout type="warning">
这两个 token 必须来自**同一个** Slack app而那个 app 恰好对应**一个**智能体。连接一个已经连到别的智能体或 workspace 的 app 会被拒绝。要把一个 app 挪到另一个智能体,先断开它;用一个**新的** app 重新连接某个智能体,会就地更新那个智能体的 Bot。
</Callout>
<Callout type="info">
要给**多个智能体**做这套设置?每个智能体都把整套流程走一遍——每个智能体都有自己的 Slack app 和自己的一对 token它们会在你的 Slack workspace 里显示成各自独立的 Bot。
</Callout>
## 这个集成能做什么
| 入口 | 行为 |
|---|---|
| **智能体 → Integrations** | 所有者和管理员能看到 **Connect Slack**;连接后它会变成一个 **Connected to Slack** 徽标,并带一个 **Disconnect** 操作。 |
| **私聊 Bot** | 工作区成员直接给 Bot 发消息。这段对话会成为该智能体的一个 Multica [chat](/chat) 会话;每一条私聊消息都会被读取。 |
| **频道里 @ 它** | 把 Bot 邀请进来(`/invite @your-bot`)再 @ 它。只有 @ 它的那条消息会被读取——Bot 不会监听整个频道。每个 @bot 的 **thread** 都是它自己的会话。 |
| **`/issue` 斜杠命令** | 输入 `/issue <标题>`(在频道或私聊里)会在工作区创建一个新的 Multica issue记在你名下。它会私下回一条确认——不需要 @ Bot。 |
| **回复** | 智能体的答复会被发回同一段私聊或 thread 里。 |
## 使用 Bot成员
### 第一条消息:绑定你的账号
第一次 @ 或私聊 Bot 时,它会回一条 **绑定你的账号** 提示。点开链接、登录 Multica你的 Slack 身份就会绑定到你的 Multica 成员身份——正是这一步让智能体能以你的身份行事(比如 `/issue` 会把 issue 记在你名下)。这个链接是一次性的,大约 15 分钟后过期;再给 Bot 发条消息就能拿到一个新的。
<Callout type="warning">
只有**工作区成员**才能使用 Bot。如果你不是成员或者跳过了身份绑定Bot 不会运行——你的消息会被丢弃(仅出于审计目的记录,不保存消息内容)。
</Callout>
### 对话与 `/issue`
- **在频道里** —— Bot 不会自动加入。先运行一次 `/invite @your-bot`,然后 `@your-bot <你的消息>`。每次追问都要重新 @ 它一下Bot 只读取 @ 了它的消息)。
- **在私聊里** —— 从 Slack 侧栏的 **Apps** 区块打开 Bot 并直接给它发消息;不用 @。
- **创建 issue** —— 使用 `/issue` 斜杠命令,比如 `/issue Fix the login redirect`。在频道或私聊里都能用(不用 @ Bot会私下回一条确认。第一次使用的人会先收到一个一次性的账号绑定链接。
## 管理与断开
工作区级别的管理在 **Settings → Integrations**
- **Connected bots** 列出工作区里每个 Bot 以及它各自绑定的智能体(所有成员都能看到)。
- **Disconnect** 仅限 **所有者 / 管理员**。它会让 Bot 停止接收 Slack 消息并拆掉它的连接;安装记录会保留以便审计,之后你可以重新连接。
## 权限
- **连接 / 断开** 需要工作区**所有者**或**管理员**。
- **和 Bot 对话** 需要你是工作区成员且已绑定 Slack 身份。其余的人一律被丢弃。
- 对于被丢弃的消息,绝不保存消息内容——只记录一个丢弃原因,用于审计。
## 自部署配置
在 Multica Cloud 上这个集成已经可用——可跳过本节。
自部署时,**在你设置好静态加密密钥之前Slack 是关闭的**。这个密钥会在每个 app 的 bot token + app-level token 落库之前对其加密。BYO **不需要** OAuth client id/secret也**不需要**部署级的 app token——每个安装用的都是管理员粘贴进来的那对 token。
1. 生成一个 32 字节的密钥并设置到 API 服务器:
```dotenv
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
```
例如:`openssl rand -base64 32`。
2. 重启 API。在密钥设置好之前**Settings → Integrations** 会显示一条「Slack integration not enabled」提示**Connect Slack** 入口也会保持隐藏。
<Callout type="info">
这个密钥必须正好解码出 32 字节——`openssl rand -base64 32` 就能做到。把它当成一个长期有效的密钥:轮换或丢失它会让已存储的 token 无法解密,迫使每个 Bot 重新连接。「绑定你的账号」链接是用你的 Web 应用地址(`MULTICA_APP_URL`,未设置时回退到 `FRONTEND_ORIGIN`)拼出来的——正常部署里这个值本来就有,不需要额外配置。
</Callout>
## 下一步
- [聊天集成](/channels) —— channel 引擎、会话与授权是怎么运作的
- [智能体](/agents) · [Chat](/chat) · [Issues](/issues)
- [环境变量](/environment-variables) —— 完整的自部署配置参考

View File

@@ -112,5 +112,5 @@ See [Providers Matrix → Session resumption](/providers#session-resumption-who-
## Next
- [Providers Matrix](/providers) — capability differences across the 12 AI coding tools (including the exact session-resumption status)
- [Providers Matrix](/providers) — capability differences across the 14 AI coding tools (including the exact session-resumption status)
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task

View File

@@ -0,0 +1,23 @@
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { SlackBindPage } from "@multica/views/slack";
// /slack/bind?token=<raw> is the bot's "link your account" destination. Suspense
// wraps useSearchParams per Next.js 15's CSR-bailout rule; the loading text
// never paints in practice because the redemption page itself renders the
// "redeeming…" state immediately.
function SlackBindPageContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
return <SlackBindPage token={token} />;
}
export default function Page() {
return (
<Suspense fallback={null}>
<SlackBindPageContent />
</Suspense>
);
}

View File

@@ -293,6 +293,77 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.33",
date: "2026-06-30",
title: "Autopilot access controls, Slack history backfill, and skill-archive imports",
changes: [],
features: [
"Autopilots now have a clear write-permission layer, plus a Manage Access dialog that lets the creator grant write access to specific workspace members.",
"Slack channels can backfill their conversation history into Multica, so an agent has the prior context the moment it joins.",
"Slack messages show a 👀 reaction while an agent is preparing its reply, and the reaction is always cleared on the way out.",
"Skill bundles can be installed from a local .skill or .zip archive.",
"multica issue commands no longer accept short UUID prefixes — use the issue key (MUL-123) or the full UUID.",
"The Agents page is now usable on mobile.",
],
improvements: [
"Comment routing was rewritten end-to-end so parent-chain mentions, agent-authored replies, and squad-leader fallback all flow through one well-tested cascade.",
"Locale bundles dropped 117 dead `_one` plural keys, with a parity test guarding against regressions.",
"The built-in runtime list now points at CodeBuddy instead of the removed Gemini runtime.",
"Self-host preflight accepts newer Docker Compose CLI plugin versions while still rejecting Docker Compose v1.",
],
fixes: [
"After a WebSocket reconnect, the daemon now reconciles in-flight tasks and workspace state immediately. (Community contribution.)",
"Antigravity replies that the agent produces silently now show up reliably instead of recording a blank but completed run.",
"Servers backed by managed Redis providers that reject CLIENT SETNAME now start up cleanly. (Community contribution.)",
"The agent-activity hover header now reads in terms of tasks instead of agents, so it agrees with the workspace chip.",
],
},
{
version: "0.3.32",
date: "2026-06-29",
title: "Detach sub-Issues, sturdier daemon reconnects, and friendlier attachment previews",
changes: [],
features: [
"Issues now have a Remove parent action, so you can detach a sub-Issue without first having to pick a different parent.",
],
improvements: [
"The local daemon reconnects to Multica through a more resilient WebSocket flow with bounded backoff, so brief network drops recover smoothly instead of stalling.",
"The daemon now bounds each runtime probe with its own timeout, so a single wedged CLI can no longer block every other runtime from coming online.",
],
fixes: [
"Scheduled autopilots advance their next-run time the moment a run is dispatched, so a slow runner can no longer cause back-to-back duplicate dispatches.",
"Attachment previews open correctly whether the URL redirects inside a frame, comes back from the same origin, or was uploaded locally — and local upload URLs are now preferred when available.",
"When the failed-task handler unsticks an Issue, the Issue view refreshes immediately instead of waiting for a manual reload.",
"Sticky Issue comment headers share the same background fade as the highlight, so settling on a comment no longer looks out of sync.",
"Chat conversations refresh their message cache when reconnecting, so you no longer see stale messages right after coming back online.",
],
},
{
version: "0.3.31",
date: "2026-06-26",
title: "Cross-workspace unread dot, Composio toolkit foundation, and a friendlier editor",
changes: [],
features: [
"The workspace switcher shows a dot when another workspace has unread inbox items.",
"New Composio toolkit foundation that prepares the upcoming third-party integrations.",
"You can run desktop dev on multiple checkouts side by side without them clashing.",
"The Chinese docs homepage now opens with a short intro video.",
],
improvements: [
"Contributor docs note that the desktop dev command isolates per checkout.",
],
fixes: [
"Tab now reliably indents selected list items in the Issue editor and keeps focus in place.",
"Squad leaders boot with the full squad briefing when you @-mention them in a comment, and replies that inherit the parent mention no longer trigger them again.",
"Code-block selections in Issues stay put while the page re-renders.",
"Assigning an Issue directly to an agent opens the handoff note instantly instead of waiting on a check.",
"The workspace switcher's unread dot now matches what you actually see in your inbox.",
"The edit-comment save button shows a loading state until the change is saved.",
"Search results load reliably again.",
"Self-hosting fails fast with a clear hint when Docker Compose v2 is missing.",
],
},
{
version: "0.3.30",
date: "2026-06-25",

View File

@@ -269,6 +269,77 @@ export function createJaDict(allowSignup: boolean): LandingDict {
fixes: "バグ修正",
},
entries: [
{
version: "0.3.33",
date: "2026-06-30",
title: "Autopilot のアクセス管理、Slack の履歴バックフィル、スキル パッケージのアーカイブ取り込み",
changes: [],
features: [
"Autopilot に明確な書き込み権限レイヤーが入り、詳細ページの「アクセス管理」から特定メンバーに 1 つの Autopilot の書き込み権限だけを委譲できます。",
"Slack チャンネルの過去のやり取りを Multica にバックフィルでき、エージェントが加わった時点で会話の流れをそのまま把握できます。",
"Slack でエージェントが返信を準備している間、ユーザーのメッセージに 👀 のリアクションが付き、終了時に確実に外れます。",
"スキル パッケージをローカルの .skill / .zip アーカイブから取り込めるようになりました。",
"multica issue 系のコマンドは短い UUID プレフィックスを受け付けなくなりました。Issue KeyMUL-123または完全な UUID を指定してください。",
"Agents ページがモバイルに最適化されました。",
],
improvements: [
"コメントのルーティング カスケードを全面的に書き直し、親リンクからの @ メンション、エージェント署名の返信、スクワッド リーダーへのフォールバックを十分にテストされた 1 本の経路にまとめました。",
"ロケール バンドルから、実際にはレンダリングされない `_one` 複数形キーを 117 件削除し、再発を防ぐパリティ チェックを追加しました。",
"組み込みランタイム一覧の Gemini を、実稼働している CodeBuddy に差し替えました。",
"セルフホストの事前チェックは新しい Docker Compose CLI プラグインを許容し、Docker Compose v1 は引き続き弾きます。",
],
fixes: [
"WebSocket 再接続後、デーモンが進行中のタスクとワークスペース状態を即座にサーバーと突き合わせるようになりました。(コミュニティ コントリビューション)",
"Antigravity が「ターンを完了しても何も出力しない」ケースでも、実行ログから返信内容を回収し、会話が空白になりません。",
"CLIENT SETNAME を拒否するマネージド Redis でサーバーが起動できなかった問題を修正しました。(コミュニティ コントリビューション)",
"エージェント活動のホバー カードのヘッダーが「実行中 N タスク」と表記され、ワークスペースの表示と一致するようになりました。",
],
},
{
version: "0.3.32",
date: "2026-06-29",
title: "サブ Issue の切り離し、より堅牢なデーモン再接続、どこからでも開ける添付プレビュー",
changes: [],
features: [
"Issue のアクションに「親 Issue を解除」が追加され、別の親を選び直さなくても子 Issue を直接切り離せます。",
],
improvements: [
"ローカル デーモンの WebSocket 再接続が、上限付きのバックオフを備えたより堅牢な流れに見直され、瞬断にもスムーズに復帰します。",
"デーモンはランタイムのバージョン確認に個別のタイムアウトを設けるようになり、応答しない 1 つの CLI が他のランタイム起動を巻き込んで止めることがなくなりました。",
],
fixes: [
"予約オートパイロットはディスパッチ直後に次回実行時刻を進めるようになり、遅いランナーが同じ実行を続けて送り出すことがなくなりました。",
"添付プレビューは、フレーム内リダイレクト、同一オリジン、ローカル アップロードのいずれの場合も正しく開き、ローカル アップロード URL があるときはそちらを優先します。",
"失敗タスク ハンドラーが詰まった Issue を解除すると、Issue 表示が即座に更新され、手動リロードが不要になりました。",
"Issue コメントの sticky ヘッダーがハイライトのフェードと同じ背景遷移を共有し、固定切り替えの違和感がなくなりました。",
"Chat の会話は再接続時にメッセージ キャッシュを更新するため、オンラインに戻った直後に古いメッセージが残らなくなりました。",
],
},
{
version: "0.3.31",
date: "2026-06-26",
title: "ワークスペース横断の未読ドット、Composio ツールキット基盤、より使いやすいエディター",
changes: [],
features: [
"ワークスペース切替メニューで、別のワークスペースに未読のインボックスがあるとドットが表示されます。",
"これから提供するサードパーティ ツールキット連携のための Composio 基盤が組み込まれました。",
"複数のチェックアウトでデスクトップ開発環境を並列に起動しても衝突しなくなりました。",
"中国語ドキュメントのトップに短いイントロ動画を追加しました。",
],
improvements: [
"コントリビューター ドキュメントに、デスクトップ開発コマンドがチェックアウトごとに自動で隔離されることを明記しました。",
],
fixes: [
"Issue エディター内のリストで Tab を押すと、選択した項目が安定して字下げされ、カーソルがリストの外に飛ばなくなりました。",
"コメントの @メンションでスクワッド リーダーに依頼すると、スクワッドのブリーフィングを携えて起動し、親メンションを引き継いだ返信が再度トリガーすることもありません。",
"Issue やコメント内のコード ブロックで選択したテキストが、画面の別領域が再描画されても解除されなくなりました。",
"Issue を特定のエージェントに直接アサインすると、ハンドオフ メモ欄がそのまますぐに開きます。",
"ワークスペース切替メニューの未読ドットが、実際のインボックス表示と一致するようになりました。",
"Issue のコメント編集時、保存ボタンに明確なローディング表示が出るようになりました。",
"検索結果が再び安定して読み込まれます。",
"セルフホストで Docker Compose v2 が見つからないときは、すぐに分かりやすい案内とともに停止します。",
],
},
{
version: "0.3.30",
date: "2026-06-25",

View File

@@ -268,6 +268,77 @@ export function createKoDict(allowSignup: boolean): LandingDict {
fixes: "버그 수정",
},
entries: [
{
version: "0.3.33",
date: "2026-06-30",
title: "Autopilot 액세스 관리, Slack 히스토리 백필, 스킬 번들 아카이브 가져오기",
changes: [],
features: [
"Autopilot에 명확한 쓰기 권한 계층이 도입되었고, 상세 페이지의 '액세스 관리'를 통해 특정 멤버에게 단일 Autopilot의 쓰기 권한만 위임할 수 있습니다.",
"Slack 채널의 과거 대화를 Multica로 백필할 수 있어, 에이전트가 채널에 합류한 순간부터 이전 맥락을 알 수 있습니다.",
"Slack에서 에이전트가 응답을 준비하는 동안 사용자 메시지에 👀 반응이 표시되고, 종료 시 안정적으로 제거됩니다.",
"스킬 번들을 로컬 .skill / .zip 아카이브에서 가져올 수 있습니다.",
"multica issue 계열 명령은 더 이상 짧은 UUID 접두사를 받지 않습니다. Issue Key(MUL-123) 또는 전체 UUID를 사용하세요.",
"Agents 페이지가 모바일에 맞게 다듬어졌습니다.",
],
improvements: [
"댓글 라우팅 캐스케이드를 전면 다시 작성해, 부모 체인 @멘션·에이전트 서명 답글·스쿼드 리더 폴백이 모두 충분히 테스트된 하나의 경로로 모입니다.",
"실제로 렌더링되지 않는 `_one` 복수 키 117개를 로케일 번들에서 제거하고, 재발을 막는 패리티 검증을 추가했습니다.",
"내장 런타임 목록의 Gemini를 실제 사용되는 CodeBuddy로 교체했습니다.",
"셀프호스트 사전 검사는 최신 Docker Compose CLI 플러그인을 허용하고, Docker Compose v1은 계속 거부합니다.",
],
fixes: [
"WebSocket 재연결 후 로컬 데몬이 진행 중인 작업과 워크스페이스 상태를 서버와 즉시 동기화합니다. (커뮤니티 기여)",
"Antigravity가 '턴을 마쳤지만 아무 것도 출력하지 않는' 경우에도 데몬이 실행 기록에서 응답을 복원해, 대화가 비어 보이지 않습니다.",
"CLIENT SETNAME을 거부하는 매니지드 Redis에서 서버가 기동되지 않던 문제를 수정했습니다. (커뮤니티 기여)",
"에이전트 활동 호버 카드의 헤더가 '실행 중 N 작업'으로 표시되어 워크스페이스 표시와 일치합니다.",
],
},
{
version: "0.3.32",
date: "2026-06-29",
title: "하위 Issue 분리, 더 견고한 데몬 재연결, 어디서나 열리는 첨부 미리보기",
changes: [],
features: [
"Issue 액션에 '상위 Issue 해제'가 추가되어, 다른 상위를 먼저 고르지 않고도 하위 Issue를 즉시 분리할 수 있습니다.",
],
improvements: [
"로컬 데몬이 더 견고한 WebSocket 흐름과 상한이 있는 백오프로 재연결해, 짧은 네트워크 단절에도 매끄럽게 복구됩니다.",
"데몬이 각 런타임의 버전 점검에 별도 타임아웃을 두어, 멈춰 버린 단 하나의 CLI가 다른 런타임의 기동을 막지 못합니다.",
],
fixes: [
"예약 오토파일럿은 디스패치되자마자 다음 실행 시각을 앞당겨, 느린 러너가 같은 실행을 중복으로 내보내지 않습니다.",
"첨부 미리보기는 프레임 내 리다이렉트, 동일 출처, 로컬 업로드 어떤 경우에도 정상적으로 열리며, 로컬 업로드 URL이 있으면 그쪽을 우선 사용합니다.",
"실패 작업 핸들러가 멈춘 Issue를 풀어 줄 때 화면이 즉시 갱신되어, 수동 새로고침이 필요 없습니다.",
"Issue 댓글의 sticky 헤더가 하이라이트 페이드와 같은 배경 전환을 공유해, 고정 표시 전환이 더 이상 어색하지 않습니다.",
"Chat 대화가 재연결 시 메시지 캐시를 새로 받아, 오프라인에서 돌아왔을 때 오래된 메시지가 남지 않습니다.",
],
},
{
version: "0.3.31",
date: "2026-06-26",
title: "워크스페이스 간 미확인 점, Composio 툴킷 기반, 더 편한 에디터",
changes: [],
features: [
"워크스페이스 전환기에서 다른 워크스페이스에 미확인 인박스가 있으면 점이 표시됩니다.",
"곧 도입될 서드파티 툴킷 연동을 위한 Composio 기반이 추가되었습니다.",
"여러 체크아웃에서 데스크톱 개발 환경을 동시에 실행해도 충돌이 없습니다.",
"중국어 문서 홈에 짧은 소개 영상이 추가되었습니다.",
],
improvements: [
"기여자 문서가 데스크톱 개발 명령이 체크아웃별로 자동 격리된다는 점을 안내합니다.",
],
fixes: [
"Issue 에디터 목록에서 Tab을 누르면 선택한 항목이 안정적으로 들여쓰기되고, 커서가 목록 밖으로 빠지지 않습니다.",
"댓글에서 @멘션으로 스쿼드 리더에게 작업을 맡기면 전체 스쿼드 브리핑과 함께 시작하며, 부모 멘션을 그대로 이어받은 답글은 리더를 다시 트리거하지 않습니다.",
"Issue와 댓글의 코드 블록에서 선택한 텍스트가 페이지의 다른 부분이 다시 렌더링되어도 풀리지 않습니다.",
"Issue를 특정 에이전트에 바로 할당하면 핸드오프 메모가 기다림 없이 곧바로 열립니다.",
"워크스페이스 전환기의 미확인 점이 실제 인박스 화면과 일치합니다.",
"Issue 댓글 편집 시 저장 버튼에 로딩 상태가 표시됩니다.",
"검색 결과가 다시 안정적으로 로드됩니다.",
"자체 호스팅에서 Docker Compose v2가 없으면 곧바로 명확한 안내와 함께 멈춥니다.",
],
},
{
version: "0.3.30",
date: "2026-06-25",

View File

@@ -293,6 +293,77 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.33",
date: "2026-06-30",
title: "Autopilot 协作权限、Slack 历史回灌、技能包归档导入",
changes: [],
features: [
"Autopilot 新增清晰的写权限分层,详情页提供「管理协作者」入口,可把单个 Autopilot 的写权限授予指定成员。",
"Slack 频道可以把过往对话回灌到 Multica智能体一进入频道即拥有完整上下文。",
"Slack 智能体处理消息期间会在用户消息上加 👀 反应表情,处理结束后稳定清除,不再出现卡死。",
"技能包支持从本地 .skill / .zip 归档导入。",
"multica issue 命令不再接受短 UUID 前缀,请使用 Issue KeyMUL-123或完整 UUID。",
"Agents 页面适配移动端。",
],
improvements: [
"重写了评论路由级联:父链 @ 提及、智能体署名回复、小队 Leader 兜底,三条路径汇入同一条经过充分测试的流程。",
"语言包清理了 117 个事实上不渲染的 _one 复数键,并新增校验防止再次回归。",
"内置运行时清单中失效的 Gemini 替换为实际使用的 CodeBuddy。",
"自托管预检允许更新版的 Docker Compose CLI 插件,同时继续拦截 Docker Compose v1。",
],
fixes: [
"WebSocket 断线重连后,守护进程会立即与服务端对账正在执行的任务和工作区状态。(社区贡献)",
"Antigravity 智能体「完成回合但未输出任何内容」时,回复会被从运行记录中补回,对话不再空白。",
"在拒绝 CLIENT SETNAME 的托管 Redis 上,服务端启动不再失败。(社区贡献)",
"智能体活动悬浮卡片头部计数改为「N 个任务正在执行」,与工作区显示保持一致。",
],
},
{
version: "0.3.32",
date: "2026-06-29",
title: "支持解除父子 Issue、守护进程重连更稳附件预览处处可开",
changes: [],
features: [
"Issue 操作菜单新增「移除父级 Issue」可以直接断开父子关系不用先去挑一个新的父级。",
],
improvements: [
"本地守护进程的 WebSocket 重连改为带上限的退避策略,短暂断网时恢复更顺滑,不再原地空转。",
"守护进程在探测各个智能体运行时版本时加上了独立超时,单个卡死的 CLI 不会再连累其他运行时。",
],
fixes: [
"定时 Autopilot 调度后会立即推进下一次运行时间,避免慢节点造成重复触发。",
"附件预览在框架内重定向、同源资源、本地上传等场景下都能正常打开;有本地上传 URL 时会优先使用本地链接。",
"失败任务处理器解开卡住的 Issue 时,前端视图会立即刷新,无需手动重新加载。",
"Issue 评论吸顶头与高亮渐隐使用了同一套背景过渡,吸顶切换不再有错位感。",
"Chat 在重新连上后会刷新消息缓存,掉线再回来时不再看到陈旧消息。",
],
},
{
version: "0.3.31",
date: "2026-06-26",
title: "跨工作区未读小圆点、Composio 工具集底座、更顺手的编辑器",
changes: [],
features: [
"工作区切换器里,其他工作区有未读 Inbox 时会亮起小圆点。",
"新增 Composio 工具集底座,为后续第三方工具对接做好准备。",
"现在可以在多个本地检出里并行启动桌面端 dev互不打架。",
"中文文档首页新增一段中文介绍视频,可点击播放。",
],
improvements: [
"贡献者文档明确说明桌面端 dev 命令会按检出自动隔离。",
],
fixes: [
"Issue 编辑器列表里按 Tab 现在能稳定缩进所选项,光标也不会跑出列表。",
"通过 @ 提及让小队 Leader 接手时,会带上完整的小队 Briefing继承父级提及的回复也不会再次触发 Leader。",
"Issue 和评论里代码块的选区,在页面其他位置刷新时不再丢失。",
"把 Issue 直接交给某个智能体时,运行确认弹窗会立刻展开 Handoff 备注。",
"工作区切换器上的未读小圆点会和你看到的 Inbox 保持一致。",
"编辑 Issue 评论时,保存按钮会显示加载状态,直到保存完成。",
"搜索结果能够稳定加载。",
"自托管缺少 Docker Compose v2 时会立刻给出明确的安装提示。",
],
},
{
version: "0.3.30",
date: "2026-06-25",

View File

@@ -111,6 +111,12 @@ services:
MULTICA_LARK_SECRET_KEY: ${MULTICA_LARK_SECRET_KEY:-}
MULTICA_LARK_HTTP_BASE_URL: ${MULTICA_LARK_HTTP_BASE_URL:-}
MULTICA_LARK_CALLBACK_BASE_URL: ${MULTICA_LARK_CALLBACK_BASE_URL:-}
# Slack bot integration. MULTICA_SLACK_SECRET_KEY is the opt-in: unset =
# integration disabled. It decrypts the per-installation bot/app tokens,
# which are brought by each workspace via OAuth/BYO and stored encrypted
# in the database, so this single deployment-wide key is all the operator
# needs to set here.
MULTICA_SLACK_SECRET_KEY: ${MULTICA_SLACK_SECRET_KEY:-}
restart: unless-stopped
frontend:

View File

@@ -22,7 +22,7 @@ See [MUL-1122](https://github.com/multica-ai/multica) for the design context.
## Configuration
All analytics shipping is toggled by environment variables (see `.env.example`):
PostHog analytics shipping is toggled by environment variables (see `.env.example`):
| Variable | Meaning | Default |
|---|---|---|
@@ -32,7 +32,7 @@ All analytics shipping is toggled by environment variables (see `.env.example`):
| `ANALYTICS_DISABLED` | Set to `true`/`1` to force the no-op client even when `POSTHOG_API_KEY` is set. | `""` |
Local dev and self-hosted instances run with `POSTHOG_API_KEY=""`, so **no
events leave the process unless the operator explicitly opts in**.
PostHog events leave the process unless the operator explicitly opts in**.
### Self-hosted instances

View File

@@ -14,6 +14,7 @@ const MCP_SUPPORTED_PROVIDERS = new Set([
"kiro",
"opencode",
"openclaw",
"traecli",
]);
export function providerSupportsMcpConfig(provider: string | undefined | null): boolean {

View File

@@ -28,6 +28,7 @@ import type {
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
InboxItem,
InboxWorkspaceUnread,
IssueSubscriber,
Comment,
CommentTriggerPreview,
@@ -99,6 +100,7 @@ import type {
UpdateAutopilotTriggerRequest,
ListAutopilotsResponse,
GetAutopilotResponse,
AutopilotCollaboratorsResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
@@ -111,6 +113,10 @@ import type {
BeginLarkInstallResponse,
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
SlackInstallation,
ListSlackInstallationsResponse,
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
@@ -205,6 +211,8 @@ import {
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
EMPTY_CANCEL_TASK_RESPONSE,
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -1475,6 +1483,17 @@ export class ApiClient {
return this.fetch("/api/inbox/unread-count");
}
// Cross-workspace unread summary: one entry per workspace the user belongs
// to that has unread inbox items. Backs the workspace-switcher dot for
// OTHER workspaces. Schema-guarded so a contract drift hides the dot rather
// than crashing the sidebar.
async getInboxUnreadSummary(): Promise<InboxWorkspaceUnread[]> {
const raw = await this.fetch<unknown>("/api/inbox/unread-summary");
return parseWithFallback(raw, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, {
endpoint: "GET /api/inbox/unread-summary",
});
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
}
@@ -2093,6 +2112,22 @@ export class ApiClient {
await this.fetch(`/api/autopilots/${id}`, { method: "DELETE" });
}
// Grant a workspace member explicit write access to the autopilot. Both
// grant and revoke return the full updated collaborator list so callers can
// refresh without a second round-trip.
async grantAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
return this.fetch(`/api/autopilots/${id}/collaborators`, {
method: "POST",
body: JSON.stringify({ user_id: userId }),
});
}
async revokeAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
return this.fetch(`/api/autopilots/${id}/collaborators/${userId}`, {
method: "DELETE",
});
}
async triggerAutopilot(id: string): Promise<AutopilotRun> {
return this.fetch(`/api/autopilots/${id}/trigger`, { method: "POST" });
}
@@ -2256,4 +2291,37 @@ export class ApiClient {
body: JSON.stringify({ token }),
});
}
// Slack integration (MUL-3666)
async listSlackInstallations(workspaceId: string): Promise<ListSlackInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/slack/installations`);
}
// registerSlackBYO performs a bring-your-own-app install: the admin pastes the
// bot token (xoxb-) + app-level token (xapp-) of the Slack app they created,
// and the backend validates + persists it, returning the new installation.
async registerSlackBYO(
workspaceId: string,
agentId: string,
body: RegisterSlackBYORequest,
): Promise<SlackInstallation> {
const search = new URLSearchParams({ agent_id: agentId });
return this.fetch(`/api/workspaces/${workspaceId}/slack/install/byo?${search.toString()}`, {
method: "POST",
body: JSON.stringify(body),
});
}
async deleteSlackInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/slack/installations/${installationId}`, {
method: "DELETE",
});
}
async redeemSlackBindingToken(token: string): Promise<RedeemSlackBindingTokenResponse> {
return this.fetch(`/api/slack/binding/redeem`, {
method: "POST",
body: JSON.stringify({ token }),
});
}
}

View File

@@ -5,7 +5,9 @@ import {
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
DuplicateIssueErrorBodySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
EMPTY_USER,
InboxUnreadSummarySchema,
IssueTriggerPreviewSchema,
ListIssuesResponseSchema,
RuntimeHourlyActivityListSchema,
@@ -414,4 +416,45 @@ describe("AppConfigSchema cdn_signed drift", () => {
const parsed = AppConfigSchema.parse({ cdn_signed: true });
expect(parsed.cdn_signed).toBe(true);
});
});
describe("InboxUnreadSummarySchema", () => {
const ENDPOINT = { endpoint: "GET /api/inbox/unread-summary" };
it("parses a well-formed summary and tolerates extra fields", () => {
const parsed = parseWithFallback(
[
{ workspace_id: "ws-1", count: 2 },
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
],
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
ENDPOINT,
);
expect(parsed).toEqual([
{ workspace_id: "ws-1", count: 2 },
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
]);
});
it("returns the empty fallback (dot hidden) for a non-array body", () => {
expect(
parseWithFallback({ rows: [] }, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
expect(
parseWithFallback(null, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
});
it("returns the empty fallback when an entry has a wrong-typed count", () => {
expect(
parseWithFallback(
[{ workspace_id: "ws-1", count: "lots" }],
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
ENDPOINT,
),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
});
});

View File

@@ -15,6 +15,7 @@ import type {
CreateBillingCheckoutSessionResponse,
CreateBillingPortalSessionResponse,
GroupedIssuesResponse,
InboxWorkspaceUnread,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
SearchIssuesResponse,
@@ -839,6 +840,10 @@ const AutopilotListItemSchema = z.object({
trigger_kinds: z.array(z.string()).optional(),
next_run_at: z.string().nullable().optional(),
last_run_status: z.string().nullable().optional(),
// Per-caller write capability; absent on older servers (treated as unknown).
can_write: z.boolean().optional(),
// Narrower per-caller access-management capability (detail endpoint only).
can_manage_access: z.boolean().optional(),
}).loose();
export const ListAutopilotsResponseSchema = z.object({
@@ -914,6 +919,25 @@ export const EMPTY_USER: User = {
updated_at: "",
};
// ---------------------------------------------------------------------------
// Cross-workspace unread inbox summary (`/api/inbox/unread-summary` GET).
// One entry per workspace the user belongs to that has unread items; the
// sidebar derives the workspace-switcher dot from it. Lenient per the usual
// rules so a future field addition can't blank the dot — on malformed JSON
// parseWithFallback returns the empty list, which simply hides the dot.
// ---------------------------------------------------------------------------
export const InboxUnreadSummarySchema = z.array(
z
.object({
workspace_id: z.string(),
count: z.number(),
})
.loose(),
);
export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = [];
// ---------------------------------------------------------------------------
// Billing schemas (cloud-billing proxy surface)
//

View File

@@ -104,6 +104,30 @@ export function useTriggerAutopilot() {
});
}
export function useGrantAutopilotAccess() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, userId }: { autopilotId: string; userId: string }) =>
api.grantAutopilotAccess(autopilotId, userId),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
},
});
}
export function useRevokeAutopilotAccess() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, userId }: { autopilotId: string; userId: string }) =>
api.revokeAutopilotAccess(autopilotId, userId),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
},
});
}
export function useCreateAutopilotTrigger() {
const qc = useQueryClient();
const wsId = useWorkspaceId();

View File

@@ -14,13 +14,17 @@ export const chatKeys = {
/** Full sessions list (active + archived); the dropdown splits locally. */
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
messagesPage: (sessionId: string) => ["chat", "messages-page", sessionId] as const,
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
messagesAll: () => ["chat", "messages"] as const,
messages: (sessionId: string) => [...chatKeys.messagesAll(), sessionId] as const,
messagesPageAll: () => ["chat", "messages-page"] as const,
messagesPage: (sessionId: string) => [...chatKeys.messagesPageAll(), sessionId] as const,
pendingTaskAll: () => ["chat", "pending-task"] as const,
pendingTask: (sessionId: string) => [...chatKeys.pendingTaskAll(), sessionId] as const,
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
/** Per-task execution messages — shared with issue agent cards. */
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
taskMessagesAll: () => ["task-messages"] as const,
taskMessages: (taskId: string) => [...chatKeys.taskMessagesAll(), taskId] as const,
};
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { InboxItem } from "../types";
import { deduplicateInboxItems } from "./queries";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
import { deduplicateInboxItems, hasOtherWorkspaceUnread, inboxKeys, unreadWorkspaceIds } from "./queries";
function item(overrides: Partial<InboxItem>): InboxItem {
return {
@@ -72,3 +72,83 @@ describe("deduplicateInboxItems", () => {
expect(merged[0]?.details?.comment_id).toBe("comment-2");
});
});
describe("hasOtherWorkspaceUnread", () => {
const summary = (entries: InboxWorkspaceUnread[]) => entries;
it("is true when a workspace other than the active one has unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-2", count: 3 }]),
"ws-1",
),
).toBe(true);
});
it("excludes the active workspace's own unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-1", count: 5 }]),
"ws-1",
),
).toBe(false);
});
it("ignores other workspaces whose count is zero", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-2", count: 0 }]),
"ws-1",
),
).toBe(false);
});
it("is true when at least one non-active workspace has unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([
{ workspace_id: "ws-1", count: 4 },
{ workspace_id: "ws-2", count: 1 },
]),
"ws-1",
),
).toBe(true);
});
it("is false for an empty summary", () => {
expect(hasOtherWorkspaceUnread([], "ws-1")).toBe(false);
});
it("counts every workspace as 'other' when there is no active workspace", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-1", count: 2 }]),
null,
),
).toBe(true);
});
});
describe("unreadWorkspaceIds", () => {
it("collects only workspaces with a non-zero count", () => {
const ids = unreadWorkspaceIds([
{ workspace_id: "ws-1", count: 0 },
{ workspace_id: "ws-2", count: 3 },
{ workspace_id: "ws-3", count: 1 },
]);
expect(ids.has("ws-1")).toBe(false);
expect(ids.has("ws-2")).toBe(true);
expect(ids.has("ws-3")).toBe(true);
expect(ids.size).toBe(2);
});
it("returns an empty set for an empty summary", () => {
expect(unreadWorkspaceIds([]).size).toBe(0);
});
});
describe("inboxKeys.unreadSummary", () => {
it("is a stable account-level key independent of any workspace", () => {
expect(inboxKeys.unreadSummary()).toEqual(["inbox", "unread-summary"]);
});
});

View File

@@ -1,10 +1,13 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem } from "../types";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
// Account-level (not workspace-scoped): a single shared cache entry that
// holds unread counts for every workspace the user belongs to.
unreadSummary: () => ["inbox", "unread-summary"] as const,
};
export function inboxListOptions(wsId: string) {
@@ -14,6 +17,41 @@ export function inboxListOptions(wsId: string) {
});
}
/**
* Cross-workspace unread inbox summary. One cache entry shared across all
* workspaces — the data is account-level, so switching workspaces does not
* refetch it; only the derived "is this for another workspace" view changes.
*/
export function inboxUnreadSummaryOptions() {
return queryOptions({
queryKey: inboxKeys.unreadSummary(),
queryFn: () => api.getInboxUnreadSummary(),
});
}
/**
* Whether any workspace OTHER than `currentWsId` has unread inbox items.
* Drives the workspace-switcher dot: the active workspace's own unread is
* already surfaced by the Inbox nav count, so it is excluded here to avoid a
* duplicate signal.
*/
export function hasOtherWorkspaceUnread(
summary: InboxWorkspaceUnread[],
currentWsId: string | null | undefined,
): boolean {
return summary.some((s) => s.workspace_id !== currentWsId && s.count > 0);
}
/**
* Set of workspace ids that have unread inbox items. Lets the workspace
* switcher dropdown mark WHICH workspace a pending message lives in (the
* aggregate switcher dot only says "somewhere else"). Workspaces with a zero
* count are excluded.
*/
export function unreadWorkspaceIds(summary: InboxWorkspaceUnread[]): Set<string> {
return new Set(summary.filter((s) => s.count > 0).map((s) => s.workspace_id));
}
/**
* Unread inbox count for the given workspace, aligned with what the inbox
* list UI renders: archived items excluded, then deduplicated by issue so a

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { onInboxIssueDeleted, onInboxIssueStatusChanged } from "./ws-updaters";
import { onInboxIssueDeleted, onInboxIssueStatusChanged, onInboxSummaryInvalidate } from "./ws-updaters";
import { inboxKeys } from "./queries";
import type { InboxItem } from "../types";
@@ -56,6 +56,28 @@ describe("onInboxIssueDeleted", () => {
});
});
describe("onInboxSummaryInvalidate", () => {
it("invalidates the account-level summary key regardless of active workspace", () => {
const qc = new QueryClient();
const spy = vi.spyOn(qc, "invalidateQueries");
onInboxSummaryInvalidate(qc);
expect(spy).toHaveBeenCalledWith({ queryKey: inboxKeys.unreadSummary() });
});
it("does not disturb a workspace-scoped inbox list cache", () => {
const qc = new QueryClient();
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), [makeItem("i1", "issue-a")]);
onInboxSummaryInvalidate(qc);
// The list cache entry is untouched (different key); only the summary
// query is marked stale.
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))?.[0]?.id).toBe("i1");
});
});
describe("onInboxIssueStatusChanged", () => {
it("updates issue_status only for items referencing the issue", () => {
const qc = new QueryClient();

View File

@@ -41,3 +41,12 @@ export function onInboxIssueDeleted(
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
}
// Refresh the cross-workspace unread summary (workspace-switcher dot). The
// summary spans every workspace, so it is invalidated on ANY inbox event
// regardless of which workspace the event came from — including read/archive
// events from a workspace other than the active one, which the workspace-
// scoped list invalidation cannot reach.
export function onInboxSummaryInvalidate(qc: QueryClient) {
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
}

View File

@@ -453,6 +453,97 @@ describe("useUpdateIssue — optimistic move keeps every bucketed board in sync"
});
});
describe("useUpdateIssue — detaching a sub-issue prunes the old parent's children cache", () => {
const PARENT_ID = "parent-1";
const childKey = issueKeys.children(WS_ID, PARENT_ID);
let qc: QueryClient;
let updateIssue: ReturnType<typeof vi.fn<(id: string, data: unknown) => Promise<Issue>>>;
function childIds(): string[] {
return (qc.getQueryData<Issue[]>(childKey) ?? []).map((c) => c.id);
}
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
updateIssue = vi.fn();
setApiInstance({ updateIssue } as unknown as ApiClient);
// Seed the detail cache so onMutate resolves the old parent from the
// freshest source, plus the parent's children list rendered by the
// sub-issues section.
const child = makeIssue(1, { parent_issue_id: PARENT_ID, stage: 2 });
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, child.id), child);
qc.setQueryData<Issue[]>(childKey, [
child,
makeIssue(2, { parent_issue_id: PARENT_ID }),
]);
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("optimistically removes the issue from the old parent's children array", async () => {
let resolve!: (issue: Issue) => void;
updateIssue.mockReturnValue(
new Promise<Issue>((r) => {
resolve = r;
}),
);
const { result } = renderHook(() => useUpdateIssue(), {
wrapper: createWrapper(qc),
});
act(() => {
result.current.mutate({ id: "issue-1", parent_issue_id: null, stage: null });
});
// Pruned immediately so the parent's sub-issues list drops it now, not
// after the settle refetch; the sibling is untouched.
expect(childIds()).toEqual(["issue-2"]);
await act(async () => {
resolve(makeIssue(1, { parent_issue_id: null, stage: null }));
});
expect(childIds()).not.toContain("issue-1");
});
it("restores the old parent's children when the request fails", async () => {
updateIssue.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useUpdateIssue(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current
.mutateAsync({ id: "issue-1", parent_issue_id: null, stage: null })
.catch(() => {});
});
expect(childIds()).toEqual(["issue-1", "issue-2"]);
});
it("keeps the issue under its parent for a non-reparenting update", async () => {
updateIssue.mockResolvedValue(
makeIssue(1, { parent_issue_id: PARENT_ID, status: "done" }),
);
const { result } = renderHook(() => useUpdateIssue(), {
wrapper: createWrapper(qc),
});
act(() => {
result.current.mutate({ id: "issue-1", status: "done" });
});
// A status-only change patches in place — never prunes the relationship.
expect(childIds()).toEqual(["issue-1", "issue-2"]);
});
});
describe("useBatchUpdateIssues — optimistic patch covers filtered boards too", () => {
const sort: IssueSortParam = { sort_by: "position", sort_direction: undefined };
const myScope = "assigned";

View File

@@ -278,10 +278,21 @@ export function useUpdateIssue() {
old ? { ...old, ...patch } : old,
);
if (parentId) {
// When the write re-parents this issue away from `parentId` (detach
// to standalone, or move under a different parent), prune it from the
// old parent's children cache. The parent's sub-issues list renders
// that array directly, so a bare patch to parent_issue_id: null would
// leave an orphaned row in the list until the settle refetch lands.
// onError restores prevChildren, so the prune rolls back on failure.
const detachedFromParent =
Object.prototype.hasOwnProperty.call(patch, "parent_issue_id") &&
patch.parent_issue_id !== parentId;
qc.setQueryData<Issue[]>(
issueKeys.children(wsId, parentId),
(old) =>
old?.map((c) => (c.id === id ? { ...c, ...patch } : c)),
detachedFromParent
? old?.filter((c) => c.id !== id)
: old?.map((c) => (c.id === id ? { ...c, ...patch } : c)),
);
}
return { prevLists, prevDetail, prevChildren, parentId, id };

View File

@@ -16,6 +16,14 @@ export {
needsSourceBackfill,
SOURCE_BACKFILL_MAX_DISMISSALS,
} from "./needs-backfill";
export {
OFFICIAL_MULTICA_API_URL,
currentApiBaseUrl,
isOfficialMulticaApiUrl,
isSelfHostedApiBaseUrl,
normalizeApiBaseUrl,
shouldShowSourceChannelReporting,
} from "./source-reporting";
export { recommendTemplate, type AgentTemplateId } from "./recommend-template";
export {
useWelcomeStore,

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
OFFICIAL_MULTICA_API_URL,
isOfficialMulticaApiUrl,
isSelfHostedApiBaseUrl,
normalizeApiBaseUrl,
shouldShowSourceChannelReporting,
} from "./source-reporting";
describe("source channel API-url detection", () => {
it("treats only the official API URL as official", () => {
expect(isOfficialMulticaApiUrl(OFFICIAL_MULTICA_API_URL)).toBe(true);
expect(isOfficialMulticaApiUrl("https://api.multica.ai/")).toBe(true);
expect(isOfficialMulticaApiUrl("https://api.example.com")).toBe(false);
expect(isOfficialMulticaApiUrl("https://multica.ai")).toBe(false);
expect(isOfficialMulticaApiUrl("http://api.multica.ai")).toBe(false);
});
it("normalizes harmless URL drift before comparing", () => {
expect(normalizeApiBaseUrl(" https://API.MULTICA.AI/ ")).toBe(
OFFICIAL_MULTICA_API_URL,
);
expect(normalizeApiBaseUrl("https://api.multica.ai?x=1#hash")).toBe(
OFFICIAL_MULTICA_API_URL,
);
});
it("shows source reporting only for non-official API URLs", () => {
expect(isSelfHostedApiBaseUrl(OFFICIAL_MULTICA_API_URL)).toBe(false);
expect(isSelfHostedApiBaseUrl("https://api.customer.example")).toBe(true);
expect(shouldShowSourceChannelReporting(OFFICIAL_MULTICA_API_URL)).toBe(
false,
);
expect(shouldShowSourceChannelReporting("https://api.customer.example")).toBe(
true,
);
});
});

View File

@@ -0,0 +1,46 @@
import { api } from "../api";
export const OFFICIAL_MULTICA_API_URL = "https://api.multica.ai";
export function normalizeApiBaseUrl(value: string | null | undefined): string {
const raw = value?.trim();
if (!raw) return "";
try {
const url = new URL(raw);
url.search = "";
url.hash = "";
url.pathname = url.pathname.replace(/\/+$/, "");
return url.toString().replace(/\/+$/, "");
} catch {
return "";
}
}
export function isOfficialMulticaApiUrl(
apiBaseUrl: string | null | undefined,
): boolean {
return normalizeApiBaseUrl(apiBaseUrl) === OFFICIAL_MULTICA_API_URL;
}
export function isSelfHostedApiBaseUrl(
apiBaseUrl: string | null | undefined,
): boolean {
return !isOfficialMulticaApiUrl(apiBaseUrl);
}
export function currentApiBaseUrl(): string {
const configured = api.getBaseUrl?.();
if (typeof configured === "string" && configured.trim() !== "") {
return configured;
}
if (typeof window !== "undefined") {
return window.location.origin;
}
return "";
}
export function shouldShowSourceChannelReporting(
apiBaseUrl = currentApiBaseUrl(),
): boolean {
return isSelfHostedApiBaseUrl(apiBaseUrl);
}

View File

@@ -84,6 +84,7 @@ export interface QuestionnaireAnswers {
source: Source[];
source_other: string | null;
source_skipped: boolean;
source_domain_consent: boolean;
role: Role | null;
role_other: string | null;
role_skipped: boolean;

View File

@@ -85,6 +85,8 @@
"./github/queries": "./github/queries.ts",
"./lark": "./lark/index.ts",
"./lark/queries": "./lark/queries.ts",
"./slack": "./slack/index.ts",
"./slack/queries": "./slack/queries.ts",
"./feedback": "./feedback/index.ts",
"./feedback/mutations": "./feedback/mutations.ts",
"./realtime": "./realtime/index.ts",

View File

@@ -6,6 +6,7 @@ import { installFreezeWatchdog } from "../diagnostics/freeze-watchdog";
import { setApiInstance, setSchemaLogger } from "../api";
import { createAuthStore, registerAuthStore } from "../auth";
import { createChatStore, registerChatStore } from "../chat";
import { shouldShowSourceChannelReporting } from "../onboarding/source-reporting";
import {
I18nProvider,
LocaleAdapterProvider,
@@ -43,6 +44,10 @@ function initCore(
});
setApiInstance(api);
setSchemaLogger(createLogger("api-schema"));
console.info(
"[Multica] self-hosted API:",
shouldShowSourceChannelReporting(),
);
// In token mode, hydrate token from storage.
if (!cookieAuth) {

View File

@@ -102,9 +102,9 @@ describe("useRealtimeSync — ws instance change", () => {
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
// = 22 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(22);
// (15 workspace-scoped + 6 per-issue prefixes + 4 per-chat prefixes
// + 1 workspaceKeys.list() + 1 cross-workspace inbox unread summary = 27 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(27);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {
@@ -164,4 +164,26 @@ describe("useRealtimeSync — ws instance change", () => {
expect(calls).toContainEqual(["issues", "attachments"]);
expect(calls).toContainEqual(["issues", "tasks"]);
});
it("invalidates per-chat-session caches (no wsId in key) on ws instance change", () => {
// These keys are not under the ["chat", wsId] prefix, so they need their
// own recovery invalidation when reconnecting after missed chat/task events.
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
const ws2 = createMockWs();
rerender({ ws: ws2 });
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
expect(calls).toContainEqual(["chat", "messages"]);
expect(calls).toContainEqual(["chat", "messages-page"]);
expect(calls).toContainEqual(["chat", "pending-task"]);
expect(calls).toContainEqual(["task-messages"]);
});
});

View File

@@ -23,6 +23,7 @@ import {
} from "../agents/queries";
import { githubKeys } from "../github/queries";
import { larkKeys } from "../lark/queries";
import { slackKeys } from "../slack/queries";
import {
onIssueCreated,
onIssueUpdated,
@@ -30,7 +31,7 @@ import {
onIssueLabelsChanged,
onIssueMetadataChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxSummaryInvalidate } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import {
notificationPreferenceOptions,
@@ -230,6 +231,9 @@ export async function handleInboxNew(
): Promise<void> {
const sourceWsId = item.workspace_id;
if (sourceWsId) onInboxNew(qc, sourceWsId, item);
// A new item in ANY workspace can light the workspace-switcher dot, so
// refresh the cross-workspace summary regardless of the active workspace.
onInboxSummaryInvalidate(qc);
// Fire a native OS notification only when the app isn't focused. When
// the user is already looking at Multica, the inbox sidebar's unread
// styling is enough — no need to interrupt with a banner. `desktopAPI`
@@ -320,6 +324,9 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: chatKeys.all(wsId) });
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
}
// Cross-workspace, so outside the wsId guard: a reconnect may have missed
// inbox events from any workspace, so re-pull the switcher-dot summary.
onInboxSummaryInvalidate(qc);
// Per-issue caches are keyed without wsId, so the issueKeys.all(wsId)
// prefix above does not reach them. They rely entirely on WS events for
// freshness (staleTime: Infinity), so events missed while disconnected
@@ -333,6 +340,14 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: issueKeys.usageAll() });
qc.invalidateQueries({ queryKey: issueKeys.attachmentsAll() });
qc.invalidateQueries({ queryKey: issueKeys.tasksAll() });
// Per-chat-session caches are also keyed without wsId, so the
// chatKeys.all(wsId) prefix above only reaches session lists / aggregates.
// Message streams rely on WS invalidation with staleTime: Infinity; recover
// sessions that missed chat/task events while the socket was disconnected.
qc.invalidateQueries({ queryKey: chatKeys.messagesAll() });
qc.invalidateQueries({ queryKey: chatKeys.messagesPageAll() });
qc.invalidateQueries({ queryKey: chatKeys.pendingTaskAll() });
qc.invalidateQueries({ queryKey: chatKeys.taskMessagesAll() });
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
}
@@ -394,6 +409,12 @@ export function useRealtimeSync(
inbox: () => {
const wsId = getCurrentWsId();
if (wsId) onInboxInvalidate(qc, wsId);
// inbox:read / inbox:archived / batch events arrive here. They can
// originate from a workspace other than the active one (personal
// events fan out to all the user's connections), so always refresh
// the cross-workspace summary — its dot must clear when another
// workspace's items are read/archived.
onInboxSummaryInvalidate(qc);
},
agent: () => {
const wsId = getCurrentWsId();
@@ -472,6 +493,10 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: larkKeys.installations(wsId) });
},
slack_installation: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
},
pull_request: () => {
// PR list is keyed by issue id, not workspace, so we invalidate all
// PR queries — the open issue detail page will refetch its own list.

View File

@@ -0,0 +1 @@
export { slackKeys, slackInstallationsOptions } from "./queries";

View File

@@ -0,0 +1,18 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
/** Query key namespace for everything Slack-installation-related. Realtime
* sync invalidates `installations(wsId)` on `slack_installation:*` events so
* the Settings panel updates without a manual refetch (e.g. after the OAuth
* callback lands the install in another tab / the system browser). */
export const slackKeys = {
all: (wsId: string) => ["slack", wsId] as const,
installations: (wsId: string) => [...slackKeys.all(wsId), "installations"] as const,
};
export const slackInstallationsOptions = (wsId: string) =>
queryOptions({
queryKey: slackKeys.installations(wsId),
queryFn: () => api.listSlackInstallations(wsId),
enabled: !!wsId,
});

View File

@@ -49,6 +49,16 @@ export interface Autopilot {
// List endpoint returns []; only the detail endpoint populates this.
// Treat undefined as empty on older servers.
subscribers?: AutopilotSubscriber[];
// Whether the requesting user may edit / delete / trigger / manage this
// autopilot (creator, workspace owner/admin, or a granted collaborator).
// Present on list and detail responses; absent on older servers — treat
// undefined as "unknown" rather than "denied" (the server is the gate).
can_write?: boolean;
// Whether the requesting user may manage the collaborator (access) list —
// narrower than can_write: held only by the creator and workspace
// owners/admins, NOT by granted collaborators. Detail-endpoint-only; absent
// on older servers (fall back to can_write).
can_manage_access?: boolean;
}
export interface WebhookEventFilter {
@@ -62,6 +72,19 @@ export interface AutopilotSubscriber {
created_at: string;
}
// A workspace member explicitly granted write access to an autopilot, on top
// of the implicit "creator owner/admin" set. Members-only for now.
export interface AutopilotCollaborator {
user_type: "member";
user_id: string;
granted_by: string;
created_at: string;
}
export interface AutopilotCollaboratorsResponse {
collaborators: AutopilotCollaborator[];
}
export interface AutopilotTrigger {
id: string;
autopilot_id: string;
@@ -164,6 +187,9 @@ export interface ListAutopilotsResponse {
export interface GetAutopilotResponse {
autopilot: Autopilot;
triggers: AutopilotTrigger[];
// Members explicitly granted write access. Absent on older servers — treat
// undefined as an empty list.
collaborators?: AutopilotCollaborator[];
}
export interface ListAutopilotRunsResponse {

View File

@@ -22,6 +22,17 @@ export type InboxItemType =
| "quick_create_done"
| "quick_create_failed";
/**
* One workspace's unread inbox count in the cross-workspace summary
* (`GET /api/inbox/unread-summary`). The sidebar uses this to light a dot on
* the workspace switcher when a workspace OTHER than the active one has
* unread items.
*/
export interface InboxWorkspaceUnread {
workspace_id: string;
count: number;
}
export interface InboxItem {
id: string;
workspace_id: string;

View File

@@ -61,7 +61,7 @@ export type {
} from "./agent";
export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread } from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, Reaction } from "./comment";
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
@@ -119,6 +119,12 @@ export type {
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
} from "./lark";
export type {
SlackInstallation,
ListSlackInstallationsResponse,
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
} from "./slack";
export type {
Autopilot,
AutopilotStatus,
@@ -126,6 +132,8 @@ export type {
AutopilotAssigneeType,
AutopilotSubscriber,
AutopilotSubscriberInput,
AutopilotCollaborator,
AutopilotCollaboratorsResponse,
AutopilotTrigger,
AutopilotTriggerKind,
AutopilotRun,

View File

@@ -0,0 +1,50 @@
/** A Slack bot installation bound to a single Multica agent (MUL-3666).
*
* Wire shape mirrors `SlackInstallationResponse` in
* `server/internal/handler/slack.go`. New fields the backend adds in the
* future MUST default to optional so older desktop builds keep parsing the
* response — see CLAUDE.md → API Compatibility. */
export interface SlackInstallation {
id: string;
workspace_id: string;
agent_id: string;
/** The Slack workspace (team) id this bot is installed in. */
team_id: string;
/** The installed bot's Slack user id. */
bot_user_id: string;
installer_user_id: string;
status: "active" | "revoked" | string;
installed_at: string;
created_at: string;
updated_at: string;
}
export interface ListSlackInstallationsResponse {
installations: SlackInstallation[];
/** Whether the deployment has the at-rest secret key configured. When false
* the connect entry points are hidden and the panel renders an "ask the
* operator to enable Slack" state. */
configured: boolean;
/** Whether the install path is available (true whenever Slack is configured,
* i.e. the at-rest key is set — a bring-your-own-app install needs no hosted
* OAuth credentials). Kept as a separate flag for forward/backward compat;
* optional so an older desktop build that predates it treats it as off. */
install_supported?: boolean;
}
/** Request body for a bring-your-own-app (BYO) install: the two tokens the
* admin pastes from the Slack app they created. The backend validates that both
* belong to the same Slack app (and that the app token is live) before
* persisting, then returns the created SlackInstallation. */
export interface RegisterSlackBYORequest {
bot_token: string;
app_token: string;
}
/** Post-redemption echo: the Slack user id the token carried is now bound to
* the logged-in Multica user in this workspace/installation. */
export interface RedeemSlackBindingTokenResponse {
workspace_id: string;
installation_id: string;
slack_user_id: string;
}

View File

@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { cleanup, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentTask } from "@multica/core/types";
import { renderWithI18n } from "../../test/i18n";
// The hover card renders one row per task and counts tasks, so its header
// must describe tasks — not agents. A single agent can run several tasks at
// once (e.g. the workspace chip reads "2 working" for two unique agents while
// the card lists three task rows). An agent-worded header here would print
// "3 agents working" for those two agents, contradicting the chip. MUL-3872.
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getActorName: (_type: string, id: string) =>
({ "agent-1": "Niko", "agent-2": "J" })[id] ?? "Unknown Agent",
getActorInitials: (_type: string, id: string) =>
({ "agent-1": "NI", "agent-2": "J" })[id] ?? "UA",
getActorAvatarUrl: () => null,
}),
}));
// The card only reads these query results for avatars / availability, never
// for the header count, so empty lists keep the row chrome inert while the
// header still derives from the task array.
vi.mock("@multica/core/runtimes/queries", () => ({
runtimeListOptions: () => ({ queryKey: ["runtimes"] }),
}));
vi.mock("@multica/core/workspace/queries", () => ({
agentListOptions: () => ({ queryKey: ["agents"] }),
}));
vi.mock("@multica/core/agents", () => ({
deriveAgentAvailability: () => "online",
}));
vi.mock("@multica/ui/components/common/actor-avatar", () => ({
ActorAvatar: ({ name }: { name: string }) => (
<span data-testid="actor-avatar">{name}</span>
),
}));
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return { ...actual, useQuery: () => ({ data: [] }) };
});
import { AgentActivityHoverContent } from "./agent-activity-hover-content";
function makeTask(overrides: Partial<AgentTask>): AgentTask {
return {
id: "task-1",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "issue-1",
status: "running",
priority: 0,
dispatched_at: null,
started_at: "2026-06-08T08:00:00Z",
completed_at: null,
result: null,
error: null,
created_at: "2026-06-08T08:00:00Z",
...overrides,
};
}
afterEach(cleanup);
describe("AgentActivityHoverContent", () => {
// Two agents, three running tasks (Niko runs two at once). The header must
// count the three task rows, not the two agents.
const threeTasksTwoAgents = [
makeTask({ id: "t1", agent_id: "agent-1" }),
makeTask({ id: "t2", agent_id: "agent-1" }),
makeTask({ id: "t3", agent_id: "agent-2" }),
];
it("counts tasks, not agents, in the header", () => {
renderWithI18n(<AgentActivityHoverContent tasks={threeTasksTwoAgents} />);
expect(screen.getByText("3 tasks working")).toBeInTheDocument();
// The old agent-worded copy would have read "3 agents working" here and
// disagreed with the chip's unique-agent count.
expect(screen.queryByText(/agents? working/)).not.toBeInTheDocument();
// One row per task — three avatars for three tasks.
expect(screen.getAllByTestId("actor-avatar")).toHaveLength(3);
});
it("uses the singular task copy for a single task", () => {
renderWithI18n(<AgentActivityHoverContent tasks={[makeTask({})]} />);
expect(screen.getByText("1 task working")).toBeInTheDocument();
});
it("renders the requested Chinese task copy", () => {
renderWithI18n(<AgentActivityHoverContent tasks={threeTasksTwoAgents} />, {
locale: "zh-Hans",
});
expect(screen.getByText("3 个 task 工作中")).toBeInTheDocument();
});
});

View File

@@ -61,7 +61,11 @@ export function AgentActivityHoverContent({
return (
<div className="flex flex-col gap-2">
<div className="text-xs font-medium text-muted-foreground">
{t(($) => $.agent_activity.hover_header, { count: tasks.length })}
{/* One row per task, so count tasks — not agents. A single agent can
run several tasks at once, so an agent-worded header here would
disagree with the workspace chip's unique-agent count (e.g. chip
"2 working" but header "3 agents working"). */}
{t(($) => $.agent_activity.hover_header_tasks, { count: tasks.length })}
</div>
<div className="flex flex-col gap-1.5">
{tasks.map((task) => {

View File

@@ -47,6 +47,7 @@ import { SkillAttach } from "./inspector/skill-attach";
import { ThinkingPropRow } from "./inspector/thinking-prop-row";
import { VisibilityPicker } from "./inspector/visibility-picker";
import { LarkAgentBindButton } from "../../settings/components/lark-tab";
import { SlackAgentBindButton } from "../../settings/components/slack-tab";
interface InspectorProps {
agent: Agent;
@@ -215,13 +216,12 @@ export function AgentDetailInspector({
</div>
{/* Integrations — surfaces external-channel bind entry points
(Lark Bot today; Slack / Discord in the future). The bind
button self-hides when the server-side device-flow install
capability gate is closed, so this section may render empty
on deployments without a configured Lark app — that's
intentional and matches the "don't surface a flow that will
fail" guarantee. We only mount it for editors: viewers
shouldn't see a CTA they can't action. */}
(Lark + Slack today; Discord in the future). Each bind button
self-hides when its server-side install capability gate is
closed, so this section may render empty on deployments without
a configured channel — that's intentional and matches the
"don't surface a flow that will fail" guarantee. We only mount
it for editors: viewers shouldn't see a CTA they can't action. */}
{canEdit && (
<div className="flex flex-col px-5 py-4">
<div className="mb-2 flex items-center gap-2">
@@ -235,6 +235,11 @@ export function AgentDetailInspector({
agentName={agent.name}
onShowConnectedDetails={onShowIntegrations}
/>
<SlackAgentBindButton
agentId={agent.id}
agentName={agent.name}
onShowConnectedDetails={onShowIntegrations}
/>
</div>
</div>
)}

View File

@@ -182,8 +182,9 @@ export function AgentListToolbar({
);
return (
<div className="flex h-12 shrink-0 items-center justify-between gap-2 px-5">
{/* Left: scope buttons + result count. Scope mixes the ownership lens
<div className="h-12 shrink-0 overflow-x-auto px-5 [-webkit-overflow-scrolling:touch]">
<div className="flex h-full w-max min-w-full items-center justify-between gap-2">
{/* Left: scope buttons + result count. Scope mixes the ownership lens
(mine/all) with the archived lifecycle stage; no search box (scope
partitions the small set). Button styling and the <md dropdown
collapse follow the issues header's scope buttons. */}
@@ -546,6 +547,7 @@ export function AgentListToolbar({
</PopoverContent>
</Popover>
</div>
</div>
</div>
);
}

View File

@@ -45,6 +45,9 @@ vi.mock("../../common/actor-issues-panel", () => ({
const larkListingRef = vi.hoisted(() => ({
current: { installations: [] as unknown[], configured: false },
}));
const slackListingRef = vi.hoisted(() => ({
current: { installations: [] as unknown[], configured: false },
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
@@ -54,6 +57,12 @@ vi.mock("@multica/core/lark", () => ({
queryFn: () => Promise.resolve(larkListingRef.current),
}),
}));
vi.mock("@multica/core/slack", () => ({
slackInstallationsOptions: () => ({
queryKey: ["slack", "installations"],
queryFn: () => Promise.resolve(slackListingRef.current),
}),
}));
import { AgentOverviewPane } from "./agent-overview-pane";
@@ -119,6 +128,7 @@ function renderPane(runtimes: AgentRuntime[]) {
beforeEach(() => {
larkListingRef.current = { installations: [], configured: false };
slackListingRef.current = { installations: [], configured: false };
});
describe("AgentOverviewPane MCP tab visibility", () => {
@@ -163,9 +173,19 @@ describe("AgentOverviewPane Integrations tab visibility", () => {
).toBeInTheDocument();
});
it("hides the Integrations tab when Lark is not configured", () => {
// Default ref is configured:false; the tab must not appear on
// deployments without the integration, which are the common case.
it("shows the Integrations tab when only Slack is configured (Lark off)", async () => {
// Regression: the tab gate must consider Slack too, not just Lark —
// a Slack-only deployment was hiding the tab (and its bind entry).
slackListingRef.current = { installations: [], configured: true };
renderPane([makeRuntime("claude")]);
expect(
await screen.findByRole("button", { name: /^Integrations$/i }),
).toBeInTheDocument();
});
it("hides the Integrations tab when neither Lark nor Slack is configured", () => {
// Default refs are configured:false; the tab must not appear on
// deployments without either integration, the common case.
renderPane([makeRuntime("claude")]);
expect(
screen.queryByRole("button", { name: /^Integrations$/i }),

View File

@@ -17,6 +17,7 @@ import type { Agent, AgentRuntime } from "@multica/core/types";
import { providerSupportsMcpConfig } from "@multica/core/agents";
import { useWorkspaceId } from "@multica/core/hooks";
import { larkInstallationsOptions } from "@multica/core/lark";
import { slackInstallationsOptions } from "@multica/core/slack";
import {
AlertDialog,
AlertDialogAction,
@@ -141,16 +142,24 @@ export function AgentOverviewPane({
enabled: !!wsId,
});
const larkConfigured = larkListing?.configured === true;
const { data: slackListing } = useQuery({
...slackInstallationsOptions(wsId),
enabled: !!wsId,
});
const slackConfigured = slackListing?.configured === true;
// The Integrations tab appears once EITHER channel is wired on the
// deployment, so a Slack-only deployment (no Lark) still surfaces it.
const integrationsConfigured = larkConfigured || slackConfigured;
// The MCP tab is only shown when the agent's runtime backend actually
// consumes mcp_config — see providerSupportsMcpConfig. We default to
// showing it when the runtime row hasn't loaded yet so a slow fetch
// can't transiently flicker the tab off and then on.
//
// The Integrations tab only appears once the deployment has Lark wired
// The Integrations tab appears once the deployment has Lark OR Slack wired
// (configured). Unlike MCP we default to HIDING while the listing loads:
// deployments without Lark are the common case, so flashing the tab on
// then off would be the worse flicker.
// deployments without either channel are the common case, so flashing the
// tab on then off would be the worse flicker.
//
// The Runtime Config tab is openclaw-only today (gateway mode lives there,
// issue #3260). Other providers' runtime_config is freeform JSONB that no
@@ -161,11 +170,11 @@ export function AgentOverviewPane({
const showRuntimeConfig = runtime ? runtime.provider === "openclaw" : false;
return detailTabs.filter((tab) => {
if (tab.id === "mcp_config") return showMcp;
if (tab.id === "integrations") return larkConfigured;
if (tab.id === "integrations") return integrationsConfigured;
if (tab.id === "runtime_config") return showRuntimeConfig;
return true;
});
}, [runtime, larkConfigured]);
}, [runtime, integrationsConfigured]);
// If the active tab disappears (e.g. user just switched the agent's
// runtime to one that doesn't read mcp_config), fall back to Activity

View File

@@ -138,7 +138,7 @@ export function AgentRowActions({
<button
type="button"
aria-label={t(($) => $.row.actions_aria)}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/row:opacity-100 data-popup-open:bg-accent data-popup-open:opacity-100 data-popup-open:text-accent-foreground"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-100 @2xl:opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/row:opacity-100 data-popup-open:bg-accent data-popup-open:opacity-100 data-popup-open:text-accent-foreground"
>
<MoreHorizontal className="size-4" />
</button>

View File

@@ -91,8 +91,8 @@ import { useT } from "../../i18n";
// the TWO-LINE form: avatar left, name + description right, 64px tall —
// the documented exception to the single-line management-list rule.
const GRID_COLS =
"grid-cols-[0.75rem_1rem_minmax(120px,1fr)_var(--agc-status)_1.75rem_0.75rem] " +
"@2xl:grid-cols-[0.75rem_1rem_minmax(200px,1fr)_var(--agc-status)_var(--agc-owner)_var(--agc-runtime)_var(--agc-lastactive)_var(--agc-runs)_var(--agc-model)_var(--agc-created)_1.75rem_0.75rem]";
"grid-cols-[0.75rem_minmax(120px,1fr)_var(--agc-status-mobile)_1.75rem_0.75rem] " +
"@2xl:grid-cols-[0.75rem_1rem_minmax(200px,1fr)_var(--agc-status-desktop)_var(--agc-owner)_var(--agc-runtime)_var(--agc-lastactive)_var(--agc-runs)_var(--agc-model)_var(--agc-created)_1.75rem_0.75rem]";
// Two-line rows; the virtualizer's fixed-size contract.
const ROW_HEIGHT = 64;
@@ -128,7 +128,8 @@ function columnTrackVars(
0,
);
return {
"--agc-status": width("status"),
"--agc-status-mobile": isVisible("status") ? "96px" : "0px",
"--agc-status-desktop": width("status"),
"--agc-owner": width("owner"),
"--agc-runtime": width("runtime"),
"--agc-lastactive": width("lastActive"),
@@ -290,7 +291,7 @@ function CheckboxCell({
onToggle: () => void;
}) {
return (
<ListGridCell className="justify-center px-0">
<ListGridCell className="hidden justify-center px-0 @2xl:flex">
<button
type="button"
aria-pressed={checked}
@@ -488,7 +489,7 @@ function AgentListHeader({
const anySelected = allSelected || someSelected;
return (
<ListGridHeader>
<div className="flex items-center justify-center">
<div className="hidden items-center justify-center @2xl:flex">
<button
type="button"
aria-pressed={allSelected}
@@ -584,7 +585,7 @@ function LoadingSkeleton() {
)}
>
<ListGridHeader>
<span aria-hidden="true" />
<span aria-hidden="true" className="hidden @2xl:inline" />
<ListGridHeaderCell>
<Skeleton className="h-3 w-12" />
</ListGridHeaderCell>
@@ -609,7 +610,7 @@ function LoadingSkeleton() {
</ListGridHeader>
{Array.from({ length: 5 }).map((_, i) => (
<ListGridRow key={i} className="h-16 hover:bg-transparent">
<span aria-hidden="true" />
<span aria-hidden="true" className="hidden @2xl:inline" />
<ListGridCell className="gap-3">
<Skeleton className="size-8 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">

View File

@@ -35,6 +35,7 @@ vi.mock("@tanstack/react-query", () => ({
if (key.includes("installations")) return { data: installationsRef.current };
return { data: undefined };
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
queryOptions: <T,>(opts: T) => opts,
}));
@@ -53,6 +54,13 @@ vi.mock("@multica/core/lark", () => ({
}),
}));
vi.mock("@multica/core/slack", () => ({
slackInstallationsOptions: () => ({
queryKey: ["slack", "installations"],
queryFn: vi.fn(),
}),
}));
vi.mock("@multica/core/auth", () => {
const useAuthStore = Object.assign(
(sel?: (s: { user: { id: string } }) => unknown) =>
@@ -68,6 +76,14 @@ vi.mock("../../../settings/components/lark-tab", () => ({
),
}));
// SlackAgentBindButton is the shared bind entry covered in slack-tab.test.tsx;
// here it is a marker so the tests assert branch selection, not the OAuth flow.
vi.mock("../../../settings/components/slack-tab", () => ({
SlackAgentBindButton: ({ agentId }: { agentId: string }) => (
<div data-testid="slack-bind-button" data-agent-id={agentId} />
),
}));
import { IntegrationsTab } from "./integrations-tab";
const TEST_RESOURCES = {
@@ -118,11 +134,12 @@ function resetFixtures() {
describe("IntegrationsTab", () => {
beforeEach(resetFixtures);
it("renders the shared bind entry for an owner when Lark is configured and supported", () => {
it("renders the shared bind entry for both platforms for an owner when configured and supported", () => {
renderTab(<IntegrationsTab agent={agent} />);
expect(screen.getByText("Lark")).toBeTruthy();
const button = screen.getByTestId("lark-bind-button");
expect(button.getAttribute("data-agent-id")).toBe("agent-1");
expect(screen.getByText("Slack")).toBeTruthy();
expect(screen.getByTestId("lark-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
expect(screen.getByTestId("slack-bind-button").getAttribute("data-agent-id")).toBe("agent-1");
});
it("shows the coming-soon notice when the install transport is not wired", () => {
@@ -147,13 +164,16 @@ describe("IntegrationsTab", () => {
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
});
it("points members at Settings instead of a dead button when they can't manage", () => {
it("points members at Settings with one role notice (not per-platform) when they can't manage", () => {
membersRef.current = [{ user_id: "user-1", role: "member" }];
renderTab(<IntegrationsTab agent={agent} />);
// The role gate is hoisted above the per-platform sections, so the notice
// appears exactly once and neither bind entry renders.
expect(
screen.getByText(/Only workspace owners and admins can bind a Lark Bot/i),
screen.getByText(/Only workspace owners and admins can connect an agent/i),
).toBeTruthy();
expect(screen.queryByTestId("lark-bind-button")).toBeNull();
expect(screen.queryByTestId("slack-bind-button")).toBeNull();
});
it("renders the bind entry (not coming-soon) when installs are unavailable but the agent is already bound", () => {

View File

@@ -1,13 +1,15 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Webhook } from "lucide-react";
import { MessagesSquare, Webhook } from "lucide-react";
import type { Agent } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { larkInstallationsOptions } from "@multica/core/lark";
import { slackInstallationsOptions } from "@multica/core/slack";
import { memberListOptions } from "@multica/core/workspace/queries";
import { LarkAgentBindButton } from "../../../settings/components/lark-tab";
import { SlackAgentBindButton } from "../../../settings/components/slack-tab";
import { useT } from "../../../i18n";
/**
@@ -37,6 +39,10 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
...larkInstallationsOptions(wsId),
enabled: !!wsId,
});
const { data: slackListing } = useQuery({
...slackInstallationsOptions(wsId),
enabled: !!wsId,
});
const { data: members = [] } = useQuery({
...memberListOptions(wsId),
enabled: !!wsId,
@@ -52,6 +58,30 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
(inst) => inst.agent_id === agent.id && inst.status === "active",
) ?? false;
const slackConfigured = slackListing?.configured === true;
const slackInstallSupported = slackListing?.install_supported === true;
const slackHasActiveInstall =
slackListing?.installations.some(
(inst) => inst.agent_id === agent.id && inst.status === "active",
) ?? false;
// Install / manage is gated on workspace owner/admin for every platform, so
// the role notice is hoisted above the per-platform sections — one note
// instead of repeating it under each integration. Members can still view
// connected bots in the (member-visible) Settings → Integrations listing.
if (!canManage) {
return (
<div className="space-y-6">
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.integrations.intro)}
</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.integrations.members_note)}
</p>
</div>
);
}
return (
<div className="space-y-6">
<p className="text-xs text-muted-foreground">
@@ -78,14 +108,6 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
<p className="text-xs text-muted-foreground">
{ts(($) => $.lark.not_enabled_title)}
</p>
) : !canManage ? (
// The backend gates install / manage on workspace owner/admin.
// Members can still view connected bots in the (member-visible)
// Settings listing, so point them there rather than show a dead
// button.
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.integrations.members_note)}
</p>
) : !installSupported && !hasActiveInstall ? (
// Key is set but the device-flow transport isn't wired in this
// build — a fresh scan would fail at the post-poll bot-info step,
@@ -107,6 +129,39 @@ export function IntegrationsTab({ agent }: { agent: Agent }) {
)}
</div>
</section>
<section className="rounded-lg border">
<div className="flex items-start gap-3 p-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-muted-foreground">
<MessagesSquare className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-sm font-medium">{ts(($) => $.slack.section_title)}</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{ts(($) => $.slack.page_description)}
</p>
</div>
</div>
<div className="border-t px-4 py-3">
{!slackConfigured ? (
<p className="text-xs text-muted-foreground">
{ts(($) => $.slack.not_enabled_title)}
</p>
) : !slackInstallSupported && !slackHasActiveInstall ? (
// Secret key is set but the OAuth client credentials aren't, so a
// fresh "Connect Slack" would 503. Surface the "coming soon" notice
// instead of a broken CTA; an already-bound agent still renders.
<div className="space-y-1">
<p className="text-xs font-medium">{ts(($) => $.slack.preview_title)}</p>
<p className="text-xs text-muted-foreground">
{ts(($) => $.slack.preview_description)}
</p>
</div>
) : (
<SlackAgentBindButton agentId={agent.id} agentName={agent.name} />
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useMemo, useState } from "react";
import { Plus, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { useActorName } from "@multica/core/workspace/hooks";
import {
useGrantAutopilotAccess,
useRevokeAutopilotAccess,
} from "@multica/core/autopilots/mutations";
import type { AutopilotCollaborator } from "@multica/core/types";
import { toast } from "sonner";
import { ActorAvatar } from "../../common/actor-avatar";
import {
PropertyPicker,
PickerItem,
PickerEmpty,
} from "../../issues/components/pickers/property-picker";
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
import { useT } from "../../i18n";
// Grant / revoke explicit write access to an autopilot. Members-only, mirroring
// the subscriber picker. Creators and workspace admins always have access and
// are not listed here — this manages the additional, explicitly-granted set.
// Rendered inside the edit dialog's "Manage access" popover; access changes
// commit immediately via their own mutations and are independent of the form's
// Save action.
export function AutopilotAccessManager({
autopilotId,
collaborators,
}: {
autopilotId: string;
collaborators: AutopilotCollaborator[];
}) {
const { t } = useT("autopilots");
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { getActorName } = useActorName();
const grant = useGrantAutopilotAccess();
const revoke = useRevokeAutopilotAccess();
const [pickerOpen, setPickerOpen] = useState(false);
const [filter, setFilter] = useState("");
const grantedIds = useMemo(
() => new Set(collaborators.map((c) => c.user_id)),
[collaborators],
);
const query = filter.trim().toLowerCase();
const candidates = useMemo(
() =>
members.filter(
(m) =>
!grantedIds.has(m.user_id) &&
(query === "" ||
m.name.toLowerCase().includes(query) ||
matchesPinyin(m.name, query)),
),
[members, grantedIds, query],
);
const handleGrant = async (userId: string) => {
try {
await grant.mutateAsync({ autopilotId, userId });
toast.success(t(($) => $.access.toast_granted));
} catch (e: any) {
toast.error(e?.message || t(($) => $.access.toast_failed));
}
};
const handleRevoke = async (userId: string) => {
try {
await revoke.mutateAsync({ autopilotId, userId });
toast.success(t(($) => $.access.toast_revoked));
} catch (e: any) {
toast.error(e?.message || t(($) => $.access.toast_failed));
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t(($) => $.access.current_label)}
</span>
<PropertyPicker
open={pickerOpen}
onOpenChange={(v) => {
setPickerOpen(v);
if (!v) setFilter("");
}}
width="w-64"
align="end"
searchable
searchPlaceholder={t(($) => $.access.search_placeholder)}
onSearchChange={setFilter}
trigger={
<span className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground">
<Plus className="size-3" />
{t(($) => $.access.add)}
</span>
}
>
{candidates.length === 0 ? (
<PickerEmpty />
) : (
candidates.map((m) => (
<PickerItem
key={m.user_id}
selected={false}
onClick={() => {
void handleGrant(m.user_id);
setPickerOpen(false);
}}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span className="truncate">{m.name}</span>
</PickerItem>
))
)}
</PropertyPicker>
</div>
{collaborators.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
{t(($) => $.access.empty)}
</p>
) : (
<ul className="space-y-1">
{collaborators.map((c) => (
<li
key={c.user_id}
className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<span className="flex min-w-0 items-center gap-2">
<ActorAvatar actorType="member" actorId={c.user_id} size={20} />
<span className="truncate text-sm">
{getActorName("member", c.user_id)}
</span>
</span>
<button
type="button"
onClick={() => void handleRevoke(c.user_id)}
disabled={revoke.isPending}
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={t(($) => $.access.remove_tooltip)}
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">
{t(($) => $.access.owner_note)}
</p>
</div>
);
}

View File

@@ -256,7 +256,7 @@ function SkippedRunsGroup({
);
}
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrigger; autopilotId: string; canWrite: boolean }) {
const { t } = useT("autopilots");
const deleteTrigger = useDeleteAutopilotTrigger();
const rotateToken = useRotateAutopilotTriggerWebhookToken();
@@ -329,7 +329,7 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
// — keep it pinned to the row's top-right corner. Without this the
// trash icon visually floats above the URL action buttons because the
// outer flex uses `items-start`.
const deleteButton = (
const deleteButton = canWrite ? (
<Button
size="icon"
variant="ghost"
@@ -339,7 +339,7 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
);
) : null;
return (
<div className="flex items-start gap-3 rounded-md border px-3 py-2">
@@ -386,16 +386,18 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5 text-muted-foreground" />}
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => setRotateOpen(true)}
title={t(($) => $.trigger_row.rotate_url)}
disabled={rotateToken.isPending}
>
<RotateCw className={cn("h-3.5 w-3.5 text-muted-foreground", rotateToken.isPending && "animate-spin")} />
</Button>
{canWrite && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => setRotateOpen(true)}
title={t(($) => $.trigger_row.rotate_url)}
disabled={rotateToken.isPending}
>
<RotateCw className={cn("h-3.5 w-3.5 text-muted-foreground", rotateToken.isPending && "animate-spin")} />
</Button>
)}
{deleteButton}
</div>
)}
@@ -683,6 +685,15 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
}
const { autopilot, triggers } = data;
const collaborators = data.collaborators ?? [];
// Treat an absent can_write (older server) as "allowed" — the backend is the
// real gate, so the UI only hides controls when the server explicitly says
// the caller cannot write.
const canWrite = autopilot.can_write !== false;
// Managing the access list is narrower than write: granted collaborators can
// edit/run but cannot grant/revoke. Fall back to canWrite when the server
// doesn't send the field (older backend).
const canManageAccess = autopilot.can_manage_access ?? canWrite;
const handleRunNow = async () => {
try {
@@ -745,30 +756,32 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
</>
}
actions={
<>
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.edit)}>
<Pencil className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t(($) => $.detail.edit)}</span>
</Button>
<Button
size="sm"
onClick={handleRunNow}
disabled={autopilot.status !== "active" || triggerAutopilot.isPending}
className="px-2 sm:px-2.5"
aria-label={triggerAutopilot.isPending ? t(($) => $.detail.running) : t(($) => $.detail.run_now)}
>
{triggerAutopilot.isPending ? (
<Loader2 className="h-3.5 w-3.5 sm:mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 sm:mr-1" />
)}
<span className="hidden sm:inline">
{triggerAutopilot.isPending
? t(($) => $.detail.running)
: t(($) => $.detail.run_now)}
</span>
</Button>
</>
canWrite ? (
<>
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.edit)}>
<Pencil className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t(($) => $.detail.edit)}</span>
</Button>
<Button
size="sm"
onClick={handleRunNow}
disabled={autopilot.status !== "active" || triggerAutopilot.isPending}
className="px-2 sm:px-2.5"
aria-label={triggerAutopilot.isPending ? t(($) => $.detail.running) : t(($) => $.detail.run_now)}
>
{triggerAutopilot.isPending ? (
<Loader2 className="h-3.5 w-3.5 sm:mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 sm:mr-1" />
)}
<span className="hidden sm:inline">
{triggerAutopilot.isPending
? t(($) => $.detail.running)
: t(($) => $.detail.run_now)}
</span>
</Button>
</>
) : null
}
/>
@@ -868,10 +881,12 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
{t(($) => $.detail.section_triggers)}
</h2>
<Button size="sm" variant="outline" onClick={() => setTriggerDialogOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
{t(($) => $.detail.add_trigger)}
</Button>
{canWrite && (
<Button size="sm" variant="outline" onClick={() => setTriggerDialogOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
{t(($) => $.detail.add_trigger)}
</Button>
)}
</div>
{triggers.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
@@ -880,7 +895,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
) : (
<div className="space-y-2">
{triggers.map((trig) => (
<TriggerRow key={trig.id} trigger={trig} autopilotId={autopilotId} />
<TriggerRow key={trig.id} trigger={trig} autopilotId={autopilotId} canWrite={canWrite} />
))}
</div>
)}
@@ -919,15 +934,17 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
</section>
{/* Danger zone */}
<section className="space-y-3 pt-4 border-t">
<h2 className="text-sm font-medium text-destructive uppercase tracking-wider">
{t(($) => $.detail.section_danger)}
</h2>
<Button size="sm" variant="destructive" onClick={() => setDeleteConfirmOpen(true)}>
<Trash2 className="h-3.5 w-3.5 mr-1" />
{t(($) => $.detail.delete_button)}
</Button>
</section>
{canWrite && (
<section className="space-y-3 pt-4 border-t">
<h2 className="text-sm font-medium text-destructive uppercase tracking-wider">
{t(($) => $.detail.section_danger)}
</h2>
<Button size="sm" variant="destructive" onClick={() => setDeleteConfirmOpen(true)}>
<Trash2 className="h-3.5 w-3.5 mr-1" />
{t(($) => $.detail.delete_button)}
</Button>
</section>
)}
</div>
</div>
@@ -955,6 +972,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
.map((s) => s.user_id) ?? [],
}}
triggers={triggers}
collaborators={collaborators}
canManageAccess={canManageAccess}
/>
)}
<AlertDialog

View File

@@ -15,6 +15,7 @@ import {
Minimize2,
Play,
Rocket,
Users,
Webhook,
X as XIcon,
Zap,
@@ -27,6 +28,14 @@ import {
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
} from "@multica/ui/components/ui/popover";
import { Button } from "@multica/ui/components/ui/button";
import {
Select,
@@ -51,6 +60,7 @@ import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
import { api } from "@multica/core/api";
import type {
AutopilotAssigneeType,
AutopilotCollaborator,
AutopilotExecutionMode,
AutopilotTrigger,
} from "@multica/core/types";
@@ -60,6 +70,7 @@ import { ProjectPicker } from "../../projects/components/project-picker";
import { ProjectIcon } from "../../projects/components/project-icon";
import { AgentPicker, type AssigneeSelection } from "./pickers/agent-picker";
import { SubscriberMultiSelect } from "./subscriber-multi-select";
import { AutopilotAccessManager } from "./autopilot-access-manager";
import {
getDefaultTriggerConfig,
getLocalTimezone,
@@ -102,6 +113,8 @@ export type AutopilotDialogProps =
autopilotId: string;
initial: AutopilotInitial;
triggers: AutopilotTrigger[];
collaborators: AutopilotCollaborator[];
canManageAccess: boolean;
};
// ---------------------------------------------------------------------------
@@ -555,6 +568,29 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
)}
</div>
<div className="flex items-center gap-1">
{!isCreate && props.canManageAccess && (
<>
<Popover>
<PopoverTrigger className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-xs text-muted-foreground opacity-90 transition-all hover:bg-accent/60 hover:text-foreground hover:opacity-100 cursor-pointer">
<Users className="size-3.5" />
<span>{t(($) => $.access.title)}</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} keepMounted className="w-80">
<PopoverHeader>
<PopoverTitle>{t(($) => $.access.title)}</PopoverTitle>
<PopoverDescription className="text-xs">
{t(($) => $.access.description)}
</PopoverDescription>
</PopoverHeader>
<AutopilotAccessManager
autopilotId={props.autopilotId}
collaborators={props.collaborators}
/>
</PopoverContent>
</Popover>
<span className="mx-0.5 h-4 w-px bg-border" />
</>
)}
<Tooltip>
<TooltipTrigger
render={

View File

@@ -146,6 +146,11 @@ export function AutopilotRowActions({ row }: { row: Autopilot }) {
const [deleteOpen, setDeleteOpen] = useState(false);
const setStatus = useSetStatus();
// The kebab only holds write actions (pause/resume/delete). Hide it entirely
// for members without write access; an absent can_write (older server) keeps
// the menu visible and lets the backend remain the gate.
if (row.can_write === false) return null;
return (
<span
onClick={(e) => e.stopPropagation()}

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