Compare commits

...

35 Commits

Author SHA1 Message Date
yushen
9a98e2cdca fix(composio): mount remote MCP for codex 2026-06-30 17:54:08 +08:00
LinYushen
4aaa5ee412 feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)
Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.

- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
  composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
  array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
  cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
  empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
  to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
  is gated to the creator (currentUserId === agent.owner_id), matching the
  backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
  empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
  non-creator hidden) with Composio + agent endpoints mocked at the boundary.

Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 14:39:36 +08:00
yushen
20b50230bb Merge branch 'feature/composio-integration' of github.com:multica-ai/multica into feature/composio-integration
# Conflicts:
#	server/pkg/db/generated/agent.sql.go
#	server/pkg/db/generated/autopilot.sql.go
#	server/pkg/db/generated/chat.sql.go
#	server/pkg/db/generated/models.go
#	server/pkg/db/generated/runtime.sql.go
2026-06-30 13:56:24 +08:00
yushen
cdc67694ce fix(composio): accept nested connected account auth config 2026-06-30 13:53:18 +08:00
yushen
66794fc4f3 Merge remote-tracking branch 'origin/main' into feature/composio-integration
# Conflicts:
#	packages/core/api/client.ts
#	packages/core/package.json
#	packages/core/types/index.ts
#	packages/views/locales/en/settings.json
#	packages/views/locales/ja/settings.json
#	packages/views/locales/ko/settings.json
#	packages/views/locales/zh-Hans/settings.json
#	packages/views/settings/components/integrations-tab.tsx
#	server/pkg/db/generated/agent.sql.go
#	server/pkg/db/generated/models.go
2026-06-30 13:52:51 +08:00
Multica Eve
4708dba978 feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)

Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:

- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
  Owner-only read/write; non-owner GET/PUT silently redacts/drops the
  field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
  top-of-chain HUMAN at every enqueue path:
    * issue/mention comment by member  -> author_id
    * issue/mention comment by agent   -> inherit via comment.source_task_id
                                          -> parent task originator_user_id
    * quick-create                     -> requester_id
    * chat                             -> initiator_user_id
    * retry                            -> SQL-inherited from parent row
    * autopilot                        -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
  agent) and short-circuits on five gates: invalid originator,
  originator != agent.owner_id, empty allowlist, empty intersection of
  allowlist ∩ active connections, defensive empty session URL. Composio
  CreateSession is called with BOTH `toolkits.slugs` (the intersection)
  AND `connected_accounts` (the pinned account ids), narrowing the
  tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
  workspace member who can @-mention a public agent used to project the
  owner's connected apps into their run. Now the overlay only mounts
  when the human at the top of the chain IS the agent owner.

Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
  normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
  applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
  (list/empty/null), workspace-admin silent-drop, owner-only GET
  visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
  agent-authored inherits via comment.source_task_id, invalid id.

Migration 129 up/down/up verified against docker postgres.

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

* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)

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 13:50:17 +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
Multica Eve
aa4268c1e2 feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)
Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.

Server side
  - Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
    BEFORE-UPDATE trigger that wipes the column on any transition into a
    terminal status (completed / failed / cancelled). A trigger is the single
    source of truth — future queries that flip status cannot bypass it.
  - composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
    emits the Claude-style { mcpServers: { composio: { type: http, url,
    headers } } } shape the daemon's existing sidecar generators consume.
    Returns (nil, nil) on zero active connections so we never burn a
    Composio session for a user with nothing to call.
  - TaskService grows a Composio ComposioOverlayBuilder seam, wired in
    router.go after composiointeg.NewService succeeds. Five enqueue paths
    (issue / mention / quick-create / chat / auto-retry) attach the overlay
    after CreateAgentTask returns and before the daemon is notified — so
    every claim reads a settled row, with no second daemon hop. Best-effort:
    a builder failure logs and proceeds with no overlay.
  - resolveInitiatorFromTriggerComment derives the initiator user from the
    trigger comment when it was authored by a member. Agent-authored
    triggers are not treated as initiators (their connected-apps view is
    empty by construction).

Daemon side
  - handler/daemon.go claim path merges task.runtime_mcp_overlay onto
    agent.mcp_config via mergeMCPOverlay before populating
    TaskAgentData.McpConfig. Overlay wins on server-name collisions
    because it carries the live user-scoped session URL. Errors fall back
    to the agent config unchanged — a bad overlay must not surprise-disable
    saved MCP tools. The existing execenv sidecar generators (cursor /
    codex / openclaw / opencode / hermes-kiro) need no changes: they keep
    consuming the merged result through TaskAgentData.McpConfig.

Tests
  - 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
    pass-through, overlay-only canonicalization, two-side merge, name
    collision (overlay wins), top-level key preservation, malformed agent
    fallback, malformed overlay fallback, non-object server rejection.
  - 4 dispatch cases (composio): zero-connections returns nil without
    CreateSession, happy-path emits the right shape with the right user
    id, empty-URL defensive branch, SDK error surfacing.
  - 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
    invalid initiator does not call the builder, nil overlay skips the
    UPDATE, builder error swallowed without panic.
  - Migration 128 verified to roll up + down + up cleanly against the test
    database.

Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 13:12: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
LinYushen
f8405a931d fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)
* fix(composio): move OAuth callback out of the Auth group (MUL-3843)

Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.

Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.

Refs MUL-3843, MUL-3715.

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

* fix(composio): correct stale callback routing comments (MUL-3843)

The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.

Refs MUL-3843.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:40:55 +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
LinYushen
51ae12604c feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)
* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)

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

* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 14:11:42 +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
LinYushen
b933d9fd41 feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)

Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).

- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
  active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
  CompleteCallback (idempotent upsert), ListConnections, Disconnect
  (upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
  pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
  COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
  delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
  toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
  or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
  or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
  idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
  handlers (httptest), redact regression for Bearer mcp_ tokens

MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).

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

* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)

Address PR 4608 review (CHANGES_REQUESTED):

- callback: verify connected_account_id with Composio before mirroring it.
  The signed state only proved user/toolkit/exp, so a valid state paired with
  a tampered connected_account_id would be written verbatim. CompleteCallback
  now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
  unless the account belongs to the state's user (composio_user_id == multica
  user id) and was created under the toolkit's auth config. No row is written
  on mismatch / unknown account / upstream error.

- disconnect: short-circuit to a no-op when the local row is already revoked,
  before touching upstream. Previously a second DELETE re-hit Composio and a
  non-404 upstream error surfaced as a 502, breaking the 204-idempotent
  contract.

- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
  constraint and make duplicate selection deterministic (newest-wins, rows are
  connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
  real single-account-enforcement vs multi-account-shape decision.

Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.

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

* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)

Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.

Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
  is now resolved at request time from the project's /auth_configs (cached,
  5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
  to exactly one project; the project is resolved from the key. Only org-level
  endpoints use x-org-api-key, which this integration never calls.

Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
  is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
  custom/white-label config over Composio-managed, newest wins); BeginConnect
  and CompleteCallback resolve via it; ListToolkits fetches the full catalog
  (paginated, capped) annotated with connectable = has an enabled auth config,
  connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
  when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.

Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
  composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
  toolkit as a card with search. Connect is gated on `connectable`;
  non-connectable toolkits show a muted "not configured" hint instead of a
  dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.

Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.

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

* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)

Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.

Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.

Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.

Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.

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

* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)

Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.

Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 12:50:11 +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
214 changed files with 16879 additions and 1935 deletions

View File

@@ -37,25 +37,27 @@ define REQUIRE_ENV
fi
endef
# Self-hosting requires Docker Compose v2 (the `docker compose` CLI plugin).
# 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 >/dev/null 2>&1; then \
echo "Docker Compose v2 ('docker compose') was not found."; \
echo "Self-hosting requires the Compose v2 CLI plugin; legacy 'docker-compose' v1 is not supported."; \
@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; \
if ! $(COMPOSE) version --short 2>/dev/null | grep -Eq '^v?2\.'; then \
echo "'$(COMPOSE)' is not Docker Compose v2."; \
echo "Self-hosting requires the Compose v2 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

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

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` (12 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))
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**

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

@@ -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

@@ -15,7 +15,7 @@ Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在
智能体执行任务**不**发生在 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

@@ -159,14 +159,14 @@ Agentic coding CLI using the ACP protocol over stdio (shares the transport with
### 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

@@ -31,7 +31,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
### 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

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

@@ -0,0 +1,175 @@
---
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
oauth_config:
scopes:
bot:
- app_mentions:read
- channels:history
- groups:history
- im:history
- mpim:history
- chat:write
- users:read
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 に投稿し返します。 |
| `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 つのトークンが同じアプリのものであることを検証するために必要です。 |
| `socket_mode_enabled: true` | Bot は Socket Mode 経由で外向きに接続します——**公開 URLリクエスト URL は不要**です。 |
| `interactivity.is_enabled: false` | Multica のプロンプトはボタンではなくプレーンなリンクなので、インタラクティビティは不要です。 |
**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 <タイトル>`(続く行に本文を足してもよい)でメッセージを始めると、ワークスペースに新しい 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 Fix the login redirect` と送ります。タイトルの後ろに行を足せば、それが説明になります。
## 管理と切断
ワークスペース全体の管理は **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,175 @@
---
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
oauth_config:
scopes:
bot:
- app_mentions:read
- channels:history
- groups:history
- im:history
- mpim:history
- chat:write
- users:read
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으로 다시 게시합니다. |
| `app_mentions:read` + `app_mention` 이벤트 | 채널에서 `@`-멘션을 받습니다. |
| `im:history` + `message.im` | 봇에게 보내는 **DM**을 받습니다(모든 DM 메시지를 읽습니다). |
| `channels:history` / `groups:history` / `mpim:history` + 대응하는 `message.*` 이벤트 | 공개 채널, 비공개 채널, 그룹 DM의 메시지를 받습니다. 이런 곳에서 봇은 자신을 **@로 멘션한** 메시지에만 반응합니다. |
| `users:read` | Multica가 두 토큰이 같은 앱에 속하는지 (`bots.info`를 통해) 확인하는 데 필요합니다. |
| `socket_mode_enabled: true` | 봇이 Socket Mode로 밖으로 연결합니다 — **공개 URL / request URL이 필요 없습니다**. |
| `interactivity.is_enabled: false` | Multica의 안내는 버튼이 아니라 일반 링크라서, interactivity가 필요 없습니다. |
**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 <제목>`(다음 줄에 본문 추가 가능)으로 메시지를 시작하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. |
| **답변** | 에이전트의 답변은 같은 DM 또는 스레드로 다시 게시됩니다. |
## 봇 사용하기 (멤버)
### 첫 메시지: 계정 연결하기
봇을 처음 `@`로 멘션하거나 DM하면, **계정을 연결하라**는 안내로 답합니다. 링크를 탭하고 Multica에 로그인하면, 당신의 Slack 신원이 Multica 멤버십에 바인딩됩니다 — 바로 이 단계가 에이전트로 하여금 당신을 대신해 행동하게 합니다(예: `/issue`는 당신 이름으로 이슈를 생성합니다). 이 링크는 일회용이며 약 15분 후에 만료됩니다. 새 링크가 필요하면 봇에게 다시 메시지를 보내세요.
<Callout type="warning">
**워크스페이스 멤버**만 봇을 사용할 수 있습니다. 멤버가 아니거나 신원 연결을 건너뛰면 봇은 실행되지 않으며, 메시지는 폐기됩니다(감사 목적으로 기록되며, 내용은 저장하지 않습니다).
</Callout>
### 대화와 `/issue`
- **채널에서** — 봇은 자동으로 참여하지 않습니다. `/invite @your-bot`을 한 번 실행한 다음 `@your-bot <당신의 메시지>`로 보내세요. 후속 메시지마다 다시 멘션하세요(봇은 자신을 멘션한 메시지만 읽습니다).
- **DM에서** — Slack 사이드바의 **Apps** 섹션에서 봇을 열고 직접 메시지를 보내세요. 멘션이 필요 없습니다.
- **이슈 생성** — `/issue Fix the login redirect`를 보내세요. 제목 뒤에 줄을 더 추가하면 설명이 됩니다.
## 관리 및 연결 해제
워크스페이스 전체 관리는 **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,175 @@
---
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
oauth_config:
scopes:
bot:
- app_mentions:read
- channels:history
- groups:history
- im:history
- mpim:history
- chat:write
- users:read
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. |
| `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. |
| `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. |
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` command** | Starting a message with `/issue <title>` (optionally with a body on the next lines) creates a new Multica issue in the workspace, attributed to you. |
| **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** — send `/issue Fix the login redirect`; add more lines after the title for a description.
## 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,175 @@
---
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
oauth_config:
scopes:
bot:
- app_mentions:read
- channels:history
- groups:history
- im:history
- mpim:history
- chat:write
- users:read
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。 |
| `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。 |
| `socket_mode_enabled: true` | Bot 通过 Socket Mode 向外连接——**无需任何公网 URL / request URL**。 |
| `interactivity.is_enabled: false` | Multica 的提示是纯链接,不是按钮,所以不需要交互性。 |
这里**没有 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记在你名下。 |
| **回复** | 智能体的答复会被发回同一段私聊或 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 Fix the login redirect`;在标题后面再加几行就是描述。
## 管理与断开
工作区级别的管理在 **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

@@ -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,26 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
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",

View File

@@ -269,6 +269,26 @@ export function createJaDict(allowSignup: boolean): LandingDict {
fixes: "バグ修正",
},
entries: [
{
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",

View File

@@ -268,6 +268,26 @@ export function createKoDict(allowSignup: boolean): LandingDict {
fixes: "버그 수정",
},
entries: [
{
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",

View File

@@ -293,6 +293,26 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
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",

193
e2e/agent-mcp.spec.ts Normal file
View File

@@ -0,0 +1,193 @@
import { test, expect, type Page } from "@playwright/test";
import { TestApiClient } from "./fixtures";
import { waitForPageText } from "./helpers";
// Stage 3.2 (MUL-3870): the creator-only MCP tab on the agent detail page.
//
// Auth + workspace bootstrap go through the real backend (same as every other
// spec), but the agent list and the Composio connection/catalog endpoints are
// mocked at the network boundary so the test runs without a configured
// COMPOSIO_API_KEY or a live runtime to bind an agent to. The PUT /api/agents
// write is intercepted so we can assert the exact allowlist body the toggle
// produces — the heart of the data contract — instead of depending on the
// backend persisting it.
const E2E_WORKER =
process.env.TEST_PARALLEL_INDEX ?? process.env.TEST_WORKER_INDEX ?? "0";
const E2E_RUN_ID =
process.env.E2E_RUN_ID ?? `${Date.now().toString(36)}-${process.pid.toString(36)}`;
const EMAIL = `e2e-mcp-${E2E_WORKER}-${E2E_RUN_ID}@multica.ai`;
const NAME = "E2E MCP User";
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_USER_ID = "99999999-9999-4999-8999-999999999999";
interface SetupResult {
slug: string;
userId: string;
}
/** Log in via the real API, capture the authed user id, inject the token, and
* return the workspace slug + user id so the test can mock an agent owned
* (or not) by this exact user. */
async function loginCapturingUser(page: Page): Promise<SetupResult> {
const api = new TestApiClient();
const data = await api.login(EMAIL, NAME);
const userId: string | undefined = data?.user?.id;
if (!userId) throw new Error("login did not return a user id");
const workspace = await api.ensureWorkspace(
`E2E MCP WS ${E2E_WORKER}`,
`e2e-mcp-${E2E_WORKER}-${E2E_RUN_ID}`,
);
await api.markUserOnboarded();
const token = api.getToken();
if (!token) throw new Error("login did not return a token");
await page.addInitScript((t) => {
localStorage.setItem("multica_token", t);
localStorage.setItem("multica:chat:isOpen", "false");
}, token);
return { slug: workspace.slug, userId };
}
function mockAgent(ownerId: string, workspaceId: string) {
return {
id: AGENT_ID,
workspace_id: workspaceId,
runtime_id: "22222222-2222-4222-8222-222222222222",
name: "MCP Test Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_args: [],
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 1,
model: "",
owner_id: ownerId,
skills: [],
created_at: "2026-06-30T00:00:00Z",
updated_at: "2026-06-30T00:00:00Z",
archived_at: null,
archived_by: null,
composio_toolkit_allowlist: [],
};
}
/** Mock the Composio catalog + the current user's active connections (Notion +
* Slack), the agent list (owned by `ownerId`), and capture any PUT
* /api/agents/<id> body. Returns a getter for the last captured allowlist. */
async function mockApis(page: Page, ownerId: string) {
const captured: { allowlist?: unknown } = {};
await page.route("**/api/integrations/composio/toolkits", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ slug: "notion", name: "Notion", connectable: true },
{ slug: "slack", name: "Slack", connectable: true },
]),
}),
);
await page.route("**/api/integrations/composio/connections", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "conn-notion",
toolkit_slug: "notion",
status: "active",
connected_at: "2026-06-30T00:00:00Z",
last_used_at: null,
},
{
id: "conn-slack",
toolkit_slug: "slack",
status: "active",
connected_at: "2026-06-30T00:00:00Z",
last_used_at: null,
},
]),
}),
);
// One handler for both the list (GET, query string) and the write
// (PUT /api/agents/<id>). Other agent sub-routes fall through.
await page.route("**/api/agents**", (route) => {
const req = route.request();
const url = new URL(req.url());
const workspaceId = url.searchParams.get("workspace_id") ?? "ws-mock";
if (req.method() === "PUT" && url.pathname.endsWith(`/api/agents/${AGENT_ID}`)) {
const body = req.postDataJSON?.() ?? {};
captured.allowlist = body.composio_toolkit_allowlist;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...mockAgent(ownerId, workspaceId),
composio_toolkit_allowlist: body.composio_toolkit_allowlist ?? [],
}),
});
}
if (req.method() === "GET" && url.pathname.endsWith("/api/agents")) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([mockAgent(ownerId, workspaceId)]),
});
}
return route.fallback();
});
return () => captured.allowlist;
}
test.describe("Agent MCP tab (creator-only)", () => {
test("creator sees the MCP Apps tab and toggling a toolkit writes the allowlist", async ({
page,
}) => {
const { slug, userId } = await loginCapturingUser(page);
const getAllowlist = await mockApis(page, userId);
await page.goto(`/${slug}/agents/${AGENT_ID}`, {
waitUntil: "domcontentloaded",
});
await waitForPageText(page, "MCP Test Agent");
// The creator-only tab entry is present and opens the connection list.
const tab = page.getByRole("button", { name: "MCP Apps" });
await expect(tab).toBeVisible({ timeout: 15000 });
await tab.click();
await expect(page.getByText("Notion")).toBeVisible();
await expect(page.getByText("Slack")).toBeVisible();
// Allow Notion → the PUT body carries exactly ["notion"].
await page.getByLabel(/Allow Notion for this agent/i).click();
await expect.poll(() => getAllowlist()).toEqual(["notion"]);
});
test("a non-creator viewer does not see the MCP Apps tab", async ({ page }) => {
const { slug } = await loginCapturingUser(page);
// Agent owned by someone else → the creator gate hides the tab entry.
await mockApis(page, OTHER_USER_ID);
await page.goto(`/${slug}/agents/${AGENT_ID}`, {
waitUntil: "domcontentloaded",
});
await waitForPageText(page, "MCP Test Agent");
// Other tabs render, but the creator-only MCP Apps entry must not.
await expect(page.getByRole("button", { name: "Activity" })).toBeVisible({
timeout: 15000,
});
await expect(page.getByRole("button", { name: "MCP Apps" })).toHaveCount(0);
});
});

View File

@@ -37,4 +37,83 @@ test.describe("Settings", () => {
await expect(page.getByText("Workspace settings saved").first()).toBeVisible({ timeout: 5000 });
await expect(page.getByRole("button", { name: new RegExp(originalName) }).first()).toBeVisible();
});
// Composio connect flow, fully mocked at the network boundary so it runs
// without a configured COMPOSIO_API_KEY or a live Composio project. The
// backend redirect is simulated by pointing the init endpoint's redirect_url
// straight back at the settings page with ?connected=<slug> — exercising the
// frontend's callback toast + connections refresh (MUL-3718) end to end.
test("connecting a Composio toolkit shows a toast and refreshes the list", async ({
page,
}) => {
const workspaceSlug = await loginAsDefault(page);
const settingsUrl = `/${workspaceSlug}/settings?tab=integrations`;
// Stateful: connections is empty until the (mocked) connect flow lands.
let connected = false;
await page.route("**/api/integrations/composio/toolkits", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ slug: "notion", name: "Notion", connectable: true },
]),
}),
);
await page.route("**/api/integrations/composio/connections", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
connected
? [
{
id: "conn-notion-1",
toolkit_slug: "notion",
status: "active",
connected_at: new Date().toISOString(),
last_used_at: null,
},
]
: [],
),
});
});
await page.route("**/api/integrations/composio/connect/init", (route) => {
// Composio would 302 through its hosted consent and back to our callback,
// which emits CallbackRedirect's slug-less shape:
// `/settings?tab=integrations&connected=<slug>`. The web proxy's
// legacy-route redirect then prepends the last workspace slug, landing on
// the real settings route. Mock that exact backend shape (NOT the final
// slugged URL) so the test exercises the same redirect path real users hit.
connected = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
redirect_url: `/settings?tab=integrations&connected=notion`,
}),
});
});
await page.goto(settingsUrl, { waitUntil: "domcontentloaded" });
await waitForPageText(page, "Composio");
// Notion starts disconnected → click Connect.
await page.getByRole("button", { name: /^Connect$/ }).first().click();
// Success toast from the simulated callback redirect.
await expect(page.getByText("Connected").first()).toBeVisible({ timeout: 10000 });
// List refreshed without a manual reload: the Notion card now offers
// Disconnect, and the one-shot ?connected param has been stripped.
await expect(
page.getByRole("button", { name: /Disconnect/ }).first(),
).toBeVisible({ timeout: 10000 });
await expect(page).not.toHaveURL(/connected=notion/);
});
});

View File

@@ -2,6 +2,7 @@ export * from "./types";
export * from "./derive-presence";
export * from "./queries";
export * from "./use-agent-presence";
export * from "./use-update-agent-allowlist";
export * from "./use-agent-activity";
export * from "./use-workspace-presence-prefetch";
export * from "./constants";

View File

@@ -0,0 +1,53 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import type { Agent } from "../types";
import { workspaceKeys } from "../workspace/queries";
/**
* Mutation hook for the creator-only MCP tab: writes an agent's Composio
* toolkit allowlist via `PUT /api/agents/:id` ({ composio_toolkit_allowlist })
* — no dedicated endpoint, the existing agent PATCH path carries it (MUL-3870).
*
* The hook is optimistic: it patches the matching agent in the cached
* workspace list before the round-trip so the checkbox flips instantly, then
* rolls back to the captured snapshot on error and always invalidates on
* settle so the cache reconverges with the server's normalised slugs
* (lowercase / trimmed / deduped). The server silently drops the write for
* non-owners, which is why this is only wired into the owner-gated tab.
*
* Accepts the full desired allowlist (`string[]`) — callers compute the next
* array (add / remove a slug) and pass it wholesale, matching the backend's
* replace semantics. Pass `[]` to clear every toolkit.
*/
export function useUpdateAgentAllowlist(agentId: string) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation<Agent, Error, string[], { previous?: Agent[] }>({
mutationFn: (allowlist) =>
api.updateAgent(agentId, { composio_toolkit_allowlist: allowlist }),
onMutate: async (allowlist) => {
const queryKey = workspaceKeys.agents(wsId);
// Cancel in-flight refetches so they can't clobber the optimistic write.
await qc.cancelQueries({ queryKey });
const previous = qc.getQueryData<Agent[]>(queryKey);
qc.setQueryData<Agent[]>(queryKey, (old) =>
old?.map((a) =>
a.id === agentId
? ({ ...a, composio_toolkit_allowlist: allowlist } as Agent)
: a,
),
);
return { previous };
},
onError: (_error, _allowlist, context) => {
if (context?.previous) {
qc.setQueryData(workspaceKeys.agents(wsId), context.previous);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
},
});
}

View File

@@ -100,6 +100,7 @@ import type {
UpdateAutopilotTriggerRequest,
ListAutopilotsResponse,
GetAutopilotResponse,
AutopilotCollaboratorsResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
@@ -112,6 +113,13 @@ import type {
BeginLarkInstallResponse,
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
ComposioToolkit,
ComposioConnection,
ComposioConnectInitResponse,
SlackInstallation,
ListSlackInstallationsResponse,
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
@@ -2107,6 +2115,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" });
}
@@ -2270,4 +2294,67 @@ export class ApiClient {
body: JSON.stringify({ token }),
});
}
// Composio integration (MUL-3720). All routes are user-scoped (a connection
// belongs to a user, not a workspace), so none take a workspaceId.
/** Full Composio toolkit catalog, each annotated with `connectable`
* (whether the project has an enabled auth config for it). */
async listComposioToolkits(): Promise<ComposioToolkit[]> {
return this.fetch(`/api/integrations/composio/toolkits`);
}
/** The caller's active Composio connections. */
async listComposioConnections(): Promise<ComposioConnection[]> {
return this.fetch(`/api/integrations/composio/connections`);
}
/** Starts a hosted Composio connect flow for a toolkit and returns the
* redirect URL the browser should be sent to. */
async beginComposioConnect(toolkitSlug: string): Promise<ComposioConnectInitResponse> {
return this.fetch(`/api/integrations/composio/connect/init`, {
method: "POST",
body: JSON.stringify({ toolkit_slug: toolkitSlug }),
});
}
/** Disconnects a Composio connection the caller owns. */
async deleteComposioConnection(connectionId: string): Promise<void> {
await this.fetch(`/api/integrations/composio/connections/${connectionId}`, {
method: "DELETE",
});
}
// 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

@@ -840,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({

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

@@ -0,0 +1 @@
export { composioKeys, composioToolkitsOptions, composioConnectionsOptions } from "./queries";

View File

@@ -0,0 +1,26 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
/** Query-key namespace for Composio integration data. */
export const composioKeys = {
all: ["composio"] as const,
toolkits: () => [...composioKeys.all, "toolkits"] as const,
connections: () => [...composioKeys.all, "connections"] as const,
};
/** The full Composio toolkit catalog (with per-toolkit `connectable`). The
* catalog changes rarely, so a long staleTime avoids refetching it every time
* the Settings tab mounts. */
export const composioToolkitsOptions = () =>
queryOptions({
queryKey: composioKeys.toolkits(),
queryFn: () => api.listComposioToolkits(),
staleTime: 5 * 60 * 1000,
});
/** The current user's active Composio connections. */
export const composioConnectionsOptions = () =>
queryOptions({
queryKey: composioKeys.connections(),
queryFn: () => api.listComposioConnections(),
});

View File

@@ -85,6 +85,10 @@
"./github/queries": "./github/queries.ts",
"./lark": "./lark/index.ts",
"./lark/queries": "./lark/queries.ts",
"./composio": "./composio/index.ts",
"./composio/queries": "./composio/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

@@ -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()
// + 1 cross-workspace inbox unread summary = 23 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(23);
// (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,
@@ -339,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() });
}
@@ -484,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

@@ -281,6 +281,24 @@ export interface Agent {
* Older backends omit this field; treat `undefined` as false.
*/
mcp_config_redacted?: boolean;
/**
* The subset of Composio toolkit slugs this agent is allowed to mount as
* MCP servers at task dispatch — but only when the run originator is the
* agent owner (MUL-3869 / MUL-3721). `null`/`[]`/omitted all mean "no
* overlay regardless of who triggers". Owner-only data: the server hands
* it through verbatim to the owner and redacts it to `undefined` +
* `composio_toolkit_allowlist_redacted=true` for everyone else (same
* contract as `mcp_config`). Treat `undefined` as "unknown — assume none".
*/
composio_toolkit_allowlist?: string[];
/**
* True when the server stripped `composio_toolkit_allowlist` from this
* response because the caller is not the agent owner. The MCP tab is
* creator-only so a redacted value should never reach the editor, but the
* UI renders a "hidden" fallback defensively. Older backends omit this
* field; treat `undefined` as false.
*/
composio_toolkit_allowlist_redacted?: boolean;
visibility: AgentVisibility;
status: AgentStatus;
max_concurrent_tasks: number;
@@ -432,6 +450,18 @@ export interface UpdateAgentRequest {
* validate / translate it according to their own MCP integration
*/
mcp_config?: unknown | null;
/**
* Composio toolkit allowlist. Tri-state semantics, mirroring the backend
* gate (MUL-3869):
* - field omitted → no change
* - `null` → clear the column (no MCP overlay for anyone)
* - string[] → wholesale replace; the server lowercases / trims / dedupes
* the slugs before persisting
* Writes are silently dropped server-side unless the caller is the agent
* owner, so the UI only ever exposes this field through the creator-only
* MCP tab.
*/
composio_toolkit_allowlist?: string[] | null;
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;

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

@@ -0,0 +1,36 @@
/** A Composio toolkit as surfaced by GET /api/integrations/composio/toolkits.
*
* Wire shape mirrors `ComposioToolkitResponse` in
* `server/internal/handler/integrations_composio.go`. New fields the backend
* adds later MUST stay optional so older desktop builds keep parsing — see
* CLAUDE.md → API Response Compatibility. */
export interface ComposioToolkit {
slug: string;
name: string;
logo?: string;
category?: string;
/** Whether the project has an enabled auth config for this toolkit. When
* false the UI must not offer a working Connect button — BeginConnect would
* 400 with "toolkit not supported". */
connectable: boolean;
}
/** A user's Composio connected account, as returned by
* GET /api/integrations/composio/connections. Mirrors
* `ComposioConnectionResponse` server-side. */
export interface ComposioConnection {
id: string;
toolkit_slug: string;
/** Connection lifecycle state. `expired` surfaces a Reconnect affordance in
* the UI; the backend only starts emitting it once Stage 4 webhook handling
* lands (MUL-3719), but the client renders the branch ahead of that. */
status: "active" | "expired" | "revoked" | string;
connected_at: string;
last_used_at?: string | null;
}
/** Response of POST /api/integrations/composio/connect/init — the hosted
* Composio Connect Link the browser is redirected to. */
export interface ComposioConnectInitResponse {
redirect_url: string;
}

View File

@@ -119,6 +119,17 @@ export type {
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
} from "./lark";
export type {
ComposioToolkit,
ComposioConnection,
ComposioConnectInitResponse,
} from "./composio";
export type {
SlackInstallation,
ListSlackInstallationsResponse,
RegisterSlackBYORequest,
RedeemSlackBindingTokenResponse,
} from "./slack";
export type {
Autopilot,
AutopilotStatus,
@@ -126,6 +137,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

@@ -301,6 +301,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
agent={agent}
runtimes={runtimes}
onUpdate={handleUpdate}
currentUserId={currentUser?.id ?? null}
navIntent={tabNavIntent}
onNavIntentHandled={() => setTabNavIntent(null)}
/>

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

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import {
Activity,
Blocks,
BookOpenText,
FileText,
KeyRound,
@@ -17,6 +18,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,
@@ -33,6 +35,7 @@ import { SkillsTab } from "./tabs/skills-tab";
import { EnvTab } from "./tabs/env-tab";
import { CustomArgsTab } from "./tabs/custom-args-tab";
import { McpConfigTab } from "./tabs/mcp-config-tab";
import { AgentMcpTab } from "./tabs/agent-mcp-tab";
import { IntegrationsTab } from "./tabs/integrations-tab";
import { RuntimeConfigTab } from "./tabs/runtime-config-tab";
import { ActorIssuesPanel } from "../../common/actor-issues-panel";
@@ -46,10 +49,11 @@ export type DetailTab =
| "env"
| "custom_args"
| "mcp_config"
| "composio_mcp"
| "integrations"
| "runtime_config";
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "skills" | "environment" | "custom_args" | "mcp_config" | "integrations" | "runtime_config"> = {
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "skills" | "environment" | "custom_args" | "mcp_config" | "composio_mcp" | "integrations" | "runtime_config"> = {
activity: "activity",
tasks: "tasks",
instructions: "instructions",
@@ -57,6 +61,7 @@ const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "
env: "environment",
custom_args: "custom_args",
mcp_config: "mcp_config",
composio_mcp: "composio_mcp",
integrations: "integrations",
runtime_config: "runtime_config",
};
@@ -72,6 +77,7 @@ const detailTabs: {
{ id: "env", icon: KeyRound },
{ id: "custom_args", icon: Terminal },
{ id: "mcp_config", icon: Plug },
{ id: "composio_mcp", icon: Blocks },
{ id: "integrations", icon: Webhook },
{ id: "runtime_config", icon: Router },
];
@@ -80,6 +86,13 @@ interface AgentOverviewPaneProps {
agent: Agent;
runtimes: AgentRuntime[];
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
/**
* The viewer's user id. Gates the creator-only MCP tab — the tab entry is
* only rendered when the viewer is the agent owner (`agent.owner_id`),
* matching the backend's owner-only read/write of the toolkit allowlist
* (MUL-3870). `null` while auth is still loading hides the tab.
*/
currentUserId?: string | null;
/**
* One-shot request from a sibling (the inspector's compact Lark status
* row) to focus a specific tab. Routed through the same `requestTabChange`
@@ -117,6 +130,7 @@ export function AgentOverviewPane({
agent,
runtimes,
onUpdate,
currentUserId,
navIntent,
onNavIntentHandled,
}: AgentOverviewPaneProps) {
@@ -141,16 +155,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
@@ -159,13 +181,20 @@ export function AgentOverviewPane({
const visibleTabs = useMemo(() => {
const showMcp = runtime ? providerSupportsMcpConfig(runtime.provider) : true;
const showRuntimeConfig = runtime ? runtime.provider === "openclaw" : false;
// The Composio MCP tab is creator-only: it edits the agent owner's own
// toolkit allowlist, which the backend reads/writes for the owner alone
// (redacted + write-dropped for everyone else — MUL-3870 / MUL-3869).
// Hide the entry entirely for non-owners, and while auth is still loading.
const showComposioMcp =
!!currentUserId && !!agent.owner_id && agent.owner_id === currentUserId;
return detailTabs.filter((tab) => {
if (tab.id === "mcp_config") return showMcp;
if (tab.id === "integrations") return larkConfigured;
if (tab.id === "composio_mcp") return showComposioMcp;
if (tab.id === "integrations") return integrationsConfigured;
if (tab.id === "runtime_config") return showRuntimeConfig;
return true;
});
}, [runtime, larkConfigured]);
}, [runtime, integrationsConfigured, currentUserId, agent.owner_id]);
// 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
@@ -278,6 +307,11 @@ export function AgentOverviewPane({
/>
</TabContent>
)}
{effectiveTab === "composio_mcp" && (
<TabContent>
<AgentMcpTab agent={agent} />
</TabContent>
)}
{effectiveTab === "integrations" && (
<TabContent>
<IntegrationsTab agent={agent} />

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

@@ -0,0 +1,177 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Agent } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../../locales/en/common.json";
import enAgents from "../../../locales/en/agents.json";
// AgentMcpTab reads its connection list + toolkit catalog from two queries and
// writes through the useUpdateAgentAllowlist mutation. We stub all three at the
// module boundary so the tests assert the tab's own logic (which slugs are
// selectable, what the toggle computes, the empty/redacted branches) rather
// than the query/mutation plumbing, which is covered elsewhere.
const connectionsRef = vi.hoisted(() => ({
current: [] as { toolkit_slug: string; status: string }[],
}));
const toolkitsRef = vi.hoisted(() => ({
current: [] as { slug: string; name: string }[],
}));
const queryStateRef = vi.hoisted(() => ({
isLoading: false,
isError: false,
}));
const mutateSpy = vi.hoisted(() => vi.fn());
const isPendingRef = vi.hoisted(() => ({ current: false }));
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: { queryKey: unknown[] }) => {
const key = JSON.stringify(opts.queryKey);
if (queryStateRef.isLoading) return { data: undefined, isLoading: true, isError: false };
if (queryStateRef.isError) return { data: undefined, isLoading: false, isError: true };
if (key.includes("connections"))
return { data: connectionsRef.current, isLoading: false, isError: false };
if (key.includes("toolkits"))
return { data: toolkitsRef.current, isLoading: false, isError: false };
return { data: undefined, isLoading: false, isError: false };
},
queryOptions: <T,>(opts: T) => opts,
}));
vi.mock("@multica/core/composio", () => ({
composioConnectionsOptions: () => ({ queryKey: ["composio", "connections"] }),
composioToolkitsOptions: () => ({ queryKey: ["composio", "toolkits"] }),
}));
vi.mock("@multica/core/agents", () => ({
useUpdateAgentAllowlist: () => ({
mutate: mutateSpy,
isPending: isPendingRef.current,
}),
}));
vi.mock("@multica/core/paths", () => ({
useWorkspacePaths: () => ({ settings: () => "/ws/settings" }),
}));
vi.mock("../../../navigation", () => ({
AppLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href} data-testid="app-link">
{children}
</a>
),
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
import { AgentMcpTab } from "./agent-mcp-tab";
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
const baseAgent: Agent = {
id: "agent-1",
workspace_id: "ws-1",
runtime_id: "runtime-1",
name: "Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_args: [],
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 1,
model: "",
owner_id: "user-1",
skills: [],
created_at: "2026-06-30T00:00:00Z",
updated_at: "2026-06-30T00:00:00Z",
archived_at: null,
archived_by: null,
};
function renderTab(overrides: Partial<Agent> = {}) {
const agent = { ...baseAgent, ...overrides };
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<AgentMcpTab agent={agent} />
</I18nProvider>,
);
}
describe("AgentMcpTab", () => {
beforeEach(() => {
vi.clearAllMocks();
connectionsRef.current = [
{ toolkit_slug: "notion", status: "active" },
{ toolkit_slug: "slack", status: "active" },
];
toolkitsRef.current = [
{ slug: "notion", name: "Notion" },
{ slug: "slack", name: "Slack" },
];
queryStateRef.isLoading = false;
queryStateRef.isError = false;
isPendingRef.current = false;
});
it("lists active connections with checkbox state reflecting the allowlist", () => {
renderTab({ composio_toolkit_allowlist: ["notion"] });
const notion = screen.getByLabelText(/Allow Notion for this agent/i);
const slack = screen.getByLabelText(/Allow Slack for this agent/i);
expect(notion.getAttribute("aria-checked")).toBe("true");
expect(slack.getAttribute("aria-checked")).toBe("false");
});
it("checking a toolkit writes the augmented allowlist via the mutation", async () => {
const user = userEvent.setup();
renderTab({ composio_toolkit_allowlist: [] });
await user.click(screen.getByLabelText(/Allow Notion for this agent/i));
expect(mutateSpy).toHaveBeenCalledTimes(1);
expect(mutateSpy.mock.calls[0]?.[0]).toEqual(["notion"]);
});
it("unchecking a toolkit removes only that slug", async () => {
const user = userEvent.setup();
renderTab({ composio_toolkit_allowlist: ["notion", "slack"] });
await user.click(screen.getByLabelText(/Allow Notion for this agent/i));
expect(mutateSpy).toHaveBeenCalledTimes(1);
expect(mutateSpy.mock.calls[0]?.[0]).toEqual(["slack"]);
});
it("only offers active connections — expired/revoked are not selectable", () => {
connectionsRef.current = [
{ toolkit_slug: "notion", status: "active" },
{ toolkit_slug: "github", status: "expired" },
];
renderTab({ composio_toolkit_allowlist: [] });
expect(screen.getByLabelText(/Allow Notion for this agent/i)).toBeTruthy();
expect(screen.queryByLabelText(/Allow github for this agent/i)).toBeNull();
});
it("shows an empty state with a Settings link when there are no active connections", () => {
connectionsRef.current = [];
renderTab({ composio_toolkit_allowlist: [] });
expect(screen.getByText(/No connected apps yet/i)).toBeTruthy();
const link = screen.getByTestId("app-link");
expect(link.getAttribute("href")).toBe("/ws/settings?tab=integrations");
});
it("renders a defensive hidden state when the allowlist is redacted", () => {
renderTab({ composio_toolkit_allowlist_redacted: true });
expect(screen.getByText(/hidden from your view/i)).toBeTruthy();
expect(screen.queryByLabelText(/Allow Notion for this agent/i)).toBeNull();
});
});

View File

@@ -0,0 +1,167 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, Lock, Plug } from "lucide-react";
import { toast } from "sonner";
import type { Agent, ComposioToolkit } from "@multica/core/types";
import { useUpdateAgentAllowlist } from "@multica/core/agents";
import {
composioConnectionsOptions,
composioToolkitsOptions,
} from "@multica/core/composio";
import { useWorkspacePaths } from "@multica/core/paths";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
import { ComposioToolkitLogo } from "../../../common/composio-toolkit-logo";
import { AppLink } from "../../../navigation";
import { useT } from "../../../i18n";
/**
* Creator-only MCP tab on the agent detail page (MUL-3870). Lets the agent
* owner pick which of *their own* active Composio connections this agent may
* mount as MCP servers — the selection is written to
* `agent.composio_toolkit_allowlist` and only takes effect at dispatch when
* the run originator is the owner (the backend gate, MUL-3869).
*
* Visibility is enforced by the parent (the tab entry isn't rendered unless
* `agent.owner_id === viewer.id`), so this component assumes the owner. It
* still renders a defensive "hidden" state if the server redacted the
* allowlist, and reads the checked state straight from the agent prop so the
* optimistic cache write in `useUpdateAgentAllowlist` flips each box
* instantly.
*/
export function AgentMcpTab({ agent }: { agent: Agent }) {
const { t } = useT("agents");
const paths = useWorkspacePaths();
const updateAllowlist = useUpdateAgentAllowlist(agent.id);
const connectionsQuery = useQuery(composioConnectionsOptions());
const toolkitsQuery = useQuery(composioToolkitsOptions());
// Toolkit metadata (name / logo) keyed by slug, so each connection row can
// render a friendly label instead of the bare slug. The catalog is a
// best-effort enrichment — a missing entry just falls back to the slug.
const toolkitBySlug = useMemo(() => {
const m = new Map<string, ComposioToolkit>();
for (const tk of toolkitsQuery.data ?? []) m.set(tk.slug, tk);
return m;
}, [toolkitsQuery.data]);
// Only ACTIVE connections are selectable — an expired / revoked connection
// can't back an MCP mount, so offering its checkbox would be a dead toggle.
// Dedupe by slug (a user could in theory hold two rows for one toolkit).
const activeSlugs = useMemo(() => {
const seen = new Set<string>();
const out: string[] = [];
for (const c of connectionsQuery.data ?? []) {
if (c.status !== "active") continue;
if (seen.has(c.toolkit_slug)) continue;
seen.add(c.toolkit_slug);
out.push(c.toolkit_slug);
}
return out;
}, [connectionsQuery.data]);
const allowlist = useMemo(
() => agent.composio_toolkit_allowlist ?? [],
[agent.composio_toolkit_allowlist],
);
const settingsHref = `${paths.settings()}?tab=integrations`;
const handleToggle = (slug: string, checked: boolean) => {
const set = new Set(allowlist);
if (checked) set.add(slug);
else set.delete(slug);
const next = Array.from(set);
updateAllowlist.mutate(next, {
onError: () => toast.error(t(($) => $.tab_body.composio_mcp.save_failed_toast)),
});
};
// Defensive: the tab is owner-gated, so a redacted allowlist should never
// reach here. If it somehow does (stale cache, future fan-out), show the
// same "configured but hidden" affordance as the MCP config tab rather than
// an empty editor that a Save could clobber.
if (agent.composio_toolkit_allowlist_redacted === true) {
return (
<div className="space-y-3">
<p className="flex items-center gap-2 text-sm font-medium">
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
{t(($) => $.tab_body.composio_mcp.redacted_title)}
</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.composio_mcp.redacted_hint)}
</p>
</div>
);
}
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.composio_mcp.subtitle)}
</p>
{connectionsQuery.isLoading ? (
<p className="text-sm text-muted-foreground">
{t(($) => $.tab_body.composio_mcp.loading)}
</p>
) : connectionsQuery.isError ? (
<p className="text-sm text-destructive">
{t(($) => $.tab_body.composio_mcp.load_failed)}
</p>
) : activeSlugs.length === 0 ? (
<div className="space-y-2 rounded-lg border border-dashed p-6 text-center">
<p className="text-sm font-medium">
{t(($) => $.tab_body.composio_mcp.empty_title)}
</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.composio_mcp.empty_hint)}
</p>
<AppLink
href={settingsHref}
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:underline"
>
<Plug className="h-3 w-3" />
{t(($) => $.tab_body.composio_mcp.empty_link_to_settings)}
</AppLink>
</div>
) : (
<ul className="divide-y rounded-lg border">
{activeSlugs.map((slug) => {
const tk = toolkitBySlug.get(slug);
const name = tk?.name || slug;
const checked = allowlist.includes(slug);
return (
<li key={slug} className="flex items-center gap-3 p-3">
<ComposioToolkitLogo slug={slug} name={name} fallbackLogo={tk?.logo} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{name}</p>
<p className="truncate text-[10px] uppercase tracking-wide text-emerald-600">
{t(($) => $.tab_body.composio_mcp.connected)}
</p>
</div>
<Checkbox
checked={checked}
disabled={updateAllowlist.isPending}
onCheckedChange={(value) => handleToggle(slug, value === true)}
aria-label={t(($) => $.tab_body.composio_mcp.toggle_aria, {
toolkit: name,
})}
/>
</li>
);
})}
</ul>
)}
{updateAllowlist.isPending && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t(($) => $.tab_body.composio_mcp.saving)}
</p>
)}
</div>
);
}

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

@@ -4,7 +4,7 @@ import { useState } from "react";
import {
Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil,
Ban, ChevronDown, ChevronRight,
Webhook, Copy, Check, RotateCw,
Webhook, Copy, Check, RotateCw, Users,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries";
@@ -62,6 +62,7 @@ import type { AgentTask } from "@multica/core/types/agent";
import { ReadonlyContent } from "../../editor";
import { TranscriptButton } from "../../common/task-transcript";
import { AutopilotDialog } from "./autopilot-dialog";
import { ManageAccessDialog } from "./manage-access-dialog";
import { WebhookPayloadPreview } from "./webhook-payload-preview";
import { WebhookDeliveriesSection } from "./webhook-deliveries-section";
import { ProjectIcon } from "../../projects/components/project-icon";
@@ -256,7 +257,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 +330,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 +340,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 +387,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>
)}
@@ -632,6 +635,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [accessDialogOpen, setAccessDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -683,6 +687,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 +758,38 @@ 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" />
canWrite ? (
<>
{canManageAccess && (
<Button size="sm" variant="outline" onClick={() => setAccessDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.manage_access)}>
<Users className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t(($) => $.detail.manage_access)}</span>
</Button>
)}
<span className="hidden sm:inline">
{triggerAutopilot.isPending
? t(($) => $.detail.running)
: t(($) => $.detail.run_now)}
</span>
</Button>
</>
<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 +889,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 +903,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 +942,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>
@@ -957,6 +982,14 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
triggers={triggers}
/>
)}
{accessDialogOpen && (
<ManageAccessDialog
open={accessDialogOpen}
onOpenChange={setAccessDialogOpen}
autopilotId={autopilot.id}
collaborators={collaborators}
/>
)}
<AlertDialog
open={deleteConfirmOpen}
onOpenChange={(v) => { if (!v && !deleting) setDeleteConfirmOpen(false); }}

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()}

View File

@@ -0,0 +1,177 @@
"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 {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
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.
export function ManageAccessDialog({
open,
onOpenChange,
autopilotId,
collaborators,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogTitle>{t(($) => $.access.title)}</DialogTitle>
<p className="text-sm text-muted-foreground">
{t(($) => $.access.description)}
</p>
<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="start"
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>
)}
</div>
<p className="text-xs text-muted-foreground">
{t(($) => $.access.owner_note)}
</p>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { ComposioToolkitLogo, composioToolkitLogoUrl } from "./composio-toolkit-logo";
describe("ComposioToolkitLogo", () => {
it("builds Composio logo URLs from toolkit slugs", () => {
expect(composioToolkitLogoUrl(" GitHub ")).toBe("https://logos.composio.dev/api/github");
expect(composioToolkitLogoUrl("vercel", "dark")).toBe(
"https://logos.composio.dev/api/vercel?theme=dark",
);
});
it("uses backend logo for light mode and Composio dark logo for dark mode", () => {
const { container } = render(
<ComposioToolkitLogo
slug="slack"
name="Slack"
fallbackLogo="https://cdn.example/slack.svg"
/>,
);
const images = Array.from(container.querySelectorAll("img"));
expect(images).toHaveLength(2);
expect(images[0]?.getAttribute("src")).toBe("https://cdn.example/slack.svg");
expect(images[1]?.getAttribute("src")).toBe(
"https://logos.composio.dev/api/slack?theme=dark",
);
});
it("keeps the dark logo when the hidden light logo fails", () => {
const { container } = render(<ComposioToolkitLogo slug="notion" name="Notion" />);
const light = container.querySelector("img.dark\\:hidden");
expect(light?.getAttribute("src")).toBe("https://logos.composio.dev/api/notion");
fireEvent.error(light!);
const dark = Array.from(container.querySelectorAll("img")).find((img) =>
img.className.includes("dark:block"),
);
expect(dark?.getAttribute("src")).toBe(
"https://logos.composio.dev/api/notion?theme=dark",
);
});
});

View File

@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@multica/ui/lib/utils";
const COMPOSIO_LOGO_BASE_URL = "https://logos.composio.dev/api";
export function composioToolkitLogoUrl(slug: string, theme?: "dark") {
const normalized = slug.trim().toLowerCase();
if (!normalized) return "";
const base = `${COMPOSIO_LOGO_BASE_URL}/${encodeURIComponent(normalized)}`;
return theme === "dark" ? `${base}?theme=dark` : base;
}
function uniqueNonEmpty(values: Array<string | null | undefined>) {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
const normalized = value?.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
out.push(normalized);
}
return out;
}
export function ComposioToolkitLogo({
slug,
name,
fallbackLogo,
className,
}: {
slug: string;
name?: string;
fallbackLogo?: string | null;
className?: string;
}) {
const label = name || slug;
const initial = label.charAt(0).toUpperCase();
const dynamicLightLogo = composioToolkitLogoUrl(slug);
const dynamicDarkLogo = composioToolkitLogoUrl(slug, "dark");
const lightSources = uniqueNonEmpty([fallbackLogo, dynamicLightLogo]);
const darkSources = uniqueNonEmpty([dynamicDarkLogo, fallbackLogo, dynamicLightLogo]);
const [failedLightSources, setFailedLightSources] = useState(0);
const [failedDarkSources, setFailedDarkSources] = useState(0);
useEffect(() => {
setFailedLightSources(0);
setFailedDarkSources(0);
}, [slug, fallbackLogo]);
const imgClassName = cn("h-8 w-8 shrink-0 rounded bg-muted object-contain", className);
const fallbackClassName = cn(
"h-8 w-8 shrink-0 items-center justify-center rounded bg-muted text-xs font-semibold text-muted-foreground",
className,
);
const lightSrc = lightSources[failedLightSources];
const darkSrc = darkSources[failedDarkSources];
return (
<>
{lightSrc ? (
<img
src={lightSrc}
alt=""
className={cn(imgClassName, "dark:hidden")}
onError={() => setFailedLightSources((n) => n + 1)}
/>
) : (
<div className={cn(fallbackClassName, "flex dark:hidden")}>{initial}</div>
)}
{darkSrc ? (
<img
src={darkSrc}
alt=""
className={cn(imgClassName, "hidden dark:block")}
onError={() => setFailedDarkSources((n) => n + 1)}
/>
) : (
<div className={cn(fallbackClassName, "hidden dark:flex")}>{initial}</div>
)}
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { BarChart3, FolderKanban } from "lucide-react";
import { BarChart3, FolderKanban, Trash2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
@@ -52,8 +52,9 @@ import {
aggregateDailyTokens,
aggregateWeeklyTasks,
aggregateWeeklyTime,
bucketUnknownAgentRows,
computeDailyTotals,
filterKnownAgentRows,
DELETED_AGENTS_ROW_ID,
formatDuration,
mergeAgentDashboardRows,
type AgentDashboardRow,
@@ -314,17 +315,29 @@ export function DashboardPage() {
[agentTokenRows, runTimeRows],
);
// Hide rollup rows for agents that were hard-deleted from the workspace —
// they'd otherwise show up as a bare UUID on the leaderboard (MUL-3771).
// Archived agents stay (the agent list is fetched with archived included);
// only truly-removed agents drop out. Skip filtering until the agent list
// has loaded so a slow agents fetch doesn't transiently blank the list.
// Fold rollup rows for hard-deleted agents into one aggregated "Deleted
// agents" row instead of showing them as a bare UUID (MUL-3771) or dropping
// them outright — dropping made the per-agent breakdown stop reconciling
// with the top-line Cost/Tokens KPIs, which still count that spend (MUL-3776,
// #4640). Archived agents stay as themselves (the agent list is fetched with
// archived included); only truly-removed agents collapse into the bucket.
// Skip bucketing until the agent list has loaded so a slow agents fetch
// doesn't transiently merge every row.
const knownAgentIds = useMemo(
() => (agentsQuery.isSuccess ? new Set(agents.map((a) => a.id)) : null),
[agentsQuery.isSuccess, agents],
);
const visibleAgentRows = useMemo(
() => filterKnownAgentRows(agentRows, knownAgentIds),
() => bucketUnknownAgentRows(agentRows, knownAgentIds),
[agentRows, knownAgentIds],
);
// Distinct hard-deleted agents folded into the bucket — drives the caption's
// "· N deleted" suffix (the bucket itself is a single row).
const deletedAgentCount = useMemo(
() =>
knownAgentIds
? agentRows.filter((r) => !knownAgentIds.has(r.agentId)).length
: 0,
[agentRows, knownAgentIds],
);
@@ -431,6 +444,7 @@ export function DashboardPage() {
<Leaderboard
rows={visibleAgentRows}
agents={agents}
deletedAgentCount={deletedAgentCount}
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
/>
</>
@@ -640,10 +654,12 @@ const SORT_METRIC: Record<LeaderboardSort, (r: AgentDashboardRow) => number> = {
function Leaderboard({
rows,
agents,
deletedAgentCount,
lessThanMinuteLabel,
}: {
rows: AgentDashboardRow[];
agents: { id: string; name: string }[];
deletedAgentCount: number;
lessThanMinuteLabel: string;
}) {
const { t } = useT("usage");
@@ -684,7 +700,12 @@ function Leaderboard({
<div className="flex items-center gap-3">
<Segmented value={sortBy} onChange={setSortBy} options={sortOptions} />
<span className="text-xs text-muted-foreground">
{t(($) => $.leaderboard.caption, { count: rows.length })}
{deletedAgentCount > 0
? t(($) => $.leaderboard.caption_with_deleted, {
count: rows.length - 1,
deleted: deletedAgentCount,
})
: t(($) => $.leaderboard.caption, { count: rows.length })}
</span>
</div>
</div>
@@ -704,6 +725,11 @@ function Leaderboard({
</div>
<div className="divide-y">
{sortedRows.map((row) => {
// The deleted-agents bucket is a synthetic row, not a real agent:
// render a neutral placeholder (no avatar fetch / hover card / UUID)
// and dash out Time/Tasks, which it never carries (see
// bucketUnknownAgentRows).
const isDeletedBucket = row.agentId === DELETED_AGENTS_ROW_ID;
const agent = agents.find((a) => a.id === row.agentId);
const value = SORT_METRIC[sortBy](row);
const pct = maxValue > 0 ? (value / maxValue) * 100 : 0;
@@ -713,15 +739,28 @@ function Leaderboard({
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_5rem_5rem_5rem_4rem] items-center gap-3 px-4 py-2"
>
<div className="flex min-w-0 items-center gap-2">
<ActorAvatar
actorType="agent"
actorId={row.agentId}
size={22}
enableHoverCard
/>
<span className="cursor-pointer truncate text-sm font-medium">
{agent?.name ?? row.agentId}
</span>
{isDeletedBucket ? (
<>
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Trash2 className="h-3 w-3" />
</span>
<span className="truncate text-sm font-medium italic text-muted-foreground">
{t(($) => $.leaderboard.deleted_agents)}
</span>
</>
) : (
<>
<ActorAvatar
actorType="agent"
actorId={row.agentId}
size={22}
enableHoverCard
/>
<span className="cursor-pointer truncate text-sm font-medium">
{agent?.name ?? row.agentId}
</span>
</>
)}
</div>
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
<div
@@ -742,12 +781,14 @@ function Leaderboard({
<div
className={`text-right text-xs tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{formatDuration(row.seconds, lessThanMinuteLabel)}
{isDeletedBucket
? "—"
: formatDuration(row.seconds, lessThanMinuteLabel)}
</div>
<div
className={`text-right text-xs tabular-nums ${sortBy === "tasks" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{row.taskCount}
{isDeletedBucket ? "—" : row.taskCount}
</div>
</div>
);

View File

@@ -4,8 +4,9 @@ import {
aggregateDailyCost,
aggregateWeeklyTasks,
aggregateWeeklyTime,
bucketUnknownAgentRows,
computeDailyTotals,
filterKnownAgentRows,
DELETED_AGENTS_ROW_ID,
formatDuration,
mergeAgentDashboardRows,
} from "./utils";
@@ -202,26 +203,81 @@ describe("mergeAgentDashboardRows", () => {
});
});
describe("filterKnownAgentRows", () => {
const rows = [
{ agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 },
{ agentId: "deleted", tokens: 50, cost: 0.5, seconds: 5, taskCount: 1 },
];
describe("bucketUnknownAgentRows", () => {
const live = { agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 };
const archived = {
agentId: "archived",
tokens: 80,
cost: 0.8,
seconds: 8,
taskCount: 2,
};
const deletedA = {
agentId: "deleted-a",
tokens: 50,
cost: 0.5,
seconds: 5,
taskCount: 1,
};
const deletedB = {
agentId: "deleted-b",
tokens: 30,
cost: 0.25,
seconds: 3,
taskCount: 4,
};
it("drops rows whose agent is no longer in the workspace", () => {
// "deleted" is absent from the known set — it's a hard-deleted agent whose
// legacy rollup row would otherwise render as a bare UUID.
const out = filterKnownAgentRows(rows, new Set(["live"]));
expect(out.map((r) => r.agentId)).toEqual(["live"]);
it("folds every hard-deleted agent into one aggregated bucket row", () => {
// "deleted-a" / "deleted-b" are absent from the known set — they'd otherwise
// render as bare UUIDs. They collapse into a single sentinel row.
const out = bucketUnknownAgentRows(
[live, deletedA, deletedB],
new Set(["live"]),
);
expect(out.map((r) => r.agentId)).toEqual(["live", DELETED_AGENTS_ROW_ID]);
const bucket = out.find((r) => r.agentId === DELETED_AGENTS_ROW_ID)!;
expect(bucket.tokens).toBe(80);
expect(bucket.cost).toBeCloseTo(0.75);
// Time/Tasks never attach to the bucket — the run-time rollup inner-joins
// `agent`, so deleted agents contribute nothing to those columns.
expect(bucket.seconds).toBe(0);
expect(bucket.taskCount).toBe(0);
});
it("keeps every row while the agent list is still loading (null set)", () => {
const out = filterKnownAgentRows(rows, null);
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted"]);
it("keeps the bucket total reconciled with the top-line spend", () => {
// The KPI total counts deleted-agent spend; sum(visible rows) must match it
// so the breakdown reconciles (MUL-3776).
const out = bucketUnknownAgentRows(
[live, deletedA, deletedB],
new Set(["live"]),
);
const visibleCost = out.reduce((s, r) => s + r.cost, 0);
const kpiCost = [live, deletedA, deletedB].reduce((s, r) => s + r.cost, 0);
expect(visibleCost).toBeCloseTo(kpiCost);
});
it("drops every row when the known set is empty", () => {
expect(filterKnownAgentRows(rows, new Set())).toEqual([]);
it("keeps archived agents as themselves, never in the bucket", () => {
// The agent list is fetched with archived included, so archived agents are
// in the known set and stay on the board under their own id.
const out = bucketUnknownAgentRows(
[live, archived, deletedA],
new Set(["live", "archived"]),
);
expect(out.map((r) => r.agentId)).toEqual([
"live",
"archived",
DELETED_AGENTS_ROW_ID,
]);
});
it("adds no bucket row when every agent is known", () => {
const out = bucketUnknownAgentRows([live, archived], new Set(["live", "archived"]));
expect(out.map((r) => r.agentId)).toEqual(["live", "archived"]);
});
it("keeps every row untouched while the agent list is still loading (null set)", () => {
const out = bucketUnknownAgentRows([live, deletedA], null);
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted-a"]);
});
});

View File

@@ -227,21 +227,54 @@ export function mergeAgentDashboardRows(
});
}
// Drop usage rows whose agent no longer exists in the workspace. The agent
// list is fetched with `include_archived: true`, so archived agents keep
// their names and stay on the leaderboard; only hard-deleted agents fall out
// of `knownAgentIds`. Those are legacy rollup rows that would otherwise
// render as a bare UUID (MUL-3771).
// Synthetic agentId for the row that aggregates all hard-deleted agents.
// Sentinel (not a real UUID) so the component can detect it and render a
// placeholder instead of looking the id up in the agent list.
export const DELETED_AGENTS_ROW_ID = "__deleted_agents__";
// Fold usage rows whose agent no longer exists in the workspace into a single
// aggregated "Deleted agents" row instead of dropping them. The agent list is
// fetched with `include_archived: true`, so archived agents keep their names
// and stay on the leaderboard as themselves; only hard-deleted agents fall out
// of `knownAgentIds` and collapse into the bucket.
//
// `knownAgentIds` is empty while the agent list is still loading; callers
// MUL-3771 (PR #4637) originally *dropped* these rows 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`), so the
// per-agent breakdown no longer reconciled with the totals (MUL-3776, #4640).
// Aggregating instead of dropping keeps `sum(visible rows) == KPI total` while
// still never exposing a UUID. The bucket carries tokens + cost only; seconds
// and taskCount stay 0 because the run-time rollups inner-join `agent`, so
// deleted agents already contribute nothing to the Time/Tasks KPIs — the
// component renders those two columns as "—" for this row.
//
// `knownAgentIds` is `null` while the agent list is still loading; callers
// pass `null` in that case so the rows pass through untouched instead of the
// whole leaderboard blanking on a slow fetch.
export function filterKnownAgentRows(
// whole leaderboard collapsing into one bucket on a slow fetch.
export function bucketUnknownAgentRows(
rows: AgentDashboardRow[],
knownAgentIds: ReadonlySet<string> | null,
): AgentDashboardRow[] {
if (!knownAgentIds) return rows;
return rows.filter((r) => knownAgentIds.has(r.agentId));
const known: AgentDashboardRow[] = [];
const bucket: AgentDashboardRow = {
agentId: DELETED_AGENTS_ROW_ID,
tokens: 0,
cost: 0,
seconds: 0,
taskCount: 0,
};
let hasDeleted = false;
for (const r of rows) {
if (knownAgentIds.has(r.agentId)) {
known.push(r);
continue;
}
hasDeleted = true;
bucket.tokens += r.tokens;
bucket.cost += r.cost;
}
return hasDeleted ? [...known, bucket] : known;
}
// ---------------------------------------------------------------------------

View File

@@ -262,6 +262,40 @@ describe("Attachment — image dispatch", () => {
);
});
it("prefers a local disk /uploads URL over API markdown in split-origin self-host", () => {
getBaseUrlMock.mockReturnValue("https://api.example.test");
const id = "11111111-2222-3333-4444-555555555555";
const markdownUrl = `https://api.example.test/api/attachments/${id}/download`;
const mediaUrl = "https://api.example.test/uploads/workspaces/ws-1/shot.png";
const att = makeRecord({
id,
url: "/uploads/workspaces/ws-1/shot.png",
markdown_url: markdownUrl,
download_url: `/api/attachments/${id}/download`,
});
resolverState.attachments = [att];
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: markdownUrl,
filename: "shot.png",
forceKind: "image",
}}
/>,
);
expect(document.querySelector("img")?.getAttribute("src")).toBe(mediaUrl);
fireEvent.click(screen.getByTitle("View"));
const imageSrcs = [...document.querySelectorAll("img")].map((img) =>
img.getAttribute("src"),
);
expect(imageSrcs).toEqual([mediaUrl, mediaUrl]);
});
it("opens preview with the same resolved media URL when a reopened draft record has no download_url", () => {
configStore.setState({ cdnDomain: "cdn.example.test" });
const id = "11111111-2222-3333-4444-555555555555";

View File

@@ -237,12 +237,17 @@ function absolutizeMediaURL(rawUrl: string): string {
// reports `cdn_signed` — in CloudFront signed-URL mode the same
// domain serves PRIVATE content and a raw (unsigned) storage URL is
// a guaranteed 403 (MUL-3254).
// 3. `record.markdown_url` — the durable, server-policy-aligned URL.
// 3. Local disk `record.url` — self-host LocalStorage without
// LOCAL_UPLOAD_BASE_URL stores a site-relative `/uploads/...` path.
// It is the direct static object URL and is loadable once
// `absolutizeMediaURL` prefixes apiBaseUrl in split-origin clients.
// 4. `record.markdown_url` — the durable, server-policy-aligned URL.
// Beats raw `record.url` because it never points at a private
// bucket (must-fix 2 from MUL-3192 review).
// 4. `record.url` — legacy fallback for responses that omit
// bucket (must-fix 2 from MUL-3192 review), except for the explicit
// site-relative local upload path above.
// 5. `record.url` — legacy fallback for responses that omit
// `markdown_url` (a backend old enough to predate MUL-3192).
// 5. The input URL — when there's no record at all.
// 6. The input URL — when there's no record at all.
function pickInlineMediaURL(
record: AttachmentRecord,
fallback: string,
@@ -257,11 +262,18 @@ function pickInlineMediaURL(
return dl;
}
if (!cdnSigned && storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url;
if (isSiteRelativeLocalUploadURL(record.url)) return record.url;
if (record.markdown_url) return record.markdown_url;
if (record.url) return record.url;
return fallback;
}
function isSiteRelativeLocalUploadURL(rawURL: string): boolean {
if (!rawURL || !rawURL.startsWith("/")) return false;
const path = rawURL.split(/[?#]/, 1)[0] ?? "";
return path === "/uploads" || path.startsWith("/uploads/");
}
function storageURLMatchesCdnDomain(rawURL: string, cdnDomain: string): boolean {
const expected = normalizeHost(cdnDomain);
if (!rawURL || !expected) return false;

View File

@@ -46,8 +46,6 @@ import { deriveThreadResolution } from "./thread-utils";
const highlightedCommentBackgroundClass =
"bg-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
const highlightedCommentFadeClass =
"after:from-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
function StickyHeaderShell({
className,
@@ -67,9 +65,8 @@ function StickyHeaderShell({
return (
<div
className={cn(
"sticky top-0 z-10 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-1 after:bg-gradient-to-b after:to-transparent",
"sticky top-0 z-10 transition-colors duration-700 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-1 after:bg-[inherit] after:[mask-image:linear-gradient(to_bottom,#000,transparent)] after:[-webkit-mask-image:linear-gradient(to_bottom,#000,transparent)]",
highlighted ? highlightedCommentBackgroundClass : "bg-card",
highlighted ? highlightedCommentFadeClass : "after:from-card",
)}
>
<div className={className}>

View File

@@ -216,6 +216,7 @@
"environment": "Environment",
"custom_args": "Custom Args",
"mcp_config": "MCP",
"composio_mcp": "MCP Apps",
"integrations": "Integrations",
"runtime_config": "Routing",
"discard_dialog_title": "Discard unsaved changes?",
@@ -329,6 +330,20 @@
"redacted_title": "Configured — hidden from your view",
"redacted_hint": "Only the agent owner or a workspace admin can read this config."
},
"composio_mcp": {
"subtitle": "Check a toolkit to let this agent mount it as an MCP server — but only when you (its creator) are the one who triggered the run, directly or down a sub-agent chain.",
"loading": "Loading your connections…",
"load_failed": "Couldn't load your connected apps. Try again shortly.",
"empty_title": "No connected apps yet",
"empty_hint": "You haven't connected any third-party services. Authorize one first, then come back here to allow it.",
"empty_link_to_settings": "Connect one in Settings → Integrations",
"connected": "Connected",
"toggle_aria": "Allow {{toolkit}} for this agent",
"saving": "Saving…",
"save_failed_toast": "Couldn't save — please try again",
"redacted_title": "Configured — hidden from your view",
"redacted_hint": "Only the agent's creator can view or change which apps it may use."
},
"runtime_config": {
"intro": "Choose how the OpenClaw runtime executes this agent's turns. Local mode runs the agent inside the daemon process. Gateway mode forwards each turn to an OpenClaw Gateway — useful when the daemon host is a lightweight coordinator and the agent should run on a more powerful machine.",
"mode_label": "Routing mode",
@@ -370,7 +385,7 @@
},
"integrations": {
"intro": "Connect this agent to external chat platforms so people can work with it where they already are.",
"members_note": "Only workspace owners and admins can bind a Lark Bot to an agent. You can view connected bots in Settings → Integrations."
"members_note": "Only workspace owners and admins can connect an agent to an external chat platform. You can view connected bots in Settings → Integrations."
},
"activity": {
"section_now": "Now",

View File

@@ -70,6 +70,7 @@
"pause_aria": "Pause autopilot",
"activate_aria": "Activate autopilot",
"edit": "Edit",
"manage_access": "Manage access",
"run_now": "Run now",
"running": "Running...",
"toast_triggered": "Autopilot triggered",
@@ -101,6 +102,20 @@
"deleting": "Deleting..."
}
},
"access": {
"title": "Manage access",
"description": "Members you add can edit, run, and manage this autopilot's triggers and webhooks.",
"current_label": "With access",
"add": "Add member",
"search_placeholder": "Search members…",
"no_results": "No members found",
"remove_tooltip": "Remove access",
"empty": "No one has been granted access yet.",
"toast_granted": "Access granted",
"toast_revoked": "Access removed",
"toast_failed": "Couldn't update access",
"owner_note": "The creator and workspace admins always have access."
},
"run_status": {
"issue_created": "Issue Created",
"running": "Running",

View File

@@ -24,5 +24,20 @@
"error_already_bound": "This Lark account is already bound to a different Multica user. Account transfers must go through an explicit unbind first.",
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
},
"slack_bind": {
"page_title": "Link your Slack account",
"redeeming": "Linking your account…",
"needs_auth_description": "Sign in to Multica to complete the link. The token in the link binds your Slack account to this Multica user, so you must be logged in first.",
"sign_in": "Sign in",
"done_title": "You're linked.",
"done_description": "Your next message to the bot in Slack will go straight to the agent. You can close this tab.",
"error_title": "Couldn't complete the link",
"error_admin_hint": "If this keeps happening, message the bot again in Slack to get a fresh link.",
"error_missing_token": "The link is missing its token. Message the bot again in Slack to get a new one.",
"error_expired": "This link is invalid or expired (links are valid for 15 minutes). Message the bot again to get a new one.",
"error_already_bound": "This Slack account is already linked to a different Multica user. Account transfers must go through an explicit unbind first.",
"error_not_member": "You're signed in to a Multica account that isn't a member of this workspace.",
"error_unknown": "Something went wrong. Try again, and if the problem persists, contact the workspace admin."
}
}

View File

@@ -332,6 +332,8 @@
"agent_activity": {
"hover_header_one": "{{count}} agent working",
"hover_header_other": "{{count}} agents working",
"hover_header_tasks_one": "{{count}} task working",
"hover_header_tasks_other": "{{count}} tasks working",
"hover_header_queued_one": "{{count}} agent queued",
"hover_header_queued_other": "{{count}} agents queued",
"status_running": "Working",

View File

@@ -300,6 +300,81 @@
"install_error_forbidden": "You no longer have permission to install Lark Bots in this workspace. Ask a workspace admin to continue.",
"install_error_generic": "Install failed. Try again."
},
"composio": {
"section_title": "Composio",
"page_description": "Browse the full Composio toolkit catalog and connect the apps your agents can act on. Only toolkits with a configured auth config can be connected right now.",
"not_enabled_title": "Composio integration not enabled",
"not_enabled_description_prefix": "Set",
"not_enabled_description_suffix": "on the server to enable Composio toolkit connections.",
"loading": "Loading toolkits…",
"load_failed": "Failed to load Composio toolkits.",
"empty_title": "No toolkits available",
"empty_description": "Composio returned no toolkits. Check the API key and project configuration.",
"search_placeholder": "Search toolkits…",
"connect": "Connect",
"connecting": "Connecting…",
"connected": "Connected",
"disconnect": "Disconnect",
"disconnecting": "Disconnecting…",
"not_connectable": "Not configured",
"not_connectable_hint": "This toolkit has no auth config in your Composio project yet, so it can't be connected. Add an auth config for it in the Composio dashboard to enable connecting.",
"connect_failed": "Couldn't start the connection. Please try again.",
"disconnect_failed": "Couldn't disconnect. Please try again.",
"toast_disconnected": "Disconnected",
"disconnect_confirm_title": "Disconnect this app?",
"disconnect_confirm_description": "Your connected account will be revoked at Composio and your agents will lose access to this toolkit. You can reconnect later.",
"disconnect_confirm_cancel": "Cancel",
"connections_load_failed": "Couldn't load your existing connections, so connected status may be incomplete.",
"toast_connected": "Connected",
"toast_connect_failed": "Couldn't complete the connection. Please try again.",
"last_used": "Last used {{when}}",
"last_used_never": "Never used",
"expired": "Token expired",
"reconnect": "Reconnect"
},
"slack": {
"section_title": "Slack",
"page_description": "Connect each Multica Agent to its own Slack bot. Members can DM the bot, @mention it in a channel, and type /issue to spin up a new Multica issue.",
"not_enabled_title": "Slack integration not enabled",
"not_enabled_description_prefix": "Set",
"not_enabled_description_suffix": "on the server to enable Slack bot installations.",
"not_enabled_self_host_hint": "Self-hosters: see the project README for details.",
"preview_title": "Slack install coming soon",
"preview_description": "The at-rest key is set, but the hosted Slack app's OAuth credentials are not configured in this deployment. The Connect button will appear here once they are set.",
"connected_bots": "Connected bots",
"loading": "Loading…",
"empty_title": "No bots connected yet",
"empty_description_prefix": "Open an Agent in this workspace and click",
"empty_description_cta": "Connect Slack",
"empty_description_suffix": "to install a bot for it.",
"revoked_badge": "revoked",
"installed_at_label": "Installed {{when}}",
"disconnect": "Disconnect",
"disconnecting": "Disconnecting…",
"disconnect_confirm_title": "Disconnect this Slack bot?",
"disconnect_confirm_description": "The bot will stop receiving Slack messages for this workspace. The installation row is kept for audit; you can re-install later from the same Agent.",
"disconnect_confirm_cancel": "Cancel",
"toast_disconnected": "Disconnected Slack bot",
"toast_disconnect_failed": "Disconnect failed",
"bind_button": "Connect Slack",
"bind_button_title": "Connect {{agent}} to a Slack bot",
"connecting": "Opening Slack…",
"connect_failed_toast": "Could not start the Slack install",
"agent_bot_connected_label": "Connected to Slack",
"agent_bot_disconnect_tooltip": "Unbind this Slack bot from the Agent. The bot will stop receiving Slack messages.",
"agent_bot_manage_link": "Open in Slack",
"agent_bot_manage_tooltip": "Open this bot's Slack workspace.",
"byo_dialog_title": "Connect a Slack bot",
"byo_video_cta": "Watch the setup walkthrough",
"byo_docs_link": "Step-by-step: connect your Multica agent to Slack",
"byo_bot_token_label": "Bot token (xoxb-)",
"byo_app_token_label": "App-level token (xapp-)",
"byo_submit": "Connect",
"byo_submitting": "Connecting…",
"byo_cancel": "Cancel",
"byo_success_toast": "Slack bot connected",
"byo_failed_toast": "Could not connect the Slack bot"
},
"repositories": {
"section_title": "Repositories",
"description": "Git repositories associated with this workspace. Agents use these to clone and work on code.",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "Leaderboard",
"caption": "{{count}} agents",
"caption_with_deleted": "{{count}} agents · {{deleted}} deleted",
"deleted_agents": "Deleted agents",
"header_agent": "Agent",
"header_tokens": "Tokens",
"header_cost": "Cost",

View File

@@ -203,6 +203,7 @@
"environment": "環境",
"custom_args": "カスタム引数",
"mcp_config": "MCP",
"composio_mcp": "MCP アプリ",
"integrations": "連携",
"runtime_config": "ルーティング",
"discard_dialog_title": "保存していない変更を破棄しますか?",
@@ -314,6 +315,20 @@
"redacted_title": "設定済み — 現在の表示では非表示",
"redacted_hint": "この config を読み取れるのは、エージェントのオーナーまたはワークスペースの admin のみです。"
},
"composio_mcp": {
"subtitle": "ツールキットにチェックを入れると、あなた(この agent の作成者)が直接または下位 agent のチェーン経由でこの agent をトリガーしたときに限り、MCP サーバーとしてマウントされます。",
"loading": "接続を読み込み中…",
"load_failed": "接続済みアプリを読み込めませんでした。しばらくしてから再試行してください。",
"empty_title": "接続済みのアプリがありません",
"empty_hint": "サードパーティサービスをまだ接続していません。先に 1 つ認可してから、ここで許可してください。",
"empty_link_to_settings": "設定 → 連携 で接続する",
"connected": "接続済み",
"toggle_aria": "この agent に {{toolkit}} を許可",
"saving": "保存中…",
"save_failed_toast": "保存できませんでした。もう一度お試しください",
"redacted_title": "設定済み — あなたには非表示",
"redacted_hint": "この agent が使用できるアプリを閲覧・変更できるのは作成者のみです。"
},
"runtime_config": {
"intro": "OpenClaw ランタイムがこのエージェントの各ターンをどのように実行するかを選択します。Local モードはエージェントを daemon プロセス内で実行します。Gateway モードは各ターンを OpenClaw Gateway に転送します — daemon のホストが軽量な調整役で、エージェントの実作業はより強力なマシンで動かしたい場合に有用です。",
"mode_label": "ルーティングモード",
@@ -354,7 +369,7 @@
},
"integrations": {
"intro": "このエージェントを外部のチャットプラットフォームに接続し、普段使っているツールから直接やり取りできるようにします。",
"members_note": "エージェントに Lark Bot を紐付けできるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
"members_note": "エージェントを外部チャットプラットフォームに接続できるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
},
"activity": {
"section_now": "現在",

View File

@@ -70,6 +70,7 @@
"pause_aria": "オートパイロットを一時停止",
"activate_aria": "オートパイロットを有効化",
"edit": "編集",
"manage_access": "アクセス管理",
"run_now": "今すぐ実行",
"running": "実行中...",
"toast_triggered": "オートパイロットを実行しました",
@@ -101,6 +102,20 @@
"deleting": "削除中..."
}
},
"access": {
"title": "アクセス管理",
"description": "追加したメンバーは、このオートパイロットの編集・実行や、トリガー・Webhook の管理ができます。",
"current_label": "アクセス権あり",
"add": "メンバーを追加",
"search_placeholder": "メンバーを検索…",
"no_results": "メンバーが見つかりません",
"remove_tooltip": "アクセス権を削除",
"empty": "まだ誰にもアクセス権が付与されていません。",
"toast_granted": "アクセス権を付与しました",
"toast_revoked": "アクセス権を削除しました",
"toast_failed": "アクセス権を更新できませんでした",
"owner_note": "作成者とワークスペース管理者は常にアクセスできます。"
},
"run_status": {
"issue_created": "イシュー作成済み",
"running": "実行中",

View File

@@ -24,5 +24,20 @@
"error_already_bound": "この Lark アカウントはすでに別の Multica ユーザーに連携されています。アカウントを移すには、まず明示的に連携を解除する必要があります。",
"error_not_member": "現在ログイン中の Multica アカウントは、このワークスペースのメンバーではありません。",
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
},
"slack_bind": {
"page_title": "Slack アカウントを連携",
"redeeming": "アカウントを連携しています…",
"needs_auth_description": "連携を完了するには Multica にサインインしてください。リンク内のトークンが、あなたの Slack アカウントをこの Multica ユーザーに紐付けるため、先にログインが必要です。",
"sign_in": "サインイン",
"done_title": "連携が完了しました。",
"done_description": "次に Slack でボットへ送るメッセージは、そのままエージェントに届きます。このタブは閉じて構いません。",
"error_title": "連携を完了できませんでした",
"error_admin_hint": "繰り返し発生する場合は、Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
"error_missing_token": "リンクにトークンがありません。Slack でボットにもう一度メッセージを送って新しいリンクを取得してください。",
"error_expired": "このリンクは無効か期限切れです(有効期限は 15 分)。ボットにもう一度メッセージを送って新しいリンクを取得してください。",
"error_already_bound": "この Slack アカウントは別の Multica ユーザーに連携済みです。移行するにはまず明示的に解除する必要があります。",
"error_not_member": "サインインしている Multica アカウントはこのワークスペースのメンバーではありません。",
"error_unknown": "問題が発生しました。もう一度試し、それでも解決しない場合はワークスペース管理者にお問い合わせください。"
}
}

View File

@@ -319,6 +319,7 @@
},
"agent_activity": {
"hover_header_other": "作業中のエージェント {{count}} 件",
"hover_header_tasks_other": "作業中のタスク {{count}} 件",
"hover_header_queued_other": "待機中のエージェント {{count}} 件",
"status_running": "作業中",
"status_queued": "待機中",

View File

@@ -300,6 +300,81 @@
"install_error_forbidden": "このワークスペースに Lark ボットを設置する権限がなくなりました。ワークスペース管理者にお問い合わせください。",
"install_error_generic": "設置に失敗しました。もう一度お試しください。"
},
"composio": {
"section_title": "Composio",
"page_description": "Browse the full Composio toolkit catalog and connect the apps your agents can act on. Only toolkits with a configured auth config can be connected right now.",
"not_enabled_title": "Composio integration not enabled",
"not_enabled_description_prefix": "Set",
"not_enabled_description_suffix": "on the server to enable Composio toolkit connections.",
"loading": "Loading toolkits…",
"load_failed": "Failed to load Composio toolkits.",
"empty_title": "No toolkits available",
"empty_description": "Composio returned no toolkits. Check the API key and project configuration.",
"search_placeholder": "Search toolkits…",
"connect": "Connect",
"connecting": "Connecting…",
"connected": "Connected",
"disconnect": "Disconnect",
"disconnecting": "Disconnecting…",
"not_connectable": "Not configured",
"not_connectable_hint": "This toolkit has no auth config in your Composio project yet, so it can't be connected. Add an auth config for it in the Composio dashboard to enable connecting.",
"connect_failed": "Couldn't start the connection. Please try again.",
"disconnect_failed": "Couldn't disconnect. Please try again.",
"toast_disconnected": "Disconnected",
"disconnect_confirm_title": "Disconnect this app?",
"disconnect_confirm_description": "Your connected account will be revoked at Composio and your agents will lose access to this toolkit. You can reconnect later.",
"disconnect_confirm_cancel": "Cancel",
"connections_load_failed": "Couldn't load your existing connections, so connected status may be incomplete.",
"toast_connected": "接続しました",
"toast_connect_failed": "接続を完了できませんでした。もう一度お試しください。",
"last_used": "最終使用 {{when}}",
"last_used_never": "未使用",
"expired": "トークンの有効期限切れ",
"reconnect": "再接続"
},
"slack": {
"section_title": "Slack",
"page_description": "各 Multica エージェントを専用の Slack ボットに接続します。メンバーはボットに DM したり、チャンネルで @メンションしたり、/issue と入力して新しい Multica イシューを起こすことができます。",
"not_enabled_title": "Slack 連携が有効になっていません",
"not_enabled_description_prefix": "サーバーで",
"not_enabled_description_suffix": "を設定すると Slack ボットのインストールが有効になります。",
"not_enabled_self_host_hint": "セルフホストの場合: 詳細はプロジェクトの README を参照してください。",
"preview_title": "Slack インストールは近日対応",
"preview_description": "保存用キーは設定済みですが、このデプロイではホスト型 Slack アプリの OAuth 認証情報が未設定です。設定すると接続ボタンがここに表示されます。",
"connected_bots": "接続済みのボット",
"loading": "読み込み中…",
"empty_title": "まだボットが接続されていません",
"empty_description_prefix": "このワークスペースのエージェントを開き、",
"empty_description_cta": "Slack を接続",
"empty_description_suffix": "をクリックしてボットをインストールします。",
"revoked_badge": "取り消し済み",
"installed_at_label": "{{when}} にインストール",
"disconnect": "切断",
"disconnecting": "切断中…",
"disconnect_confirm_title": "この Slack ボットを切断しますか?",
"disconnect_confirm_description": "このボットはこのワークスペースの Slack メッセージを受信しなくなります。インストール記録は監査のため保持され、同じエージェントから再インストールできます。",
"disconnect_confirm_cancel": "キャンセル",
"toast_disconnected": "Slack ボットを切断しました",
"toast_disconnect_failed": "切断に失敗しました",
"bind_button": "Slack を接続",
"bind_button_title": "{{agent}} を Slack ボットに接続",
"connecting": "Slack を開いています…",
"connect_failed_toast": "Slack のインストールを開始できませんでした",
"agent_bot_connected_label": "Slack に接続済み",
"agent_bot_disconnect_tooltip": "この Slack ボットをエージェントから解除します。ボットは Slack メッセージを受信しなくなります。",
"agent_bot_manage_link": "Slack で開く",
"agent_bot_manage_tooltip": "このボットの Slack ワークスペースを開きます。",
"byo_dialog_title": "Slack ボットを接続",
"byo_video_cta": "セットアップ手順の動画を見る",
"byo_docs_link": "Step-by-stepMultica エージェントを Slack に接続する",
"byo_bot_token_label": "Bot トークンxoxb-",
"byo_app_token_label": "App レベルトークンxapp-",
"byo_submit": "接続",
"byo_submitting": "接続中…",
"byo_cancel": "キャンセル",
"byo_success_toast": "Slack ボットを接続しました",
"byo_failed_toast": "Slack ボットを接続できませんでした"
},
"repositories": {
"section_title": "リポジトリ",
"description": "このワークスペースに関連付けられた Git リポジトリです。エージェントはこれらをクローンしてコードを作業します。",

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "リーダーボード",
"caption": "{{count}} 件のエージェント",
"caption_with_deleted": "{{count}} 件のエージェント · 削除済み {{deleted}} 件",
"deleted_agents": "削除済みエージェント",
"header_agent": "エージェント",
"header_tokens": "トークン",
"header_cost": "コスト",

View File

@@ -216,6 +216,7 @@
"environment": "환경",
"custom_args": "사용자 지정 인자",
"mcp_config": "MCP",
"composio_mcp": "MCP 앱",
"integrations": "연동",
"runtime_config": "라우팅",
"discard_dialog_title": "저장하지 않은 변경사항을 버릴까요?",
@@ -329,6 +330,20 @@
"redacted_title": "설정됨 - 현재 보기에서는 숨김",
"redacted_hint": "에이전트 소유자 또는 워크스페이스 관리자만 이 config를 읽을 수 있습니다."
},
"composio_mcp": {
"subtitle": "툴킷을 선택하면, 본인(이 에이전트의 생성자)이 직접 또는 하위 에이전트 체인을 통해 이 에이전트를 트리거할 때만 MCP 서버로 마운트됩니다.",
"loading": "연결을 불러오는 중…",
"load_failed": "연결된 앱을 불러오지 못했습니다. 잠시 후 다시 시도하세요.",
"empty_title": "아직 연결된 앱이 없습니다",
"empty_hint": "아직 서드파티 서비스를 연결하지 않았습니다. 먼저 하나를 인증한 뒤 여기서 허용하세요.",
"empty_link_to_settings": "설정 → 연동에서 연결하기",
"connected": "연결됨",
"toggle_aria": "이 에이전트에 {{toolkit}} 허용",
"saving": "저장 중…",
"save_failed_toast": "저장하지 못했습니다. 다시 시도하세요",
"redacted_title": "설정됨 — 보기에서 숨김",
"redacted_hint": "이 에이전트가 사용할 수 있는 앱은 생성자만 보거나 변경할 수 있습니다."
},
"runtime_config": {
"intro": "OpenClaw 런타임이 이 에이전트의 각 턴을 어떻게 실행할지 선택하세요. Local 모드는 에이전트를 daemon 프로세스 내부에서 실행합니다. Gateway 모드는 각 턴을 OpenClaw Gateway로 전달합니다 — daemon 호스트가 가벼운 조율기 역할을 하고 실제 작업은 더 강력한 머신에서 돌리고 싶을 때 유용합니다.",
"mode_label": "라우팅 모드",
@@ -370,7 +385,7 @@
},
"integrations": {
"intro": "이 에이전트를 외부 채팅 플랫폼에 연결해 팀원이 평소 사용하는 도구에서 바로 함께 작업할 수 있도록 합니다.",
"members_note": "에이전트에 Lark 봇을 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
"members_note": "에이전트를 외부 채팅 플랫폼에 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
},
"activity": {
"section_now": "현재",

View File

@@ -70,6 +70,7 @@
"pause_aria": "오토파일럿 일시 중지",
"activate_aria": "오토파일럿 활성화",
"edit": "수정",
"manage_access": "접근 권한 관리",
"run_now": "지금 실행",
"running": "실행 중...",
"toast_triggered": "오토파일럿을 실행했습니다",
@@ -101,6 +102,20 @@
"deleting": "삭제하는 중..."
}
},
"access": {
"title": "접근 권한 관리",
"description": "추가한 멤버는 이 오토파일럿을 수정·실행하고 트리거와 webhook을 관리할 수 있습니다.",
"current_label": "접근 가능",
"add": "멤버 추가",
"search_placeholder": "멤버 검색…",
"no_results": "멤버를 찾을 수 없습니다",
"remove_tooltip": "접근 권한 제거",
"empty": "아직 아무에게도 접근 권한을 부여하지 않았습니다.",
"toast_granted": "접근 권한을 부여했습니다",
"toast_revoked": "접근 권한을 제거했습니다",
"toast_failed": "접근 권한을 업데이트하지 못했습니다",
"owner_note": "작성자와 워크스페이스 관리자는 항상 접근할 수 있습니다."
},
"run_status": {
"issue_created": "이슈 생성됨",
"running": "실행 중",

View File

@@ -24,5 +24,20 @@
"error_already_bound": "이 Lark 계정은 이미 다른 Multica 사용자에 연결되어 있습니다. 계정 이전은 먼저 명시적으로 연결을 해제해야 합니다.",
"error_not_member": "현재 로그인한 Multica 계정이 이 워크스페이스의 멤버가 아닙니다.",
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
},
"slack_bind": {
"page_title": "Slack 계정 연결",
"redeeming": "계정을 연결하는 중…",
"needs_auth_description": "연결을 완료하려면 Multica에 로그인하세요. 링크의 토큰이 Slack 계정을 이 Multica 사용자와 연결하므로 먼저 로그인해야 해요.",
"sign_in": "로그인",
"done_title": "연결되었어요.",
"done_description": "이제 Slack에서 봇에게 보내는 다음 메시지는 바로 에이전트로 전달돼요. 이 탭은 닫아도 됩니다.",
"error_title": "연결을 완료하지 못했어요",
"error_admin_hint": "계속 발생하면 Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
"error_missing_token": "링크에 토큰이 없어요. Slack에서 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
"error_expired": "이 링크는 유효하지 않거나 만료됐어요(유효 기간 15분). 봇에게 다시 메시지를 보내 새 링크를 받으세요.",
"error_already_bound": "이 Slack 계정은 이미 다른 Multica 사용자에 연결되어 있어요. 이전하려면 먼저 명시적으로 연결을 해제해야 합니다.",
"error_not_member": "로그인한 Multica 계정이 이 워크스페이스의 멤버가 아니에요.",
"error_unknown": "문제가 발생했어요. 다시 시도해 보고, 계속되면 워크스페이스 관리자에게 문의하세요."
}
}

View File

@@ -331,6 +331,8 @@
"agent_activity": {
"hover_header_one": "작업 중인 에이전트 {{count}}개",
"hover_header_other": "작업 중인 에이전트 {{count}}개",
"hover_header_tasks_one": "작업 중인 태스크 {{count}}개",
"hover_header_tasks_other": "작업 중인 태스크 {{count}}개",
"hover_header_queued_one": "대기 중인 에이전트 {{count}}개",
"hover_header_queued_other": "대기 중인 에이전트 {{count}}개",
"status_running": "작업 중",

View File

@@ -376,5 +376,80 @@
"install_error_session_lost": "설치 세션이 만료되었거나 유실되었어요. 다시 스캔해 처음부터 진행하세요.",
"install_error_forbidden": "이 워크스페이스에 Lark 봇을 설치할 권한이 더 이상 없어요. 워크스페이스 관리자에게 문의하세요.",
"install_error_generic": "설치에 실패했어요. 다시 시도하세요."
},
"composio": {
"section_title": "Composio",
"page_description": "Browse the full Composio toolkit catalog and connect the apps your agents can act on. Only toolkits with a configured auth config can be connected right now.",
"not_enabled_title": "Composio integration not enabled",
"not_enabled_description_prefix": "Set",
"not_enabled_description_suffix": "on the server to enable Composio toolkit connections.",
"loading": "Loading toolkits…",
"load_failed": "Failed to load Composio toolkits.",
"empty_title": "No toolkits available",
"empty_description": "Composio returned no toolkits. Check the API key and project configuration.",
"search_placeholder": "Search toolkits…",
"connect": "Connect",
"connecting": "Connecting…",
"connected": "Connected",
"disconnect": "Disconnect",
"disconnecting": "Disconnecting…",
"not_connectable": "Not configured",
"not_connectable_hint": "This toolkit has no auth config in your Composio project yet, so it can't be connected. Add an auth config for it in the Composio dashboard to enable connecting.",
"connect_failed": "Couldn't start the connection. Please try again.",
"disconnect_failed": "Couldn't disconnect. Please try again.",
"toast_disconnected": "Disconnected",
"disconnect_confirm_title": "Disconnect this app?",
"disconnect_confirm_description": "Your connected account will be revoked at Composio and your agents will lose access to this toolkit. You can reconnect later.",
"disconnect_confirm_cancel": "Cancel",
"connections_load_failed": "Couldn't load your existing connections, so connected status may be incomplete.",
"toast_connected": "연결되었습니다",
"toast_connect_failed": "연결을 완료하지 못했습니다. 다시 시도해 주세요.",
"last_used": "마지막 사용 {{when}}",
"last_used_never": "사용한 적 없음",
"expired": "토큰 만료됨",
"reconnect": "다시 연결"
},
"slack": {
"section_title": "Slack",
"page_description": "각 Multica 에이전트를 전용 Slack 봇에 연결하세요. 멤버는 봇과 1:1로 대화하거나, 채널에서 @ 멘션하거나, /issue 를 입력해 새 Multica 이슈를 만들 수 있습니다.",
"not_enabled_title": "Slack 연동이 활성화되지 않았어요",
"not_enabled_description_prefix": "서버에서",
"not_enabled_description_suffix": "를 설정하면 Slack 봇 설치가 활성화됩니다.",
"not_enabled_self_host_hint": "셀프 호스팅: 자세한 내용은 프로젝트 README를 참고하세요.",
"preview_title": "Slack 설치 곧 지원 예정",
"preview_description": "저장용 키는 설정되어 있지만, 이 배포에는 호스팅 Slack 앱의 OAuth 자격 증명이 설정되지 않았어요. 설정하면 연결 버튼이 여기에 표시됩니다.",
"connected_bots": "연결된 봇",
"loading": "불러오는 중…",
"empty_title": "아직 연결된 봇이 없어요",
"empty_description_prefix": "이 워크스페이스의 에이전트를 열고",
"empty_description_cta": "Slack 연결",
"empty_description_suffix": "을(를) 클릭해 봇을 설치하세요.",
"revoked_badge": "해제됨",
"installed_at_label": "{{when}}에 설치됨",
"disconnect": "연결 해제",
"disconnecting": "연결 해제 중…",
"disconnect_confirm_title": "이 Slack 봇을 연결 해제할까요?",
"disconnect_confirm_description": "봇이 이 워크스페이스의 Slack 메시지를 더 이상 받지 않습니다. 설치 기록은 감사를 위해 보관되며, 같은 에이전트에서 다시 설치할 수 있어요.",
"disconnect_confirm_cancel": "취소",
"toast_disconnected": "Slack 봇을 연결 해제했어요",
"toast_disconnect_failed": "연결 해제에 실패했어요",
"bind_button": "Slack 연결",
"bind_button_title": "{{agent}}을(를) Slack 봇에 연결",
"connecting": "Slack 여는 중…",
"connect_failed_toast": "Slack 설치를 시작할 수 없었어요",
"agent_bot_connected_label": "Slack에 연결됨",
"agent_bot_disconnect_tooltip": "이 Slack 봇을 에이전트에서 연결 해제합니다. 봇이 Slack 메시지를 받지 않게 됩니다.",
"agent_bot_manage_link": "Slack에서 열기",
"agent_bot_manage_tooltip": "이 봇의 Slack 워크스페이스를 엽니다.",
"byo_dialog_title": "Slack 봇 연결",
"byo_video_cta": "설정 안내 영상 보기",
"byo_docs_link": "Step-by-step: Multica 에이전트를 Slack에 연결하기",
"byo_bot_token_label": "Bot 토큰(xoxb-)",
"byo_app_token_label": "App 레벨 토큰(xapp-)",
"byo_submit": "연결",
"byo_submitting": "연결 중…",
"byo_cancel": "취소",
"byo_success_toast": "Slack 봇을 연결했어요",
"byo_failed_toast": "Slack 봇을 연결하지 못했어요"
}
}

View File

@@ -41,6 +41,8 @@
"leaderboard": {
"title": "리더보드",
"caption": "에이전트 {{count}}개",
"caption_with_deleted": "에이전트 {{count}}개 · 삭제됨 {{deleted}}개",
"deleted_agents": "삭제된 에이전트",
"header_agent": "에이전트",
"header_tokens": "토큰",
"header_cost": "비용",

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