Compare commits

..

177 Commits

Author SHA1 Message Date
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
Jiayuan Zhang
ff0979008b fix(dashboard): hide deleted agents from usage leaderboard (MUL-3771) (#4637)
* fix(dashboard): hide deleted agents from usage leaderboard (MUL-3771)

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

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

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

* fix(dashboard): stabilize empty agent list

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

---------

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

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

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

Closes #4629

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

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

Addresses review feedback on #4630:

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

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

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

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

---------

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

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

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

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

MUL-3749

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

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

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

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

MUL-3749

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

Addresses code review on #4598:

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

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

---------

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

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

MVP surface (just the endpoints Stage 2 needs):

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

Other notes:

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

Tests:

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

Follow-ups (separate PRs):

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Closes multica-ai/multica#3773

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 12:09:15 +08:00
Naiyuan Qing
f1e6c18e3e fix(issues): add loading state to edit comment save button (MUL-3709) (#4588) 2026-06-26 10:11:21 +08:00
Ryan Yu
0d8df7032c fix(core): parse search API responses (#4572)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 09:37:24 +08:00
Naiyuan Qing
f0d6d88069 fix(views): resolve handoff-note version gate locally for direct agent assigns (#4585)
The run-confirm interception box gated its handoff-note field on the
preview round-trip's `handoff_supported`, so every open showed a
"checking…" wait before the note box could even be used — to learn
something the client already holds. For a concrete agent assignee the
target runtime is exactly that agent's, and its CLI version is already
warm in the prefetched agent + runtime caches, so the box can settle
synchronously, the same way the quick-create version gate does.

Add a frontend `handoffSupported` mirror of the server's
MinHandoffCLIVersion gate, resolve the agent → runtime → cli_version
locally, and drive the note box from that verdict without waiting on
loading. Squad / batch-status / unresolved-agent paths — whose resolved
trigger set is only known server-side — keep falling back to the
preview's `handoff_supported`, unchanged.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 09:21:09 +08:00
Multica Eve
87ddbde316 docs(changelog): add v0.3.30 entry for the 2026-06-25 release (#4573)
Covers the Slack Socket Mode collaboration channel, the editor Tab-to-accept
suggestion, the one-click task-list toggle, and the day's reliability fixes
(OpenClaw schema, project move, attachment previews, board counts, Lark app
URLs, Codex / Kiro / opencode cleanup, webhook rate limiting, skill bundles,
label control characters, and friends).

Localized into en / zh-Hans / ko / ja with product-language wording per the
`Issue`-only exception (`agent` -> "smart agent" / \u667a\u80fd\u4f53, `Squad` -> "squad" / \u5c0f\u961f).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 17:55:58 +08:00
Naiyuan Qing
8ff312dbe9 feat(editor): accept highlighted composer suggestion on Tab (MUL-3685) (#4570)
* feat(editor): accept highlighted composer suggestion on Tab

Plain Tab now accepts the highlighted mention / slash-command suggestion,
matching Enter, across every composer built on the shared TipTap editor
(chat, issue description, comment/reply). A single shared isPickerAcceptKey
predicate centralizes the accept-key policy so the two picker lists stay in
sync instead of each re-deciding what counts as accept.

Shift+Tab and Ctrl/Cmd/Alt+Tab are intentionally NOT accept keys, so reverse
focus navigation and OS window switching are preserved. When no picker is
open, Tab keeps its existing behavior (list indent / focus traversal).

Adds unit coverage for both picker lists plus a plugin-order guard that fires
Tab through real ProseMirror dispatch with the caret inside a list item,
proving the suggestion layer outranks PatchedListItem's Tab -> sinkListItem.

Scope: web/desktop shared composer only; mobile and the generic combobox are
untouched.

Refs: MUL-3685

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

* test(editor): make Tab/list-item priority guard actually exercise sinkListItem

The guard placed the caret in the first (only) bullet item, where
sinkListItem is a no-op (no preceding sibling to nest under), so the test
passed regardless of whether the suggestion layer intercepted Tab. Rebuild
the fixture as a two-item list with the caret in the SECOND item, where
Tab -> sinkListItem can fire, and add a sanity control proving a bare Tab
(no picker) does sink that item — so the accept-wins assertion is meaningful.

Production code unchanged.

Refs: MUL-3685

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-25 16:59:47 +08:00
Multica Eve
7d0c73d11f MUL-3417: tolerate OpenClaw config file CLI mismatch
Closes MUL-3417
Fixes #4299
2026-06-25 16:58:07 +08:00
Naiyuan Qing
8e7d28bff1 fix(issues): emit project_changed so moved issues leave the old project list (MUL-3669) (#4571)
The per-project issue list rides the filtered myAll cache. Changing an
issue's project is a membership change, but the surgical patch
(patchIssueInBuckets) is filter-blind and never removes a card that no
longer matches the list's project filter — so a moved issue stayed visible
in the old project's list until a manual refetch (#4548 / MUL-3669).

Root cause: project_id was the only membership-affecting field with no
server *_changed flag. The WS handler fell back to diffing project_id
against its own cache, which breaks once onMutate has optimistically
overwritten the cached value on a local move.

- server: stamp project_changed on issue:updated (UpdateIssue + Batch),
  alongside status_changed / assignee_changed.
- events.ts: surface project_changed (optional, additive — old clients ignore).
- ws-updaters: prefer the server flag, fall back to the cache diff only when
  absent (older backend) so a new frontend on an old backend does not regress.
- mutations: onSettled invalidates myAll when project_id changed — a local
  safety net that never depends on the WS echo (update + batch).

Tests: WS flag wins over a matching cache (local-move repro), explicit false
suppresses the legacy diff, the cache-diff fallback still fires, and both
mutations invalidate myAll on a project change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:54:11 +08:00
Bohan Jiang
35e5455953 fix: allow split-origin attachment previews (#4539)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 16:01:09 +08:00
Ol1ver0413
adddfbdf89 fix(issues): reconcile board column counts on off-screen status change (#4557)
Board column headers read byStatus[status].total, which #4415 began
maintaining purely client-side via patchIssueInBuckets ±1. That patch
adjusts the totals only when it can locate the issue in a loaded page,
so a status change on an issue outside the loaded window (very common
when an agent flips the status of something the viewer never scrolled
to) was a silent no-op. #4415 also removed the list invalidation that
used to reconcile counts, so the totals drifted until a full reload.

Thread the server's status_changed flag through onIssueUpdated and, when
a status-changed patch cannot be applied surgically (no-op because the
issue is off-screen), refetch just that one list to reconcile its
counts. The loaded/echoed fast path is untouched, so #4415's no-flicker
drag behavior is preserved.

Closes #4554

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:43:32 +08:00
Willow Lopez
33967611b2 fix(cli): add daemon signal check to prevent silent PAT fallback
Closes #4204

Add a daemon signal guard in resolveToken so daemon-managed subprocesses fail closed instead of falling back to the user-global config token when agent/task env markers are missing. Also adds boundary tests for MULTICA_DAEMON_PORT, MULTICA_SERVER_URL, explicit MULTICA_TOKEN priority, and normal config fallback.
2026-06-25 15:25:30 +08:00
苗大
93ed3dc131 fix(lark): use app URL for web links (MUL-3679)
Point Lark binding and issue-created links at the web app URL (MULTICA_APP_URL)
instead of the backend public URL, so users on split-domain / self-host
deployments get working links. appURLFromEnv falls back to FRONTEND_ORIGIN,
matching the backend's existing app-URL resolution.
2026-06-25 15:14:09 +08:00
Multica Eve
aa4478af52 fix(codex): unhang cleanup after stdout scanner overflow (#4520) (#4563)
When codex emits a single stdout line larger than the daemon's 10 MB
bufio.Scanner cap (typical trigger: thread/resume on a long-history
session), the reader goroutine returns scanner.Err()="token too long",
markProcessExited fails the in-flight RPC, and the lifecycle goroutine
enters its failure path. That path calls drainAndWait() — stdin.Close()
+ cmd.Wait() — before sending the failed Result. But cmd.Wait() never
returns: codex is alive and blocked writing the rest of the oversized
line into a stdout pipe nobody is reading, so it never reaches its
stdin read syscall and never sees the EOF. The lifecycle goroutine
therefore never sends Result{failed} to its caller, the outer daemon
blocks on the result channel, and the existing PriorSessionID-with-
empty-SessionID fallback never fires — the task is permanently
stalled and codex (Node wrapper + native Rust app-server) leaks until
the OS reaps them.

The cancel() that would have unblocked things via cmd.WaitDelay's
SIGKILL was registered as a defer AFTER drainAndWait, so LIFO defer
order put cancel last — drainAndWait blocks first, cancel never runs.

Fix:

1. drainAndWait now runs the existing graceful-then-cancel pattern
   itself, in two bounded phases. Phase 1 waits for readerDone (capped
   by codexGracefulShutdownTimeout, so we still give codex its OTEL
   flush window on clean exits); on timeout it cancels the runCtx so
   cmd.Cancel kills the tree and the reader unblocks. Phase 2 bounds
   cmd.Wait() the same way for the scanner-overflow case, where
   readerDone closed early but the process is still alive on a full
   stdout pipe. The success-path cleanup that previously duplicated the
   graceful-cancel pattern around readerDone collapses to a single
   drainAndWait() call.

2. cmd.Cancel is set to send SIGKILL to the whole codex process group
   (Setpgid via configureProcessGroup, signalProcessGroup on cancel)
   instead of just the leader. This addresses YOMXXX's
   orphaned-Codex-child concern: the Node wrapper and the native
   app-server it spawns now both die when cleanup forces the kill,
   rather than the native binary leaking as an orphan reparented to
   init. configureProcessGroup is a no-op on Windows.

3. codexGracefulShutdownTimeoutNanos atomic.Int64 mirrors
   opencodeTerminateGraceNanos so the regression test can shrink the
   grace window from 10 s to 500 ms. Production code is unchanged
   (default 10 s).

Outer daemon (daemon.go) already retries with a fresh session when
result.Status == "failed" && PriorSessionID != "" && result.SessionID
== ""; the failed Result now actually reaches it, so the recovery
fires on its own without any daemon-side change.

Tests:

- New regression TestCodexExecuteCleansUpWhenScannerOverflowsOnResume
  spawns a fake codex that emits an 11 MB single-line thread/resume
  response (trips the scanner cap) and then sleeps without re-reading
  stdin. With the original drainAndWait body it blocks at the 10 s
  executeFakeCodex deadline ("timeout waiting for result") — verified
  by temporarily reverting just the helper body — and with the fix it
  completes in ~1.3 s with Result.Status="failed",
  Result.SessionID="" so the outer fallback can fire.
- Full codex test suite, full agent package, daemon + execenv +
  repocache packages, go build ./..., and go vet on agent/daemon all
  pass.

Out of scope (deferred to follow-up per YOMXXX): bumping the 10 MB
bufio.Scanner cap on codex / claude / copilot / cursor / hermes /
kimi / kiro / codebuddy / antigravity / qoder / openclaw / opencode
(pi already sits at 32 MB), and the shared bounded JSON-RPC line
reader that would eliminate the single-line-overflow risk class
entirely. Buffer size alone is not the fix — recovery behaviour is.

Refs: GH#4520

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 14:46:20 +08:00
Bohan Jiang
cb6616f530 feat(slack): Socket Mode channel.Channel adapter (MUL-3516) (#4523)
* feat(slack): Socket Mode channel.Channel adapter (MUL-3516)

First slice of the Slack adapter: implements channel.Channel (Type/Connect/Disconnect/Send/Capabilities) over Slack Socket Mode, normalizes inbound events to channel.InboundMessage (DM, channel @mention, thread reply; bot-loop + edit/delete guards), decodes the per-installation config/secret blob, and registers the Factory under TypeSlack. No engine, core, or channel_* schema change. Unit-tested (translation, capabilities, config decode, chunking, Send via httptest). Resolvers + engine wiring + Block Kit binding replier follow.

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

* fix(slack): address adapter review (MUL-3516)

- Propagate InboundHandler errors through dispatchEventsAPI/handleSocketEvent to Connect so an infra failure tears down the connection for Supervisor reconnect/backoff instead of being silently swallowed (ACK still happens first).
- Capabilities: declare only CapText | CapThreadReply; drop CapRichCard/CapAttachment/CapMessageEdit until those Send paths are wired.
- slackChatType: map mpim (multi-party DM) to group, not p2p, so the 'must address bot' filter applies; only 1:1 im is p2p.
- Document the group-addressing decision: explicit @bot mention required in groups; mention-free thread continuation deferred to the session-aware layer.
- Tests: handler-error propagation, slackChatType table, mpim-requires-mention, capabilities negative assertions.

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

* refactor(channel): shared channel-agnostic ChatSession service (MUL-3516)

Extract the session/append//issue machinery — currently locked inside the Feishu-pinned lark.chatSessionService — into a shared engine.ChatSession parameterized by channel_type + session titles, so every IM adapter reuses it instead of re-implementing it. Logic is verbatim (find-or-create session+binding with unique-violation race re-read; append+touch+reply-target+in-tx dedup Mark; /issue parse with bare-command previous-message fallback) but channel-neutral: command-parse source is supplied by the adapter (enrichment is platform-specific). Backed by a narrow SessionQueries interface so it is unit-tested with an in-memory fake (no DB). /issue parser moved to engine.ParseIssueCommand. Next: migrate Feishu onto it and wire Slack's ResolverSet, removing the lark duplicate.

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

* fix(channel): decouple session binding key from outbound target (MUL-3516)

Addresses Elon's round-2 review. engine.ChatSession.EnsureSession previously keyed the binding on a raw chat id (EnsureSessionInput.ChatID), so a resolver wiring Slack straight through would collapse every @bot thread in one channel into a single chat_session and overwrite last_thread_id. Make the API un-misusable:

- EnsureSessionInput.ChatID -> BindingKey: the explicit session-isolation key (Feishu: chat id; Slack DM: channel id; Slack channel: channel id + thread root), documented so a raw threaded-platform chat id is never passed straight through.
- Add EnsureSessionInput.BindingConfig (opaque) persisted on the binding's config column, so the real outbound channel/thread is preserved when BindingKey is composite — outbound routing stays separate from the isolation key.
- channel.sql CreateChannelChatSessionBinding now writes config (additive, uses the existing NOT NULL column; lark caller passes '{}', no schema change, no Feishu regression).
- Tests: TestEnsureSession_ThreadRootIsolation (two thread roots in one channel -> two sessions; same root reuses) and TestEnsureSession_StoresBindingConfig.

No production wiring change yet (per review, the not-yet-wired shared service is an accepted preparatory state); this makes the API correct before Feishu/Slack are migrated onto it.

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

* feat(slack): Slack ResolverSet with thread-root session isolation (MUL-3516)

Wires Slack into the channel-agnostic engine.Router via a ResolverSet built on the generic channel_* queries (installation route by team_id, identity + workspace-membership recheck, two-phase dedup, audit) plus the shared engine.ChatSession. No new query, no schema change.

slackSessionRouting is the per-message isolation rule (Elon round-2 / Niko round-3): a DM is one session per channel; a channel/group message is isolated by thread root (key = channel:threadRoot, root = inbound thread_ts or the message ts for a top-level @mention), so two @bot threads in one channel are two sessions. The real channel id rides in BindingConfig for outbound; the reply thread is returned separately. Tests cover DM/channel/thread routing, config, and that distinct thread roots isolate while a same-thread follow-up reuses its key.

Not yet wired into router.go (still a preparatory commit, per review); Feishu migration onto the shared service, router/config wiring, and the Slack outbound path follow.

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

* feat(slack): Markdown->mrkdwn outbound formatting (MUL-3516)

Slack renders mrkdwn, not Markdown, so an unconverted agent reply shows literal ** , ## and [text](url). Add formatMrkdwn — a faithful Go port of Hermes Agent's slack format_message (MIT) — and apply it in slackChannel.Send before chunking/posting. Protects fenced+inline code, converted links, and existing Slack entities behind placeholders; converts headers/bold/italic/strike/links; escapes control chars. Unit tests cover each construct plus fenced-code protection and a link nested in bold.

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

* docs(slack): preserve Hermes MIT notice for ported mrkdwn converter (MUL-3516)

Addresses Niko's review. formatMrkdwn is a substantial port of Hermes Agent's slack format_message; MIT requires preserving the copyright + permission notice. Add the full Hermes MIT copyright/permission notice + source URL as a header on mrkdwn.go (no repo-level third-party notice file exists, and the header cannot get separated from the ported code). Also add the suggested Send-layer regression test (TestSend_AppliesMrkdwn) that pins the wiring: slackChannel.Send converts Markdown to mrkdwn before posting.

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

* refactor(lark): migrate Feishu onto shared engine.ChatSession, drop duplicate (MUL-3516)

Completes 'every IM reuses one shared session service' and removes the dual-path the reviewers flagged as temporary. Feishu's ResolverSet now drives the channel-agnostic engine.ChatSession (channel_type=feishu, Lark session titles preserved) instead of the Feishu-specific lark.chatSessionService, which is deleted. Behavior is unchanged: engine.ChatSession is the verbatim port of the old logic and is unit-tested; the new Feishu binder param-mapping (BindingKey=chat id, CommandText=un-enriched CommandBody from Raw) is covered by feishu_resolvers_test.go.

- Delete chat_service.go (chatSessionService + helpers) and issue_command.go/_test.go (parser now engine.ParseIssueCommand). Relocate the shared TxStarter interface to tx.go (still used by binding-token + registration services).
- chat.go keeps only the AuditLogger seam; remove the now-dead ChatSessionService / EnsureChatSessionParams / AppendUserMessageParams / AppendResult / IssueCommand types.
- router.go constructs engine.NewChatSession for Feishu; inbound_enricher_test + doc.go updated.

make-test parity: go build ./..., go vet, gofmt, and go test ./internal/integrations/{lark,channel/...,slack} all pass (full Feishu suite green).

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

* feat(slack): wire Slack adapter + ResolverSet + outbound into router (MUL-3516)

Activates the full Slack pipeline, gated by MULTICA_SLACK_SECRET_KEY (the bot/app-token decryption key). When unset the block is skipped, so existing deployments are unaffected and Feishu is untouched.

- router.go registers slack.RegisterSlack (Socket Mode connect/send Factory) + channelRouter.Register(TypeSlack, NewSlackResolverSet) (inbound pipeline) + slack.NewOutbound(...).Register(bus) (outbound).
- New slack/outbound.go: an EventChatDone subscriber mirroring the Feishu Patcher. It finds the Slack chat binding for the finished session, recovers the real channel from the binding config (the channel_chat_id may be a composite thread-isolation key) + the reply thread from last_thread_id, and posts via slackChannel.Send (reusing formatMrkdwn / chunking / threading). Sessions with no Slack binding are ignored, so it coexists with the Feishu Patcher on the shared bus.
- Tests: posts to the bound channel/thread with the real channel id; ignores non-Slack sessions, empty completions, revoked installations, and non-chat events.

Slack now shares engine.ChatSession, channel_* tables, IssueService and TaskService with Feishu. Remaining: config-driven installation provisioning (an operator currently creates the channel_type='slack' row; the config block shape — which workspace/agent — is a product decision) and a live end-to-end smoke. go build ./..., go vet, gofmt, and go test ./internal/integrations/{slack,channel/...,lark} all pass.

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-25 14:29:00 +08:00
Multica Eve
57d1a0a00f fix(quick-create): track concurrent uploads with in-flight counter (MUL-3339) (#4562)
`useFileUpload` exposed a single `uploading: boolean` shared across all
concurrent upload calls. When the user drag-dropped N images into the
Quick Create modal, the first upload's `finally` flipped the flag back
to false while N-1 uploads were still in flight. The submit gate (which
only checked `uploading`) re-enabled, and on submit:

  - `getMarkdown()` ran `stripBlobUrls()` and erased every still-pending
    `![alt](blob:...)` placeholder from the prompt.
  - `pendingAttachments` only contained the first-completed upload, so
    `activeAttachmentIds` shipped a single ID.
  - The remaining attachment rows never got linked to the new issue —
    their `issue_id` stayed NULL and the UI showed "Attachment doesn't
    exist".

Fix:

1. Replace the boolean with an in-flight counter in `useFileUpload`.
   `uploading = inFlight > 0` so the flag stays true until ALL concurrent
   uploads resolve (or reject — the `finally` decrements either way).
   The public return shape is unchanged; every existing call site keeps
   working.

2. Belt-and-suspenders: add `editorRef.current?.hasActiveUploads()` to
   the quick-create submit gate. The editor already tracks per-node
   `uploading` attrs (the source of truth for "is there a blob preview
   still resolving"); checking it on submit guarantees the strip step
   can never run against an in-flight image even if some future code
   path mis-clears `uploading`.

3. Regression coverage in `use-file-upload.test.ts`:
   - Fire two concurrent uploads, resolve them out of order, assert
     `uploading` stays true until BOTH resolve. Confirmed to fail
     against the pre-fix code.
   - Reject one of the concurrent uploads, assert the counter still
     decrements correctly (the `finally` runs on rejection).

The mock ContentEditor in `quick-create-issue.test.tsx` gains a
`hasActiveUploads: () => false` stub so the new defense call doesn't
explode in existing tests.

Verified: `pnpm test` on @multica/core (663 tests) and @multica/views
(1471 tests) both green; typecheck + lint clean on both packages.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 14:11:59 +08:00
Bohan Jiang
65ce228e10 ci(frontend): path-filter the frontend job to skip irrelevant PRs (MUL-3667) (#4556)
* ci(frontend): path-filter the frontend job to skip irrelevant PRs (MUL-3667)

The frontend job (~6min) is the CI bottleneck and runs in full on every
PR, including pure backend-only / docs-only ones that change no frontend
code.

Gate it on a paths filter: a 'changes' job (dorny/paths-filter) decides
whether anything the frontend job validates changed; the frontend job
always runs but its steps are individually gated, so on an irrelevant PR
all steps skip and the job reports a genuine green — the required
'frontend' check stays satisfied with no branch-protection change, and no
top-level 'paths' that would also gate the shared backend/installer jobs.
Push to main always runs the full job.

Also fix a stale comment: mobile-verify filters packages/core/**, not
packages/core/types/**.

An earlier revision of this PR also cached apps/web/.next/cache. Two
back-to-back CI runs (cold vs warm) showed it cut the web build compile
4.3min -> 2.0min but did NOT move the job wall (6m13s -> 6m14s): the
floor is a cluster of typecheck/test tasks (web:typecheck ~2m13s,
views:test, desktop:typecheck) co-critical with web:build and bound by
the 4-vCPU runner, not the web build alone. Dropped the cache since it is
a no-op on its own; the real wall-clock levers (turbo remote cache /
larger runner) are tracked separately.

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

* ci(frontend): include .npmrc in the frontend path filter (MUL-3667)

Address review: root .npmrc (shamefully-hoist=true) affects the pnpm
install layout, so a .npmrc-only PR must still run the frontend job. It
was missing from the filter's install-graph group, which would have made
such a PR a silent skip — exactly what the filter must avoid.

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-25 13:38:34 +08:00
YYClaw
f4dba5d6b0 fix(cli): setup self-host respects existing config and shows URL changes (#4537)
Honor an already-configured self-host server_url/app_url when re-running `multica setup self-host` without flags, instead of silently resetting to http://localhost:8080. The existing config is added as a fallback step in the URL resolution chain (flag → env → existing config → localhost), and the overwrite confirmation now shows URL changes as `old -> new`.

Closes #4536

MUL-3660
2026-06-25 13:13:46 +08:00
Multica Eve
b71d9d0ab9 MUL-3674: Preserve Kiro goal completion on close error (#4560)
* fix(daemon): preserve Kiro goal completion on close error

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

* fix(daemon): require completed Kiro goal marker

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-25 12:43:04 +08:00
Ryan Yu
d9bf4b85c9 test(cli): cover additional subcommands (#4555) 2026-06-25 11:25:00 +08:00
Bohan Jiang
0d3b49f2c7 fix(webhook): use unique ZSET member in Redis rate limiter (#4546)
The sliding-window Lua script used the nanosecond timestamp as both the
ZSET score and member. Two requests landing in the same nanosecond
collided on an identical member, so ZADD updated in place instead of
inserting and the window under-counted — letting requests through past
the limit. This surfaced as a flaky CI failure in
TestRedisWebhookIPRateLimiter_HasSeparateBudgetFromTokenLimiter.

Keep the timestamp as the score (so ZREMRANGEBYSCORE trimming is
unchanged) and use a per-request UUID as the member so each admitted
request is counted exactly once.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 01:29:20 +08:00
Bohan Jiang
a03055b07d fix(agent): terminate opencode process group before closing stdout (#4533) (#4541)
On cancellation/timeout the opencode backend closed the stdout read end
immediately, leaving the child writing into a closed pipe. Every write then
returns EPIPE and, per anomalyco/opencode#33653, can spin an orphaned process
at 100% CPU — surfacing as high idle CPU after a cancelled task or daemon
restart (MUL-3655).

Cleanup now runs opencode in its own process group and, on cancel, drives a
graceful group-wide SIGTERM → grace → SIGKILL, closing the stdout pipe only as
a last-resort unblock once the tree has been signalled (SIGKILL is uncatchable,
so no member can write again — no EPIPE window). The group signal also reaps
tool subprocesses opencode spawned instead of orphaning them. WaitDelay remains
the hard backstop.

Adds unix tests covering the graceful path and the SIGTERM-ignored → SIGKILL
escalation, asserting the whole process group is reaped and the run never
deadlocks on the scanner. Windows behaviour is unchanged (no process groups).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 00:41:25 +08:00
Ryan Yu
bea028784a fix(labels): reject control characters in label names (#4531) 2026-06-25 00:19:13 +08:00
Jiayuan Zhang
343ace89a7 feat(editor): MUL-3557 add one-click task-list toggle to bubble menu (#4538)
Add a dedicated checkbox/task-list toggle button to the editor bubble menu, between the List dropdown and Quote. It turns the current line(s) into a `- [ ]` task item or back to a paragraph in one tap, reusing the existing toggleTaskList() command and the same Toggle/Tooltip pattern as the neighboring controls. Adds bubble_menu.task_list locale keys for en/zh-Hans/ja/ko.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 17:04:05 +02:00
Bohan Jiang
dfa384ffa2 fix(daemon): resolve skill bundles per-skill with size-scaled timeout (#4505) (#4530)
* fix(daemon): resolve skill bundles per-skill with size-scaled timeout (MUL-3650, #4505)

Cold-start skill resolution downloaded the agent's entire bundle in one
atomic request bounded by the shared 30s control-plane http.Client timeout.
On a slow/jittery link a large bundle (15+ skills) could not finish the body
read in 30s, and because the cache was only written after the whole batch
succeeded, nothing was persisted on failure — so every dispatch re-downloaded
the full bundle and timed out again, never converging.

Resolve each missing bundle in its own request and cache it the moment it
arrives:

- daemon: per-skill resolve with a deadline scaled to the bundle's declared
  size (floor 30s, cap 5m, ~50KB/s floor throughput) instead of the fixed
  control-plane timeout; each success is persisted independently, so a
  dispatch that fails on one skill still caches the rest and the next dispatch
  only re-fetches what is missing.
- client: dedicated bundleClient with no fixed Timeout (deadline comes from
  ctx), a singular ResolveSkillBundle, and a short transient-retry schedule.

Tests cover the size-scaled timeout and the cross-dispatch incremental
caching / convergence (a failed skill does not discard its siblings, and
cached skills are not re-fetched).

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

* fix(daemon): accept server-side skill updates in per-skill resolve (MUL-3650)

Address review on #4530: resolveSkillBundle validated the returned bundle
against the claim-time ref, which pinned it to the requested hash. The resolve
endpoint intentionally serves the agent's current bundle and hash when the
requested hash is stale (the skill can be edited between claim and prepare), so
a legitimate updated bundle was rejected as invalid and the task failed.

Confirm only that the server returned the requested skill (source/id), then
validate self-consistency against a ref derived from the returned bundle and
cache it under its own hash — matching the documented endpoint contract. Adds a
regression test covering a stale-hash request answered with an updated bundle.

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-24 19:00:13 +08:00
Bohan Jiang
5e824a94ae chore(channel): remove the one-time MULTICA_LARK_HUB_DISABLED cutover switch (#4527)
The lark_*->channel_* cutover (MUL-3515) is deployed to prod, and the
MULTICA_LARK_HUB_DISABLED park-switch was a one-time scaffold for that
rollout — the end state intentionally does not use it (prod never set the
env). Remove the env-gated branch from cmd/server/main.go so the channel
supervisor always starts when built; its existing nil-guard and shutdown
join are unchanged. Trim migration 124's now-obsolete switch runbook to a
short historical note (comment-only; 124 is already applied, so this does
not re-run).

Refs MUL-3515

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 18:18:06 +08:00
apollion69
a6cf4cb46a docs: document MULTICA_<PROVIDER>_ARGS default agent argument env vars (#4434)
* docs: document MULTICA_<PROVIDER>_ARGS default agent argument env vars

The backend default-args env-var layer (MULTICA_CLAUDE_ARGS / MULTICA_CODEX_ARGS /
MULTICA_CODEBUDDY_ARGS) shipped in #1807 but was never added to the docs site
environment-variables page. Document the variable, its precedence relative to
per-agent custom_args, POSIX shell-word parsing, and the shared blocked-flags
filter. Closes the docs follow-up requested in #1467.

* docs: refine MULTICA_<PROVIDER>_ARGS wording and sync zh/ja/ko translations

Reword the English section so daemon-wide default args read as a default
baseline rather than a hard ceiling (per-agent custom_args are appended
afterward and can override), and drop the uncertain --max-budget-usd example.
Sync the new env var row and section into the zh/ja/ko docs pages.

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-24 18:12:33 +08:00
J
34bd115808 test(execenv): fix stale test name reference in comment (#3028)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 18:06:24 +08:00
jockibeard
3adfaf4285 fix(execenv): support OpenClaw 2026.6.x agents schema (#3028) (#4319)
Adapts OpenClaw execenv prep to the 2026.6.x agents schema (agents.list config path removed; agents live in a sqlite registry). Case-insensitive key-missing guard + registry fallback on read, version-aware emission on write so per-task workspace pinning keeps working.

Closes #3028

MUL-3643
2026-06-24 18:05:38 +08:00
Multica Eve
a66f7ce8b1 MUL-3640: add 2026-06-24 changelog entry (#4518)
* docs: add 2026-06-24 changelog entry

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

* docs(changelog): refine 2026-06-24 entry wording and terms

- Surface the flagship features in the title (Feishu collaboration channel
  upgrade + feature rollout) instead of leading with an improvement and a
  vague "runtime rollout" phrase
- Fix glossary term: Autopilot -> 自动化 (was 自动任务) in zh
- Make Feishu naming consistent within the entry (was mixing 飞书/Lark)
- Reconcile cross-language mismatch (Gemini CLI removal + Qoder/CodeBuddy/
  Antigravity guidance now stated the same in all locales)
- Replace internal/jargon phrasing with product language across en/zh/ja/ko

MUL-3640

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-06-24 17:44:26 +08:00
Bohan Jiang
c3d8529ba5 fix(channel): eliminate WaitGroup Add/Wait data race in engine.Supervisor (MUL-3620) (#4517)
The race detector caught a flaky failure on main (passes on retry):
Supervisor.startSupervisor does s.wg.Add(1) under s.mu, while Supervisor.Wait
calls s.wg.Wait() with no lock. Calling WaitGroup.Add concurrently with
WaitGroup.Wait is a data race and undefined per the WaitGroup contract — so it
only trips occasionally (it passed locally and in PR CI).

Wait now blocks on stopChan (closed by Run's defer when Run returns) before
calling wg.Wait(). Run is the sole caller of startSupervisor, so once Run has
returned no further Add can happen and wg.Wait is race-free. WaitWithTimeout
inherits the fix (it calls Wait), and its timer still bounds shutdown.

This latent race existed in the original lark.Hub.Wait too; fixed properly in
the generalized Supervisor.

Verified: go test -race -count=300 on the flagged test and -count=8 on the
whole engine package, all clean; no deadlock from the stopChan gate (every
caller pairs Wait with a started Run + cancelled ctx).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 17:21:03 +08:00
Bohan Jiang
9db80a0940 fix(daemon): forbid mid-run progress comments in runtime brief (#4516)
A run could post running progress/plan narration as issue comments, and a
review run surfaced its in-progress narration as the result instead of a
conclusion (MUL-3605).

Add one rule to the Output section's issue-task branch, in both the
legacy and slim briefs: post exactly one comment per run — the final
result, before the turn exits — and keep plans/progress in the agent's
own reasoning. The pre-existing "Final results MUST be delivered … a task
that finishes without a result comment is invisible" line already makes
the comment mandatory, and "state the outcome, not the process" already
rules out progress dumps, so no second rule is added.

Chat / quick-create / autopilot keep their own delivery channels. Adds a
regression test across both brief paths.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 17:20:19 +08:00
Bohan Jiang
3e21e58df0 feat(channel): channel-agnostic engine (Supervisor + Router); Feishu as channel.Channel (MUL-3620) (#4512)
* feat(channel): add channel-agnostic engine Supervisor (MUL-3620)

Stage-1 (MUL-3515) shipped the channel abstraction but nothing drove it.
Add the generic engine that does:

- channel.InboundHandler + Config.Handler: the single shared inbound entry
  the engine injects into every adapter (Hermes set_message_handler model).
- channel.Channel.Connect now blocks for the connection lifetime (doc), so
  the supervisor can tie lease renewal to connection liveness.
- new package channel/engine: Supervisor, generalized out of lark.Hub. It
  enumerates active installations across ALL channel types (no hard-coded
  feishu), fences each behind the WS lease CAS, builds the platform Channel
  via channel.Registry, drives Connect/Disconnect with backoff+jitter, and
  restarts on credential rotation. Knows nothing about any platform.

channel.Channel is now driven by an engine; integrations/channel has an
external consumer. Feishu adapter + boot cutover follow next.

Tests: supervisor_test.go covers lease CAS, reclaim, reap-on-revoke,
rotation restart + token fencing, backoff on build error, lease-loss
teardown, bounded release, shutdown timeout. Race-clean.

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

* feat(lark): drive Feishu through the channel engine; remove lark.Hub (MUL-3620)

Refactor Feishu into the first channel.Channel and cut boot over to the
channel-agnostic engine.Supervisor, removing the Feishu-only Hub.

- feishuChannel implements channel.Channel: Connect runs the existing
  WS long-conn connector for one installation; Send posts a text reply
  via the Lark IM API; Capabilities declares Feishu's feature set.
  RegisterFeishu wires it to channel.TypeFeishu — adding a platform is
  now 'register a Factory', no engine edit.
- FeishuRuntime extracts the former Hub.handleEvent / scheduleReply:
  runs the Dispatcher and drives the detached typing indicator +
  OutcomeReplier off the connector ACK path. main.go drains it on
  shutdown after the supervisor stops delivering events.
- channelInstallationStore (engine.InstallationStore) enumerates active
  installations across ALL channel types via the new de-hardcoded query
  ListAllActiveChannelInstallations; the Supervisor routes each row to
  its registered Factory by channel_type. Generic per-row fingerprint
  replaces the feishu-specific one.
- boot: engine.Supervisor replaces lark.Hub.Run; MULTICA_LARK_HUB_DISABLED
  keeps its name for runbook compatibility.
- delete hub.go / hub_pgx.go / hub_test.go; relocate the connector
  contract (EventConnector/EventEmitter), uuidString, and the reply-path
  tests (-> feishu_runtime_test.go) so coverage is preserved.

No channel_* schema change. Feishu behaviour unchanged; lark + channel +
engine tests green under -race; go build/vet ./... clean.

Remaining (follow-up): lift the Dispatcher pipeline into a channel-
agnostic engine.Router over channel.InboundMessage + resolver interfaces,
so the inbound core stops being Lark-shaped and adding a channel needs
zero core edits (validated by Slack, MUL-3516).

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

* feat(channel): add channel-agnostic engine.Router (inbound pipeline) (MUL-3620)

Generalize lark.Dispatcher's inbound pipeline into engine.Router: the single
shared channel.InboundHandler the Supervisor injects into every Channel. It
routes by ChannelType to a registered ResolverSet and runs the same ordered
pipeline for every platform (install route -> two-phase dedup -> group @bot
filter -> identity+membership -> ensure session -> append+mark -> /issue ->
debounced run), then drives the detached OutboundReplier + typing indicator.

Platform specifics live behind resolver interfaces (InstallationResolver,
IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier,
TypingNotifier) + shared services (IssueCreator/TaskEnqueuer/SessionReader).
Adding a platform is 'register a ResolverSet', not 'edit the Router'. Outcome
/ DropReason values match the legacy lark ones 1:1.

Additive: lark.Dispatcher untouched and still wired; the feishu ResolverSet,
the cutover, and the old-path removal land next. channel.InboundMessage gains
ForceFresh (the normalized /fresh affordance). Batcher moved into engine.

router_test.go covers the pipeline invariants (routing, dedup finalize
states, group filter, identity, membership, ensure/append, /issue, debounce,
flush offline, force-fresh, drain) with generic fakes; race-clean.

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

* feat(lark): cut Feishu over to engine.Router; remove lark.Dispatcher; core no longer Lark-shaped (MUL-3620)

Wire the channel-agnostic engine.Router (added in the prior commit) as the
shared inbound handler and refactor Feishu into a ResolverSet, completing the
generic-engine cutover. The inbound core (engine.Router) now contains zero
platform specifics.

- Feishu ResolverSet (feishu_resolvers.go): InstallationResolver,
  IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier,
  TypingNotifier — each backed by the existing ChannelStore / ChatSessionService
  / OutcomeReplier / typing indicator, translating at the channel.InboundMessage
  boundary (platform fields read from Raw). origin_type stays 'lark_chat'.
- feishuChannel now produces a normalized channel.InboundMessage and hands it to
  the engine handler via channel.Config.Handler; the old Raw round-trip through
  lark.Dispatcher is gone.
- Remove lark.Dispatcher, FeishuRuntime, and lark's pending_batcher (the engine
  owns the pipeline + batcher now); their behavioural coverage moved to
  engine.Router tests. Surviving native types (InboundMessage / Outcome /
  DispatchResult) relocated to feishu_types.go.

elon review nits addressed:
- The channel engine (Registry + Router + Supervisor) is now built
  UNCONDITIONALLY, outside the MULTICA_LARK_SECRET_KEY gate, so a non-Lark
  deployment runs it; Feishu registers its Factory + ResolverSet only when its
  key is present.
- channel.Config.Raw is now genuinely the platform config JSONB
  (channel_installation.config): the feishu factory builds a credentials-only
  Installation from it, and the workspace/agent identity is resolved per message
  by the Router — no full-db-row marshaling.
- feishuChannel gains direct unit tests: factory config decode, Send text +
  reply-target mapping, Capabilities, inbound normalization + Raw round-trip,
  msg-type + result mapping.

No channel_* schema change. go build/vet ./... clean; channel + engine + lark
green under -race. Feishu behaviour preserved (pipeline logic lifted verbatim,
only generalized).

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

* docs(channel): fix stale comments on the channel engine boot (MUL-3620)

Address Elon's review nit: three comments still described the pre-cutover
behavior.

- handler.go: ChannelSupervisor is built UNCONDITIONALLY now, not nil when
  MULTICA_LARK_SECRET_KEY is unset.
- main.go: same — the supervisor always exists; only MULTICA_LARK_HUB_DISABLED
  parks it.
- router.go: with no platform registered the store still lists active rows;
  Registry.Build returns ErrUnknownType and the supervisor backs off (it does
  not 'find no installations').

Comment-only; no behavior change.

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-24 17:01:33 +08:00
YzKing
4d7111d396 MUL-3605: Fix serialization of agent task claims by capacity
* fix(server): serialize agent task claims by capacity

* test(server): clean up claim race fixture

---------

Co-authored-by: Yanziqing25 <1519319045@qq.com>
2026-06-24 17:00:03 +08:00
beast
20eecfb093 fix(projects): honor repo resource checkout refs (MUL-3593) (#4470) 2026-06-24 16:25:17 +08:00
Multica Eve
1ac3a03e5d MUL-3618: dispatch daemon feature flag snapshots (#4509)
* MUL-3618: dispatch daemon feature flag snapshots

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

* MUL-3618: narrow daemon flag snapshots to process scope

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-24 16:19:30 +08:00
Bohan Jiang
79c9158097 fix(issue): order sub-issues by number ASC instead of position (#4511)
ListChildIssues and ListChildrenByParents ordered by
`position ASC, created_at DESC`. position is assigned by
NextTopPosition as MIN(position)-1 scoped to (workspace, status),
not relative to siblings, so a parent's children interleave
unpredictably across creation batches and statuses.

Order by `number` (a per-workspace monotonic counter) instead.
ASC keeps sub-issues in stable creation order (oldest first), so a
parent's plan reads top-to-bottom in the order tasks were added.

Adds ordering tests covering both queries with scrambled positions
and mixed statuses.

Closes #4232
MUL-3362

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 16:06:14 +08:00
Bohan Jiang
cb7cc82ecb fix: allow same-origin attachment previews (#4504)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 15:37:38 +08:00
Bohan Jiang
76c58a4ee8 MUL-3617: remove Gemini CLI runtime (#4503)
* fix: remove gemini cli runtime

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

* fix: skip unsupported custom runtime profiles

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-24 15:15:42 +08:00
Willow Lopez
af34b8f83a feat(lark): add proxy support for WebSocket connections (#4166)
Add ProxyURL field to GorillaDialer so deployments behind a corporate
proxy can route Lark WebSocket connections through an HTTP CONNECT proxy.

- GorillaDialer.ProxyURL: optional proxy URL parsed and applied to the
  underlying gorilla/websocket dialer before each DialContext call.
  Empty value preserves the default ProxyFromEnvironment behaviour.
- Router reads MULTICA_LARK_WS_PROXY_URL env var and sets it on the
  production dialer.
- Three new unit tests cover invalid URL, proxy-applied, and empty-URL
  default paths.

Closes #4032

Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 15:12:53 +08:00
Bohan Jiang
189f95fabb docs: tidy agent runtime provider pages, add per-runtime FAQ (MUL-3617) (#4500)
* docs: tidy agent runtime provider pages, add per-runtime FAQ (MUL-3617)

- Remove the Gemini CLI provider from install-agent-runtime and providers
  across all four languages (Google folded the standalone CLI into
  Antigravity). Update tool counts 12 -> 11 and the dependent
  session-resumption, MCP, and skill-path sections.
- Add the Hermes profile custom_args workaround as a per-runtime FAQ note
  under providers#hermes (supersedes #4497, which placed it in agents-create).
- Fix stale Japanese install copy that claimed only Claude Code reads
  mcp_config and linked to a non-existent anchor.

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

* docs: add Qoder and CodeBuddy runtimes to provider pages (MUL-3617)

Document the two newly added runtimes on install-agent-runtime and
providers across all four languages:

- Qoder (Alibaba): ACP-over-stdio CLI `qodercli`, shares the transport
  with Hermes/Kimi/Kiro; session/resume, ACP mcpServers, dynamic model
  discovery, native skills at .qoder/skills/.
- CodeBuddy (Tencent): Claude Code-compatible CLI `codebuddy`, driven via
  stream-json; --resume, --mcp-config, dynamic models, .claude/skills/.

Update tool counts 11 -> 13 and the MCP section (now ten of thirteen
consume mcp_config; the other three still ignore it).

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-24 14:35:26 +08:00
Multica Eve
8ad673fdb7 MUL-3560: gate slim runtime brief behind runtime_brief_slim feature flag (#4449)
The MUL-3560 slim runtime brief — kind-driven dispatcher, per-section
gating, prose compression for ~7k chars saved on the typical
comment-triggered task — now ships behind the `runtime_brief_slim`
feature flag wired via the framework-level service from MUL-3615.

Default: OFF in every environment (production stays on the legacy
brief that has shipped for ~2 years). Staging opts in via the YAML
rule set; ops can override per-process with `FF_RUNTIME_BRIEF_SLIM=true`.
Production is held back until staging has burned in long enough that
we are confident the slim brief does not regress agent behaviour.

Architecture (one toggle point, two code paths, both fully tested):

  buildMetaSkillContent (runtime_config.go)
      │
      └─ useSlimBrief() → false (default)
      │     → fall through to the legacy verbose body that ships on
      │       main today — byte-for-byte unchanged, no migration risk
      │
      └─ useSlimBrief() → true
            → buildMetaSkillContentSlim (runtime_config_sections.go)
              → classifyTask → 5-way kind switch → per-section writers

BuildCommentReplyInstructions takes the same gate, so the per-turn
comment prompt and the runtime brief stay in sync on which template
they emit.

What's in this PR:

- runtime_config_flag.go (new): package-scope `runtimeFlags` atomic
  pointer + `SetFeatureFlags` setter + `useSlimBrief` toggle point.
  Nil-safe: a daemon that forgets to wire the service falls back to
  legacy, no panic.
- runtime_config_kind.go (new): `taskKind` enum + `classifyTask` +
  `hasIssueContext` predicate. Used only by the slim path.
- runtime_config_sections.go (new): the slim brief itself —
  `buildMetaSkillContentSlim` + per-section `writeXxx` helpers
  + `writeAvailableCommandsQuickCreate` minimal variant +
  `writeBackgroundTaskSafetySlim` compressed safety section. The
  Section × Kind matrix is documented inline on
  `buildMetaSkillContentSlim` and the test below checks the
  dispatcher does not diverge from the spec.
- reply_instructions.go: `BuildCommentReplyInstructions` gains a
  short slim-or-legacy prelude; new `buildCommentReplyInstructionsSlim`
  is the compressed cookbook (defers the shell-hazard rationale to
  `## Comment Formatting`).
- runtime_config.go: `buildMetaSkillContent` gains a 2-line
  dispatcher at the top; the legacy body is otherwise untouched.
- runtime_config_kind_test.go (new): canaries for both paths.
  - TestClassifyTask: 5 kinds + 3 tiebreak cases.
  - TestTaskKindHasIssueContext: predicate semantics.
  - TestSlimFlagOffUsesLegacy: nil flag service → legacy path
    (renders "Get full issue details.", a legacy-only substring).
  - TestSlimFlagOnUsesSlim: flag on → slim path (renders "full
    issue.", a slim-only one-liner) AND must NOT render legacy
    "Get full issue details.".
  - TestBuildMetaSkillContentSlimKindMatrix: locks the per-kind
    section set; heading match is line-anchored so inline references
    don't trip absence assertions.
  - TestSlimQuickCreateAvailableCommands: locks the minimal-variant
    content for quick-create (issue create present, every other
    Core command absent).
  - TestSlimBriefIsSubstantiallyShorter: ≥ 30% reduction guard so
    a future change can't accidentally re-bloat the slim path back
    to legacy levels.
- cmd/server/main.go: now calls `execenv.SetFeatureFlags(flags)`
  immediately after constructing the feature flag service.

Measured impact (slim vs legacy, claude provider, realistic fixture
with 2 repos + 2 skills + member initiator):

    legacy = 19567 chars
    slim   = 11868 chars    Δ = -7699  (-39.3%)

Verification:

- go vet ./internal/daemon/... ./cmd/server/...                  ok
- go test ./internal/daemon/...                                  ok
- go test ./pkg/featureflag/...                                  ok
- TestSlimBriefIsSubstantiallyShorter logs the 39.3% ratio
- TestSlimFlagOffUsesLegacy + TestSlimFlagOnUsesSlim pass both
  directions, so the dispatcher is locked in code.

The pre-existing `internal/handler` test failures
(TestLeaveWorkspace_RevokesOwnRuntimes,
TestDeleteMember_CancelsTasksFromAgentReassignment,
TestDeleteMember_NoRuntimes_DeletesMember) reproduce on plain
`origin/main` with the same `relation "channel_user_binding" does
not exist` SQL error — they are a missing-migration bug from the
recent channels foundation PR (ce28d0aa0), not anything this PR
touched.

Rollout plan:

1. Merge this PR. Production daemons keep emitting the legacy brief
   (flag default false).
2. Add a YAML rule to staging's
   `MULTICA_FEATURE_FLAGS_FILE`:

       runtime_brief_slim:
         default: true

   Staging daemons start emitting the slim brief on next restart.
3. Watch `agent prompt prepared` logs + agent behaviour for 7 days.
4. If staging is clean, flip the prod YAML to `default: true`.
   Legacy code path stays in the binary as a kill-switch
   (`FF_RUNTIME_BRIEF_SLIM=false` to revert without a deploy).
5. After ~30 days clean in prod, follow up with a PR that deletes
   the legacy body and the flag — same pattern as docs/feature-flags.md
   recommends ("plan the death of the flag at birth").

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 14:23:17 +08:00
Wilson-G
b92e4a53fb DH-106 为飞书接入补上 /new 会话指令 (MUL-3503) (#4396)
Lark/飞书入站消息新增 /new 首行指令,解析为 force_fresh_session,复用既有 daemon 会话续接门控。

Co-authored-by: Wilson-G <Wilson-G@users.noreply.github.com>
2026-06-24 14:16:22 +08:00
Multica Eve
4a8210912a feat(featureflag): framework-level feature flag system (MUL-3615) (#4496)
* feat(featureflag): framework-level feature flag system (MUL-3615)

Introduces a reusable feature flag framework so future features can adopt
flags without writing infrastructure code.

Backend: server/pkg/featureflag (Go)
- Service / Provider / Decision separation per Martin Fowler's Toggle
  Point / Toggle Router / Toggle Configuration pattern.
- Providers: StaticProvider (rules in source control), EnvProvider
  (FF_<KEY> overrides for ops kill switches), ChainProvider
  (first-hit-wins composition).
- EvalContext carried through context.Context with WithEvalContext /
  EvalContextFrom; supports user_id, workspace_id, free-form attributes.
- PercentRollout via deterministic FNV-1a bucketing; same user always
  lands in the same bucket so experiments do not flap between requests.
- Nil-safe Service: a nil *Service or missing flag returns the caller's
  default so business code never panics on a missing flag.
- 100% unit-test coverage with -race; go vet clean.

Frontend: packages/core/feature-flags (TypeScript)
- Same vocabulary as the Go side (Decision, EvalContext, Rule,
  PercentRollout). FNV-1a parity ensures cross-tier bucket agreement.
- FeatureFlagService + StaticProvider + ChainProvider in pure TS.
- React glue: FeatureFlagsProvider, useFlag(key, default),
  useVariant(key, default). Hooks fall back to the default when no
  provider is mounted so Storybook / unit tests stay simple.
- Vitest tests for service, providers, hash, and React hooks.

Docs: docs/feature-flags.md — wiring, EvalContext, toggle points,
backend-protection note, and the standard best-practice checklist.

The framework intentionally has no third-party Go deps and no API
surface beyond what real callers will need. New providers (DB, remote
config, LaunchDarkly) plug in by implementing Provider; no existing
caller has to change.

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

* fix(featureflag): cross-tier hash parity + variant only when enabled (MUL-3615)

Two must-fix issues from the PR review on #4496:

1. TS hash had a trailing zero separator that Go did not emit, so the
   same (key, identifier) bucketed differently on the two tiers. The
   "user lands in the same bucket on server and client" promise was
   broken. For example billing_new_invoice/user-42 was bucket 97 in Go
   and bucket 11 in TS.

   Fix: TS fnv1a now emits the zero separator BETWEEN parts only, never
   after the last one, matching Go's hash.Write byte stream exactly.
   Verified by parallel golden tests on both sides that pin five
   (key, identifier) -> bucket triples; if either side drifts both tests
   fail and one must be brought back in sync.

2. StaticProvider returned `Rule.Variant` regardless of whether the rule
   evaluated to enabled=true. A 0%-rollout user, a deny-listed user, or
   a default-off user would see variant="experiment-v2", so callers
   branching on Variant() would route control users into the experiment
   arm.

   Fix: Rule.Variant is now the ON-variant only. When the rule evaluates
   to enabled=false the Decision's variant is the canonical "off",
   regardless of what Rule.Variant says. Documented as a behavior
   contract in the Rule godoc / JSDoc and covered by regression tests
   on both sides.

Tests: - go test -race ./pkg/featureflag/...  : all green (1.58s).
  - pnpm --filter @multica/core test     : 661/661 (3 new).
  - pnpm --filter @multica/core typecheck: clean.
Co-authored-by: multica-agent <github@multica.ai>

* fix(featureflag): hash UTF-8 bytes on the TS side for cross-tier parity (MUL-3615)

Follow-up review on PR #4496 caught that the previous hash fix was only
correct for ASCII input. The TS side used `charCodeAt`, which returns
UTF-16 code units, while the Go side hashes the UTF-8 byte
representation. Any non-ASCII flag key or identifier — Chinese flag
names, accented user IDs, emoji — would bucket differently on backend
vs frontend, silently breaking the "same user, same bucket" promise the
PR description makes.

Concretely:
  flag/é         Go 53  vs TS-old 68
  flag/🦄        Go 82  vs TS-old 75
  实验/user-1    Go 90  vs TS-old 4
  flag/用户-1    Go 95  vs TS-old 2

Fix: replace per-char charCodeAt with a module-level `TextEncoder`
('utf-8') and hash each encoded byte. After the fix all four cases above
match Go exactly, and the existing ASCII cases continue to match.

The cross-language golden tables on both sides now include the 5 new
non-ASCII cases alongside the 5 ASCII cases, so any future regression
that swaps UTF-8 for charCodeAt (or vice versa) will fail loudly on
both Go and TS simultaneously.

TextEncoder is part of WHATWG Encoding and is available in every
evergreen browser, in Node 11+, and in Hermes (React Native) >= 0.74,
which covers every runtime that imports @multica/core/feature-flags.

Tests: - go test -race ./pkg/featureflag/...   : all green.
  - pnpm --filter @multica/core test      : 661/661.
  - pnpm --filter @multica/core typecheck : clean.
Co-authored-by: multica-agent <github@multica.ai>

* feat(featureflag): wire into main app config — YAML file + env override (MUL-3615)

Follow-up requested by Yushen on PR #4496: make the feature flag
framework configurable through the existing main-program config system
instead of requiring Go code edits. multica's main app is purely env-var
driven (see .env.example) with optional MULTICA_*_FILE knobs for richer
config; feature flags now follow the same pattern.

server/pkg/featureflag/config.go
  - LoadRulesFromYAMLFile(path) parses a YAML rule set into runtime
    Rule structs. Empty files are a valid "no flags yet" state; missing
    or malformed files surface a hard error so operators see misconfig
    the same way DATABASE_URL parse errors do.
  - NewServiceFromEnv composes the standard provider chain:
      1. EnvProvider("FF_")               (runtime kill-switch path)
      2. StaticProvider from YAML file    (declarative rule set)
    When MULTICA_FEATURE_FLAGS_FILE is unset, only the env layer is
    active and every IsEnabled call falls through to the caller's
    default, so the server can boot before any flag is authored.

server/cmd/server/main.go
  - Construct the Service once at startup right after env-var warnings,
    fail loudly on malformed YAML, log the loaded rule count via the
    Service logger. The Service is held in a local `flags` variable
    ready to be threaded into handler.Handler / service constructors
    when the first flag user lands. Threading is deferred to the PR
    that adds the first business consumer so this PR stays a pure
    framework + config layer.

.env.example
  - New "Feature flags" section documents MULTICA_FEATURE_FLAGS_FILE and
    the FF_<KEY> override convention, with a minimal YAML schema example
    inline.

docs/feature-flags.md
  - Replace the "build a provider manually" example with the
    NewServiceFromEnv pattern that now matches what main.go actually
    does. Show the YAML schema in one place. Note the on-variant /
    off semantics from the previous review round.

server/pkg/featureflag/doc.go
  - Update package doc to mention the gopkg.in/yaml.v3 dependency
    (already a server-level dep) instead of the now-inaccurate
    "no third-party dependencies" claim.

Tests: - go test -race -count=1 ./pkg/featureflag/...   all green; new
    config_test.go covers: simple YAML, full-shape YAML, empty file,
    missing file, malformed YAML, no env var, file-only, env-beats-file,
    bad file surfaces error.
  - go test -race -count=1 -run TestHealth ./cmd/server/...   sanity
    check that the main.go boot path with the new wiring still passes.
  - go vet ./...   clean.
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-24 13:49:59 +08:00
Naiyuan Qing
a7908e6967 fix(issues): sync header agent chip with execution log via shared query (#4498)
The header live chip derived its active-task state from the workspace-wide
agent-task-snapshot, while the right-panel Execution log read the per-issue
task list. Two queries, two endpoints, two independent refetches: the heavier
workspace snapshot lands later than the per-issue list, so the log could show
a running task while the header chip had not started yet.

Point the chip at the same `issueKeys.tasks(issueId)` cache the Execution log
uses (identical query options). Both surfaces now observe one cache entry and
update atomically. Drop the now-redundant workspace-id lookup and client-side
issue_id filter, since the endpoint is already issue-scoped.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:26:50 +08:00
Multica Eve
00b9668cd2 fix(autopilot): cold-start planner honors trigger.last_fired_at (MUL-3551) (#4495)
Post-deploy of the new scheduled-dispatch scheduler (PR #4444), an
autopilot configured for "weekdays 17:10 Asia/Shanghai" fired at
~12:30 Beijing the day after deploy — ~4h 38m before the next
scheduled time the UI showed. Traced to a cold-start regression in
the planner hook:

Old behaviour
-------------
On the first tick after migration the hook found no
`sys_cron_executions` row for the trigger
(`latestPlan(...).Found == false`) and anchored on the trigger's
`created_at`, then applied the 24h replay cap:

  after := cfg.CreatedAt
  if oldest := now.Add(-replayWindow); after.Before(oldest) {
      after = oldest // now - 24h
  }

For a trigger created days/weeks earlier and last fired by the
legacy goroutine at Mon 17:10 Beijing (= Mon 09:10 UTC), this set
`after = Tue 04:13 UTC - 24h ≈ Mon 04:13 UTC`. The half-open
enumeration `(Mon 04:13 UTC, Tue 04:13 UTC]` STILL contained Mon
09:10 UTC — the occurrence the legacy code had already handled —
so the new scheduler dispatched it again the moment it took over.
The result: a SCHEDULED-source autopilot_run with planned_at = Mon
17:10 Beijing but a wall-clock dispatch at Tue ~12:30 Beijing.

Timezone math was correct; the bug was purely the cold-start
anchor not respecting prior-fire history.

Fix

Co-authored-by: multica-agent <github@multica.ai>
---
The `autopilot_trigger.last_fired_at` column is maintained by both
the legacy goroutine and the new scheduler (via
TouchAutopilotTriggerFiredAt), so it is the authoritative
"most-recent successful fire" cursor across the migration boundary.
The planner hook now anchors cold-start enumeration on it:

  case latest.Found:        after = latest.PlanTime
  case lastFiredAt != zero: after = lastFiredAt
  default:                  after = cfg.CreatedAt

For the regressed case, `after = Mon 17:10 Beijing`, the next
enumeration window is `(Mon 17:10, Tue 12:30]`, and Tue 17:10 is
in the future — the hook returns nothing and the trigger waits
quietly for Tue 17:10 as the UI promised. For brand-new triggers
(last_fired_at NULL), the original `created_at` path still
applies. For long-dormant triggers the `replayWindow` cap remains.

Changes
-------
* `ListSchedulableAutopilotTriggers` SQL now returns
  `last_fired_at`.
* `autopilotTriggerConfig.LastFiredAt` is populated by the scope
  provider on every tick.
* `autopilotPlansForScope` cold-start branch uses the new anchor.

Tests
-----
* TestAutopilotScheduleJobColdStartHonorsLastFiredAt — seeds the
  exact dev-environment shape (created 3 days ago, last_fired_at
  5 hours ago, no sys_cron_executions row), runs a tick, asserts
  zero exec rows AND zero autopilot_run rows. Without the fix this
  test produces one of each at a historical plan_time.
* TestAutopilotScheduleJobColdStartBrandNewTriggerStillFires —
  asserts a brand-new trigger (last_fired_at NULL) still fires its
  first due occurrence on cold start.

All existing `TestAutopilotScheduleJob*` tests still pass.

Refs MUL-3551

Co-authored-by: Eve <eve@multica-ai.local>
2026-06-24 13:01:59 +08:00
Bohan Jiang
ce28d0aa0e feat(integrations): add platform-agnostic channel foundation (MUL-3515) (#4412)
* feat(integrations): add platform-agnostic channel foundation

Introduce server/internal/integrations/channel — the contract every
inbound IM integration implements, so the core never learns a platform's
event JSON. Four pieces:

- Channel interface (Type/Connect/Disconnect/Send/Capabilities) + Factory
  + Config (channel_type + opaque JSON blob, maps to channel_installation).
- Normalized InboundMessage/OutboundMessage envelopes + Source/MediaRef/
  ReplyCtx/MsgType/ChatType. Envelope holds only cross-platform-true
  fields; platform specifics live in Raw, read only by the adapter.
- Capability bitmask: declaration only, no degrade logic in core.
- Registry: Type->Factory map, last-writer-wins, concurrency-safe.

Pure package (no DB/network/platform deps). Foundation for MUL-3515; the
lark cutover + lark_*->channel_* generalization land in follow-up PRs.

MUL-3515

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

* feat(channel): generalize lark_* tables into channel_* (DB layer)

Migration 123 creates channel_installation / channel_user_binding /
channel_chat_session_binding / channel_inbound_message_dedup /
channel_inbound_audit / channel_outbound_card_message /
channel_binding_token. Each carries a channel_type discriminator and a
JSONB config for platform-specific identifiers/credentials; cross-platform
columns stay flat. Existing Feishu rows are backfilled (channel_type=
'feishu', app_secret_encrypted via base64). NO foreign keys / cascades
(MUL-3515 §4) — integrity moves to the app layer in the cutover.

queries/channel.sql ports the lark query surface to channel_*, JSONB-aware,
plus DeleteChannelUserBindingsByWorkspaceMember /
DeleteChannelChatSessionBindingBySession for the app-layer cleanup that
replaces the removed cascades.

lark_* tables/queries are left in place here and removed once the Go
cutover lands, so this commit ships green on its own.

Verified: sqlc generate, go build ./..., full migrate chain (1..123) on
Postgres 17, and a real-data backfill spot-check (base64 round-trip,
NULL-strip, functional unique index on (channel_type, app_id)).

MUL-3515

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

* fix(channel): name app_id query param + multi-IM install key + null-safe binding merge

Addresses review on MUL-3515 (PR #4412):

- GetChannelInstallationByAppID: explicitly name params and cast app_id to
  ::text so sqlc emits AppID string. A bare $2 next to `config ->> 'app_id'`
  was mis-attributed to the JSONB config column, generating Config []byte.

- channel_installation uniqueness -> (workspace_id, agent_id, channel_type),
  with the UpsertChannelInstallation conflict key matched. Lets one agent
  hold one installation per IM (feishu + slack + ...) instead of a later
  install clobbering an earlier one. Behaviorally identical in the current
  feishu-only world; "one agent, at most one IM overall" stays an app-layer
  rule per MUL-3515 §4, not a DB constraint.

- CreateChannelUserBinding merges jsonb_strip_nulls(EXCLUDED.config) so a
  re-bind carrying {"union_id": null} no longer erases an already-captured
  union_id, restoring the old COALESCE(EXCLUDED.union_id, ...) semantics.

Regenerated with sqlc v1.31.1. Verified on PG17: re-install replaces in
place, feishu+slack coexist, null re-bind keeps union_id, real union_id wins.

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

* feat(lark): channel-backed Feishu store + fix base64 backfill wrapping

Cutover step 1 of switching the lark Go code from lark_* onto the channel_*
tables (MUL-3515). Introduces the JSONB config boundary the rest of the
cutover sits on, and fixes a latent backfill bug surfaced while building it.

- migration 123: strip newlines from the app_secret_encrypted base64 backfill.
  PostgreSQL encode(...,'base64') MIME-wraps at 76 chars, and a secretbox-
  sealed ~72-byte secret exceeds that. Go's encoding/json decodes a JSON
  string into []byte with base64.StdEncoding, which rejects embedded newlines,
  so without the strip every migrated installation would fail to decrypt its
  app secret once reads move to channel_installation.config.

- store.go: flat domain types (Installation / UserBinding / ChatSessionBinding)
  with field parity to the retired db.Lark* rows, plus the feishu config codec.
  Row->domain mappers decode the JSONB config; the secret decoder is
  whitespace-tolerant so legacy MIME-wrapped data still round-trips, while the
  encoder emits unwrapped base64. Binding config encodes an absent union_id as
  "{}" so the upsert's jsonb_strip_nulls merge never clobbers a stored union_id.

- store_test.go: 72-byte secret round-trip, MIME-wrapped tolerance, optional
  null-strip, and flat-column preservation. Verified on PG17.

Field parity keeps the upcoming ~190 db.LarkInstallation call sites a
mechanical rename. No call sites switched yet; behavior unchanged.

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

* feat(lark): route inbound integration onto channel_* + explicit membership checks

Cutover step 2 (MUL-3515): switch the Feishu Go code from the lark_* queries to
channel_* via a ChannelStore adapter, and replace the removed member foreign key
with explicit application-layer membership checks. No user-visible behavior change.

- channel_store.go: ChannelStore embeds *db.Queries and SHADOWS the ~24 lark
  query methods with channel_*-backed equivalents, keeping the db.Lark*
  signatures so the dispatcher/hub/services and their ~20k lines of tests stay
  untouched; the feishu JSONB config is (de)coded by store.go. Adds
  IsWorkspaceMember and a tx-aware WithTx. Only production wiring swaps
  *db.Queries for *ChannelStore.

- Membership re-check (§4 removed the lark_user_binding -> member FK, so a
  binding row no longer proves current membership):
  * the dispatcher inbound identity step verifies membership after the binding
    lookup; a former member's stale binding is dropped as non_workspace_member
    + audited and never reaches chat_session (§4.3 safety property).
  * RedeemAndBind and BindInstallerTx replace the now-dead FK (23503) branch
    with an explicit IsWorkspaceMember gate, preserving the existing
    ErrBindingNotWorkspaceMember outcome without burning the token.

- router wires the ChannelStore into the patcher, typing indicator, dispatcher,
  hub, and the union_id/region backfills; constructor-based services wrap
  *db.Queries internally so their signatures and nil-check tests are unchanged.

Verified: go build ./... ; go vet ; gofmt ; go test -race ./internal/integrations/...
(full lark suite green unchanged + new membership drop/error tests). Adapter
field mappings (secret base64, union_id RMW, chat-id/open-id remaps, dedup,
token, card) checked end-to-end against a PG17 channel_* schema.

lark_* tables and queries remain (unused at runtime) until the S3 cleanup-hooks
and S4 drop-tables/rename commits.

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

* fix(channel): renumber generalization migration 123 -> 124

main merged 123_issue_stage after this branch forked, so the branch's 123_channel_generalization now collides on the migration number. The runner keys schema_migrations by full version string and would still apply both, but a duplicate number is a merge hazard and convention violation, so move the channel migration to the next free slot (124).

issue_stage (ALTER issue ADD COLUMN stage) and the channel generalization touch disjoint tables; verified on PG17 that 123_issue_stage applies cleanly on a DB already carrying 124_channel_generalization, so the two are order-independent. sqlc regenerated (v1.31.1): only the migration-number comment changed.

MUL-3515

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

* feat(channel): prune channel bindings on member removal + chat session delete

MUL-3515 §4 dropped every channel_* foreign key, so the old ON DELETE CASCADE that cleared a user's channel_user_binding when they left a workspace, and a chat's channel_chat_session_binding when its chat_session was deleted, no longer fires. Re-establish that integrity in the application layer, inside the existing transactions: revokeAndRemoveMember -> DeleteChannelUserBindingsByWorkspaceMember, DeleteChatSession -> DeleteChannelChatSessionBindingBySession.

Adds real-DB tests for both paths, including a scoping check that a remaining member's binding survives the prune. Verified on PG17: both new tests plus the existing revocation tests and the full handler package pass.

MUL-3515

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

* fix(channel): scope Lark/Feishu store reads to channel_type='feishu'

The S2 cutover routed the Feishu integration onto channel_*, but the Lark-facing ChannelStore wrappers read installation / chat-session-binding / outbound-card rows across ALL channel_type values. Once a second IM exists, that would let the Lark hub supervise a non-Feishu installation, the Lark install list show it, /lark/installations/{id} revoke another channel's row, and the outbound patcher / typing indicator act on a non-Feishu chat binding or card.

Add a channel_type predicate to the six read/list channel queries and pass channelTypeFeishu from every wrapper: GetChannelInstallation, GetChannelInstallationInWorkspace, ListChannelInstallationsByWorkspace, ListActiveChannelInstallations, GetChannelChatSessionBindingBySession, GetChannelOutboundCardByTask.

The S3 cleanup deletes (DeleteChannelUserBindingsByWorkspaceMember / DeleteChannelChatSessionBindingBySession) stay all-channel on purpose: a member leaving or a chat_session being deleted should clear every IM's binding. Adds a real-DB test that seeds a Slack installation/binding/card next to the Feishu ones and asserts the Lark wrappers never return them.

MUL-3515

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

* refactor(channel): replace db.Lark* translation layer with lark domain types

S2 introduced ChannelStore as a translation layer that read/wrote channel_* but kept the retired db.Lark* struct/param shapes so the dispatcher/hub/services and their ~20k lines of tests did not have to change. This collapses that layer: the store now takes and returns the package's flat domain types (Installation, UserBinding, ChatSessionBinding, InboundMessageDedup, BindingTokenRow, OutboundCardMessage) and the *Params types in params.go, with channel-neutral field names (ChannelUserID / ChannelChatID / ...). All call sites, fakes, and tests move to the domain types.

No behavior change: only channel_* is read/written (as before); db.Lark* is now unused, and the lark_* tables + queries/lark.sql are removed in the next commit. Verified on PG17: go build / vet / gofmt clean, go test -race ./internal/integrations/... green (the ~20k-line fake suite), and the lark + handler suites pass.

MUL-3515

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

* refactor(channel): drop lark_* tables and queries (remove old path)

The Go cutover (previous commit) moved the lark package entirely onto channel_* and the domain types, leaving the lark_* tables, queries/lark.sql, and the generated db.Lark* models unused. Remove them per the design (§5: replace, do not keep both): migration 125 drops the seven lark_* tables (data already lives in channel_* since migration 124), and queries/lark.sql is deleted + sqlc regenerated, removing the db.Lark* models and lark query methods.

The 125 down recreates the authoritative pre-drop schema (bot_union_id, region, per-installation dedup PK, thread-reply columns). Verified on PG17: fresh migrate up ends with lark_* gone + channel_* present; isolated 125 down/up round-trips correctly; go build / vet / gofmt clean; go test -race ./internal/integrations/... and the handler suite pass.

MUL-3515

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

* fix(migrations): remove trailing blank line at EOF of 125 down migration

git diff --check flagged a blank line at EOF of 125_drop_lark_tables.down.sql (a pg_dump-generation artifact). Whitespace only; the recreate SQL is unchanged.

MUL-3515

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

* refactor(channel): defer lark_* table drop to a follow-up migration

Preflight deploy review: dropping lark_* in the same release that cuts over (old migration 125) is not rollback/rolling-safe — the v0.3.27 release still reads lark_*, so a rolling deploy or a post-deploy code rollback would hit "relation does not exist". Remove the drop and keep the old tables for one release (standard expand/contract): migration 124 already backfilled lark_* -> channel_*, the new code reads/writes only channel_*, and the physical drop moves to a separate cleanup migration once this ships and is observed.

The lark_* tables remain in the schema, so sqlc regenerates the (now unused) db.Lark* models; queries/lark.sql stays deleted (the new code uses channel_*). No code path reads lark_* — only the destructive drop is deferred, keeping the design's no-compat-layer / no-dual-write rule while being deploy-safe.

MUL-3515

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

* fix(channel): skip orphaned installations in hub-boot active scan

Preflight deploy review: channel_installation dropped the workspace/agent FK (MUL-3515 §4), so unlike lark_installation it does not cascade away when its workspace is deleted or its agent is hard-deleted (e.g. runtime teardown). The hub-boot query then keeps opening a WebSocket for a bot whose owner is gone.

JOIN ListActiveChannelInstallations to live workspace + agent so an orphaned installation is never connected, uniformly for every deletion path. The JOIN matches the old ON DELETE CASCADE semantics (row existence, not agent archival), so an archived-but-present agent's installation is still listed; the orphaned row's encrypted secret is thereby never decrypted/used.

Tests: a real-DB handler test asserts a deleted-workspace/agent installation and a non-Feishu one are both excluded; the lark scope test's active-list assertion moved there since the JOIN now needs real workspace/agent fixtures. (Physically deleting dormant orphaned channel rows on workspace/agent deletion is a separate app-layer-cleanup follow-up.)

MUL-3515

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

* docs(channel): document non-rolling cutover constraint for the lark->channel migration

Elon deploy review: keeping the lark_* tables (deferred drop) stops old v0.3.27 code from crashing, but is not full expand/contract. Migration 124 is a one-time backfill; afterwards new code runs on channel_* (lease + dedup on channel_*) while pre-cutover code runs on lark_* (lease + dedup on lark_*). If both run concurrently during a rolling deploy, each side claims the same Feishu bot's WS lease on its own table and double-processes inbound events.

This release therefore requires a NON-ROLLING cutover (stop the old hub before applying migration 124 + starting new code; rollback is not lossless once new code writes channel_*). Documented where deployers/reviewers see it: migration 124 header gains a ROLLOUT note; the channel_store.go header is corrected (lark_* tables are retained one release for rollback safety, not "gone"; the store still never touches them). Comment-only — no schema/codegen/behavior change.

MUL-3515

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

* feat(lark): add MULTICA_LARK_HUB_DISABLED switch for the channel cutover

The lark_*->channel_* cutover needs a way to make the Feishu bot briefly unavailable WITHOUT taking down the whole multica-api process — the Lark hub is a goroutine inside it, not a separate Deployment. MULTICA_LARK_HUB_DISABLED=true parks the hub at startup: the API serves HTTP normally but never claims a WS lease or opens a Feishu connection.

Rollout (see migration 124 ROLLOUT note): ship the new release with the flag SET so new pods run API-only while old pods (hub on lark_*) drain during the rolling deploy — the two hubs never overlap. After the old pods are gone and migration 124 has run, flip the flag off; the new hub comes up on channel_*. The old backend does NOT need this switch — its hub stops when k8s terminates the old pods, not via a flag. Nil-ing LarkHub reuses the existing not-configured path so both the startup start and the shutdown join skip it.

MUL-3515

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

* docs(channel): point migration 124 ROLLOUT note at the hub-disable switch

Refine the rollout note to use MULTICA_LARK_HUB_DISABLED for a bot-only cutover (new pods serve API with the hub parked while old pods drain; flip the switch off after the migration), instead of the earlier whole-API recreate. Comment-only.

MUL-3515

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

* docs(channel): fix migration 124 rollout order and document self-host cutover

The previous ROLLOUT note shipped the new (channel_*) build before
running migration 124, so the channel_*-backed HTTP paths (installation
list/install/revoke, chat-session delete, member revoke) would 500 in
the window between new-pod boot and the deferred migration. Restate the
runbook around two explicit invariants — channel_* must exist before the
new build serves those paths, and the old/new hubs must never overlap —
and order the steps so channel_* is created first (park old hub -> snapshot
-> deploy parked new build -> unpark). Document that default self-host
(entrypoint migrate + single-replica Recreate) satisfies both invariants
automatically and needs no manual steps; only prd / multi-replica rolling
self-host needs the switch procedure. Clarify in main.go that the
hub-park switch is generation-agnostic (parks whichever hub the build
carries), which is what enables the preparatory release.

Refs MUL-3515

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-24 12:46:20 +08:00
Bohan Jiang
3103ed1082 fix(agent): surface Antigravity provider log failures (#4494)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 12:45:52 +08:00
Ryan Yu
4064b164be docs: add agent runtime install page to zh nav (#4480) 2026-06-24 12:40:30 +08:00
Multica Eve
131ca80a6c refactor(autopilot): migrate scheduled dispatch to scheduler.Manager (3/3 MUL-3551) (#4444)
* refactor(autopilot): migrate scheduled dispatch to scheduler.Manager

PR 3 of 3 for the scheduled-Autopilot refactor on MUL-3551.

Replaces the legacy cmd/server/autopilot_scheduler.go goroutine
(30 s app-clock polling, app-time cron advancement, weak crash
recovery) with a JobSpec registered on the existing
scheduler.Manager. sys_cron_executions is now the lease + audit
table for scheduled Autopilot occurrences, and the unique key on
(job_name, scope_kind, scope_id, plan_time) is the primary
guarantee that the same planned fire time cannot produce two runs.

What changed

  * server/internal/scheduler/jobs_autopilot.go
    New AutopilotScheduleDispatchJob factory:
      - scope_kind = "autopilot_trigger", scope_id = trigger.id
      - PlansForScope hook (from PR 1) enumerates cron occurrences
        in (lastPlan, dbNow] and collapses missed fires to the most
        recent one (CatchUpLatestOnly — same policy the legacy
        goroutine had, now provable via a one-row-per-tick audit).
      - Handler re-loads trigger + autopilot inside the handler so a
        between-tick state change (paused, disabled, deleted) takes
        effect immediately and is recorded as a no-op SUCCESS row
        with skipped_reason in the result JSON.
      - Calls AutopilotService.DispatchAutopilotForPlan (from PR 2)
        for the actual run creation; that path is itself idempotent
        on (trigger_id, planned_at), so a stale-steal retry reuses
        the run created by the prior attempt instead of duplicating.
      - RunTimeout=2m, StaleTimeout=5m, HeartbeatInterval=30s,
        AllowStaleReentry=true, MaxAttempts=3, RetryBackoff
        [1m, 5m, 15m], MaxPlansPerTick=5 (safety cap).

  * server/internal/scheduler/manager.go
    Manager.runOnce promoted to RunOnce (exported) so external test
    packages can drive deterministic ticks; existing call sites in
    this package + cmd/server tests updated.

  * server/internal/service/cron.go
    NextOccurrenceAfterUTC and NextOccurrencesUTC: cron evaluators
    that take an explicit "now" instant. Callers pass dbNow() so
    schedule decisions stay consistent across app instances with
    clock skew. Legacy ComputeNextRun is preserved (delegating to
    NextOccurrenceAfterUTC with time.Now()) for the display-only
    autopilot_trigger.next_run_at write path — scheduling decisions
    no longer use it.

  * server/pkg/db/queries/autopilot.sql
    ListSchedulableAutopilotTriggers replaces the legacy
    ClaimDueScheduleTriggers (the new path no longer mutates
    autopilot_trigger.next_run_at on claim). RecoverLostTriggers
    removed — sys_cron_executions lease theft now handles crash
    recovery without an in-handler restart sweep.

  * server/cmd/server/main.go
    The "go runAutopilotScheduler(...)" line is gone. The new
    JobSpec is registered alongside TaskUsageHourlyJob on the
    existing schedulerMgr (still using sweepCtx for lifecycle).

  * server/cmd/server/autopilot_scheduler.go DELETED.

Tests

  * server/internal/service/cron_test.go — unit tests for the cron
    helpers: timezone-aware enumeration, half-open (after, until]
    window, plan_time-exclusive "after", invalid inputs surface
    parse errors, and the "ignores wall clock" property the
    scheduler relies on.

  * server/cmd/server/autopilot_schedule_job_test.go — DB-backed
    integration tests:
      - DispatchesOnce: one tick → 1 SUCCESS exec row + 1
        autopilot_run with planned_at set; a second tick does not
        regress the count.
      - MissedSchedulesCollapse: an hour of missed */5 fires
        produce a single autopilot_run, not 12.
      - CrashRecovery: simulated stale RUNNING lease at the same
        plan_time → second tick reclaims it and DOES NOT duplicate
        autopilot_run.
      - TwoRunnersSingleWinner: two concurrent
        scheduler.Manager instances on the same trigger →
        per-plan_time uniqueness holds (sys_cron_executions never
        has two RUNNING rows at the same plan_time, autopilot_run
        count == exec row count).
      - DisabledTriggerSkips: a trigger disabled between
        scope-list and tick produces no exec row.
      - PausedAutopilotSkipsAtHandler: an autopilot paused after
        the first tick does not produce a new exec row.
      - BadCronFailsLoudly: an invalid cron expression never fires
        dispatch (parse error surfaces in the plan hook).
    Existing autopilot listener / squad / dispatch tests still
    pass.

  * server/internal/scheduler/plans_for_scope_test.go from PR 1
    still passes (RunOnce rename only).

Verification

  * go build ./...
  * go vet ./...
  * go test ./internal/scheduler ./internal/service ./cmd/server
    ./internal/handler — all green.

Rollback

  * Reverting this commit re-introduces the legacy goroutine.
    Migration 124 (PR 2) and the scheduler hook (PR 1) stay in
    place. Autopilot data on disk is forward- and backward-
    compatible: planned_at columns are nullable, the legacy
    goroutine never reads planned_at and the new job never reads
    autopilot_trigger.next_run_at.

Refs MUL-3551

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

* fix(autopilot): scheduler hook retries FAILED plans + tighten tests

Review fix for #4444 (MUL-3551).

Blocker: hook planner skipped the FAILED-with-retry plan_time

`autopilotPlansForScope` unconditionally set
`after = latest.PlanTime` when `latest.Found`, then enumerated cron
occurrences in the half-open interval `(after, dbNow]`. That
EXCLUDED the FAILED plan_time itself, so `tryClaim`'s
"FAILED-with-retry" branch — which only fires when the planner
returns the same plan_time — never ran. A claim + crash sequence
left the FAILED row stuck at attempt<max_attempts forever and the
scheduled occurrence was lost (MUL-3551 acceptance ③).

Fix: hook now branches on `latest.RetryEligible(now)` BEFORE
computing `after`. When the most recent stored row is FAILED with
attempts remaining and next_retry_at <= dbNow, the hook returns
`[latest.PlanTime]` unchanged. tryClaim's retry-from-FAILED path
fires, attempt increments, the run is retried, and the audit row
reaches SUCCESS at the same plan_time. Mirrors the cadence
planner's `info.RetryEligible(now)` branch in manager.plansForTick.

Tests

  * TestAutopilotScheduleJobCrashRecovery rewritten to actually
    pin the retry contract instead of just "no duplicate run":
      - assert first attempt completes at attempt=1 with a real
        task_id linkage (the "complete" snapshot the retry must
        reuse);
      - simulate a crash mid-dispatch (status=RUNNING, expired
        stale_after, ghost lease_token);
      - assert tick 2 transitions the SAME exec row (same plan_time)
        to status=SUCCESS at attempt=2 (proving the planner did
        NOT skip past the FAILED bucket);
      - assert autopilot_run stays at exactly one row, reused from
        the first attempt — proving DispatchAutopilotForPlan's
        complete-run reuse path is what closes the loop.

  * TestAutopilotScheduleJobPausedAutopilotSkipsAtHandler rewritten
    to invoke `job.Handler` directly (the previous version drove
    `mgr.RunOnce` which short-circuited at the scope-list SQL
    filter and never reached the handler). The new test pauses the
    autopilot AFTER setup, calls the handler with a fabricated
    HandlerInput, and asserts the handler returns
    skipped_reason=autopilot_inactive without creating an
    autopilot_run.

  * TestAutopilotScheduleJobBadCronFailsLoudly renamed to
    TestAutopilotScheduleJobBadCronStaysSilent and updated to
    match the real implementation: a parse error in the plan hook
    surfaces as a manager-level warning log, NOT a
    sys_cron_executions row (no plan_time was ever claimed). The
    test now asserts zero exec rows AND zero autopilot_run rows,
    documenting that bad cron is a permanent configuration error
    (caught at HTTP create/update time first), not a transient
    failure that belongs in the retry envelope.

Refs MUL-3551

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-24 12:09:07 +08:00
Multica Eve
eca6102365 refactor(autopilot): autopilot_run.planned_at + DispatchAutopilotForPlan (2/3 MUL-3551) (#4443)
* refactor(scheduler): add PlansForScope hook for non-cadence jobs

The current Manager.plansForTick assumes a uniform Cadence grid:
plan_times are derived via FloorPlan(db_now, Cadence). That works for
rollup_task_usage_hourly but not for the upcoming Autopilot schedule
dispatch job, where each trigger has its own cron expression and the
plan_times do not snap to a single global grid.

This change adds an optional JobSpec.PlansForScope hook. When set:

  * Manager loads the latest stored plan for (job, scope) and passes
    a new LatestPlanInfo to the hook (exported from the previously
    private latestPlanInfo). The hook returns the plan_times to attempt
    this tick.
  * Cadence, CatchUpMode and CatchUpWindow are bypassed; the hook is
    in full control of plan_time selection.
  * MaxPlansPerTick still acts as a safety cap on the hook's output.
  * All other timing fields (RunTimeout / StaleTimeout /
    HeartbeatInterval / MaxAttempts / RetryBackoff / AllowStaleReentry)
    and the lease/heartbeat/terminal-write SQL primitives are reused
    unchanged.

JobSpec.validate now allows Cadence=0 when PlansForScope is set, and
makes the every_plan MaxPlansPerTick > 0 invariant fire only on
Cadence-driven every_plan jobs. Existing rollup_task_usage_hourly
behaviour is unchanged — that JobSpec leaves PlansForScope nil.

Tests:
  * TestJobSpecValidatePlansForScopeRelaxesCadence — validate() rules.
  * TestManagerPlansForScopeHookDrivesPlans — end-to-end hook delegation
    through the manager (DB-backed), proving that hook-returned
    plan_times go through the same tryClaim path, MaxPlansPerTick
    truncates without erroring, and LatestPlanInfo is populated on the
    second tick.
  * TestManagerPlansForScopeHookEmptyIsNoOp — empty hook output is a
    valid no-op.

No behaviour change for callers that don't set PlansForScope.

Refs MUL-3551

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

* refactor(autopilot): add planned_at + DispatchAutopilotForPlan for occurrence idempotency

PR 2 of 3 for the scheduled-Autopilot refactor on MUL-3551.

Adds dispatch-layer idempotency for scheduled triggers. This is the
second line of defence behind the primary uq_sys_cron_execution
guarantee in sys_cron_executions: if a runner crashes between
"create autopilot_run" and "write SUCCESS in sys_cron_executions",
the next stale-steal retry re-enters dispatch with the SAME
(trigger_id, planned_at). Without a row-level guard, that retry
would create a duplicate autopilot_run, issue, and task.

Changes:

  * Migration 124: ALTER TABLE autopilot_run ADD COLUMN planned_at
    TIMESTAMPTZ + partial unique index on (trigger_id, planned_at)
    WHERE both are NOT NULL. Manual / webhook / api dispatch leaves
    planned_at NULL so they keep the existing semantics unchanged.

  * autopilot.sql: CreateAutopilotRun now takes planned_at;
    GetAutopilotRunByTriggerAndPlanned is the fast-path lookup used
    by DispatchAutopilotForPlan to detect a prior attempt's row
    without burning an INSERT.

  * service.DispatchAutopilotForPlan: new entry point for scheduled
    triggers that already know the canonical UTC plan_time of the
    occurrence they are firing. Looks up an existing run for
    (trigger_id, planned_at) and reuses it on a stale-steal retry;
    otherwise dispatches normally with planned_at stamped on the
    new run.

  * service.DispatchAutopilot keeps its current signature for
    manual / webhook / api callers (planned_at stays NULL).

  * recordSkippedRun also threads planned_at so the skip path
    participates in the same partial-unique guarantee.

  * sqlc v1.31.1 regenerated autopilot.sql.go + models.go.
    Unrelated workspace.sql.go drift restored.

Tests (against local Postgres):

  * TestDispatchAutopilotForPlanIsIdempotent — first call creates a
    run; second call with same (trigger, planned_at) reuses it
    (autopilot_run row count stays at 1); third call with a different
    planned_at on the same trigger creates a second run (proves we
    are not collapsing legitimate occurrences).

  * TestDispatchAutopilotForPlanRejectsZeroArgs — invalid trigger_id
    and zero planned_at both fail loudly so callers cannot silently
    disable the idempotency guard.

  * Existing autopilot listener / squad / dispatch tests all still
    pass.

This PR has no scheduler / handler / UI behaviour change on its own:
the new entry point exists but is not yet wired into the schedule
goroutine. PR 3 will register the autopilot_schedule_dispatch
JobSpec that consumes it and remove the legacy
cmd/server/autopilot_scheduler.go path.

Refs MUL-3551

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

* fix(autopilot): DispatchAutopilotForPlan recovers partial-state runs

Review fix for #4443 (MUL-3551).

Before this change, DispatchAutopilotForPlan returned ANY existing
autopilot_run for (trigger_id, planned_at), including the
half-written rows produced when a runner crashed between
"CreateAutopilotRun" and "create downstream issue/task". The
scheduler handler would then write SUCCESS in sys_cron_executions
even though no issue or agent task was ever created, silently
losing the scheduled occurrence.

Fix:

  * New isAutopilotRunComplete helper classifies an existing run:
      - terminal status (completed / failed / skipped) → reuse.
      - issue_created with valid issue_id → reuse (the issue
        listener owns task creation from here).
      - running with valid task_id → reuse (the task is queued).
      - anything else → partial; must NOT short-circuit.

  * New SQL RecoverPartialAutopilotRun marks a partial row FAILED
    with a recovery reason AND clears its planned_at. The cleared
    planned_at releases the partial-unique slot in
    uq_autopilot_run_trigger_planned, letting the fresh dispatch
    INSERT a new row at the same (trigger_id, planned_at) without
    conflict.

  * DispatchAutopilotForPlan now branches on the lookup:
    complete run → return; partial run → recover-then-fresh-
    dispatch; not-found → fresh dispatch. The fresh dispatch path
    still goes through dispatchAutopilot, so the new row carries
    the real issue_id / task_id by the time the handler returns.

  * Tests: TestDispatchAutopilotForPlanRecoversPartialRun seeds a
    partial run (status='running', task_id=NULL for run_only;
    status='issue_created', issue_id=NULL for create_issue) and
    asserts the retry:
      - returns a DIFFERENT run row (no false reuse);
      - leaves the partial row in status='failed', planned_at=NULL,
        with a non-empty failure_reason for ops;
      - produces a fresh row with planned_at preserved AND the
        appropriate downstream linkage (task_id for run_only,
        issue_id for create_issue);
      - exactly one live row at (trigger_id, planned_at) after
        recovery, so the partial-unique constraint is honoured.

Existing TestDispatchAutopilotForPlanIsIdempotent and
TestDispatchAutopilotForPlanRejectsZeroArgs still pass — the
complete-reuse path is unchanged for the realistic SUCCESS-state
case.

Refs MUL-3551

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-24 12:01:10 +08:00
Multica Eve
f9ed62f075 refactor(scheduler): add PlansForScope hook for non-cadence jobs (#4442)
The current Manager.plansForTick assumes a uniform Cadence grid:
plan_times are derived via FloorPlan(db_now, Cadence). That works for
rollup_task_usage_hourly but not for the upcoming Autopilot schedule
dispatch job, where each trigger has its own cron expression and the
plan_times do not snap to a single global grid.

This change adds an optional JobSpec.PlansForScope hook. When set:

  * Manager loads the latest stored plan for (job, scope) and passes
    a new LatestPlanInfo to the hook (exported from the previously
    private latestPlanInfo). The hook returns the plan_times to attempt
    this tick.
  * Cadence, CatchUpMode and CatchUpWindow are bypassed; the hook is
    in full control of plan_time selection.
  * MaxPlansPerTick still acts as a safety cap on the hook's output.
  * All other timing fields (RunTimeout / StaleTimeout /
    HeartbeatInterval / MaxAttempts / RetryBackoff / AllowStaleReentry)
    and the lease/heartbeat/terminal-write SQL primitives are reused
    unchanged.

JobSpec.validate now allows Cadence=0 when PlansForScope is set, and
makes the every_plan MaxPlansPerTick > 0 invariant fire only on
Cadence-driven every_plan jobs. Existing rollup_task_usage_hourly
behaviour is unchanged — that JobSpec leaves PlansForScope nil.

Tests:
  * TestJobSpecValidatePlansForScopeRelaxesCadence — validate() rules.
  * TestManagerPlansForScopeHookDrivesPlans — end-to-end hook delegation
    through the manager (DB-backed), proving that hook-returned
    plan_times go through the same tryClaim path, MaxPlansPerTick
    truncates without erroring, and LatestPlanInfo is populated on the
    second tick.
  * TestManagerPlansForScopeHookEmptyIsNoOp — empty hook output is a
    valid no-op.

No behaviour change for callers that don't set PlansForScope.

Refs MUL-3551

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 12:00:34 +08:00
Naiyuan Qing
8a0934c741 fix(editor): track @mention selection by identity, not slot index (MUL-3607) (#4488)
The @mention popup tracked the highlighted row with a positional integer
(selectedIndex into displayItems), but rows are rendered in the re-bucketed
order produced by groupItems() (current → recent → search → users → issues).
An async server "search" result is appended to the END of displayItems yet
hoisted near the TOP on render, so the highlighted row and the committed
item pointed at different entries — you navigate to one target but mention
its neighbour. A separate useEffect also force-reset selectedIndex to 0 on
every displayItems change, snapping an active selection back to the first row
whenever async results landed.

Root cause: a slot index is not a stable target for a list whose order and
length change asynchronously. Track selection by item identity instead:

- Replace selectedIndex state with selectedKey (the item's `type:id`).
- Derive groups/orderedItems (the exact rendered order) and resolve the
  numeric index from selectedKey against orderedItems; fall back to row 0
  when the pinned item is gone or nothing is picked yet.
- Keyboard nav, Enter, clicks, highlight, and scroll all index orderedItems,
  so the highlighted row always equals the committed item.
- Drop the force-reset effect; identity-based selection self-heals across
  reorders and async arrival without resetting an active pick.

Adds a regression test asserting the highlighted row equals the committed
item when groupItems reorders the list, both initially and after ArrowDown.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:53:51 +08:00
Naiyuan Qing
d43840f322 Revert "fix(editor): index @mention selection by rendered order (MUL-3607) (#…" (#4489)
This reverts commit 1890a9fa19.
2026-06-24 10:50:53 +08:00
Naiyuan Qing
1890a9fa19 fix(editor): index @mention selection by rendered order (MUL-3607) (#4487)
The @mention popup navigated and committed by indexing the flat
`displayItems` array, but rendered rows in the re-bucketed order from
`groupItems()` (current → recent → search → users → issues). In chat
(context mode) the async server-search results are appended to the end
of `displayItems` yet tagged `group:"search"`, so `groupItems()` hoists
them near the top. The highlighted row and the committed item then point
at different entries — you select one target but mention its neighbour
(the reported "@bohan picks the next one" off-by-one).

Make the flattened grouped order (`orderedItems`) the single index space
for `selectedIndex`, arrow keys, Enter, and clicks, so the highlighted
row is always the committed item. Plain issue-comment mentions (default
mode) were already safe — no group tags means `groupItems()` is
order-preserving — and stay unchanged.

Adds a regression test asserting highlighted row == committed item when
the list is reordered by a hoisted search result.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 10:46:03 +08:00
Naiyuan Qing
b79777caec feat(comments): resolve-aware fold for agent comment reads (MUL-3555) (#4463)
* feat(comments): resolve-aware fold for agent comment reads (MUL-3555)

Agents reading a long issue paid tokens for settled discussion. The human
timeline already folds resolved threads, but the agent read path
(`comment list`) ignored resolved_at entirely — humans saw the conclusion,
agents got the full raw discussion.

Add an opt-in `fold=true` projection to ListComments that collapses each
resolved thread to root + conclusion (reply-resolved) or root only
(root-resolved), reusing the human timeline's deriveThreadResolution
semantics. The resolved thread's root carries `thread_resolved` +
`folded_count`; `--full` brings the dropped comments back. Fold is rejected
on partial-thread reads (since/tail) and roots_only, where a resolution
comment could be unfetched and silently dropped.

CLI `comment list` folds by default on the complete-thread reads (default,
--recent, untailed --thread) with a `--full` escape hatch; the agent
prompts and runtime brief document the fold + escape. No new endpoint, no
human UI change, no SQL/migration change — in-memory projection, same
precedent as summary/roots_only.

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

* refactor(daemon): dedupe fold prompt restatements per review (MUL-3555)

Howard's PR review flagged DRY redundancy: the resolve-fold rule was
restated in full in the task prompt (prompt.go:41/:182) and the brief
workflow steps (runtime_config.go:673/:692, reply_instructions cold
hint) even though the canonical command catalog (runtime_config.go:477)
— always present in the brief — already documents it in full, and the
task prompt explicitly defers to it ("follow the rule in your runtime
workflow file").

Keep the catalog entry full (the canonical reference); shrink the five
inline restatements to a short "resolved threads come back folded —
`--full` to expand" pointer. No loss of signal (the agent always has the
full catalog in context), ~80-120 tokens/run saved on the worst-case
assignment / cold paths.

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-24 09:52:18 +08:00
Naiyuan Qing
a898e5317e fix(chat): stop cancelled-run drafts resurrecting after deletion (#4486)
Cancelling a chat task restored the message into the input via a
separate `editorRestore` component state that also held a private copy
of the content and forced an editor remount (bumping `editorKey`).

That copy was never cleared and `activeRestore` re-derived on every
return to the draft's session, so `defaultValue={activeRestore?.content
?? inputDraft}` kept re-injecting text the user had already deleted —
the draft (`inputDraft`) cleared, but the stale copy did not.

The editor already self-syncs external `defaultValue` changes into a
live instance (content-editor.tsx defaultValue-sync effect, used for WS
description updates), so the remount mechanism was redundant. Drop the
whole `editorRestore` state and let `inputDraft` be the single source of
truth: restore just writes the draft, the editor's own sync displays it.
Now cancel-restore behaves exactly like normal typing — delete the text
and it stays gone across navigation.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:33:56 +08:00
Bohan Jiang
ac84b8c70c fix(agent): stop Antigravity turns dying at agy's hidden 5m print-timeout (#4462)
agy's --print-timeout defaults to 5m when the flag is omitted, but the
daemon treated "omit the flag" as "no cap". In the default no-cap config
every Antigravity turn was therefore silently capped at 5 minutes: any
run whose build/tests outlived the budget had agy abort mid-turn, print
"Error: timed out waiting for response", and exit 0 — which the backend
recorded as a successful "completed" with truncated output (the reported
"Antigravity disconnects", MUL-3570 / #4453).

- Always pass --print-timeout: the configured cap when positive, else a
  large value (24h) that defers to the daemon's idle/tool watchdogs.
- Detect agy's print-mode timeout marker in the run log and surface the
  result as a timeout instead of a truncated success.

Verified by reproducing against agy 1.0.8 and with new unit + end-to-end
backend tests.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 18:57:50 +08:00
Multica Eve
be5fd7d3f0 MUL-3577: add 2026-06-23 changelog entry (#4461)
* docs: add 2026-06-23 changelog entry

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

* docs(changelog): sharpen 0.3.28 title and handoff wording

- Headline the two flagship features in the title: staged Sub-Issues and Qoder runtime support
- Rewrite the vague agent-handoff line to spell out the pre-trigger confirmation (whether/which agent will start, apply without running) and the handoff note as next-run context
- Apply across en/ja/ko/zh

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-06-23 18:50:44 +08:00
Bohan Jiang
cfc488769b MUL-3574: update runtime and CLI docs (#4460)
* docs: update runtime and CLI docs for MUL-3574

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

* docs: address runtime docs review for MUL-3574

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-23 17:26:12 +08:00
Bohan Jiang
294953ba37 fix: delete custom runtime profiles from runtime rows (#4456)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 16:52:46 +08:00
Naiyuan Qing
2857a4c649 fix(transcript): live-update issue/agent transcript dialog from shared cache (#4452)
The transcript dialog opened from a running task's row showed only a
one-shot snapshot taken at open time: TranscriptButton fetched once via
api.listTaskMessages and cached it locally, never subscribing to the
shared ["task-messages", taskId] cache that the WS task:message stream
already seeds. New tool calls / thinking / text never appeared until the
task finished or the page reloaded.

Add a live-cache mode to the shared TranscriptButton: when isLive and the
parent provides no items and the task id is a persisted UUID, render from
the shared task-messages cache so the open dialog grows in real time. On
open (and again on the running→terminal transition) force a backfill via
api.listTaskMessages and merge it into the cache by seq — taskMessagesOptions
is staleTime:Infinity, so a plain subscription never heals a WS reconnect
gap. The cache observer is read-only (enabled:false) so React Query never
blind-replaces the cache; only the WS handler and the seq-merged backfill
write it. The subscription mounts only while the dialog is open, so closed
live rows add no baseline requests; terminal tasks keep the lazy one-shot
fetch.

Covers issue execution-log and agent activity. Autopilot issue-less
run_only live log is out of scope: the backend doesn't broadcast
task:message for tasks with no issue/chat session, so there's nothing to
subscribe to — backend broadcast unchanged.

Extract mergeTaskMessagesBySeq into packages/core/chat/queries.ts and route
both the realtime task:message handler and the new backfill through it, so
there is one seq-merge semantics for that cache instead of two.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 16:35:29 +08:00
Bohan Jiang
5038c983c0 MUL-3281: Add daemon skill bundle refs (#4445)
* feat: add daemon skill bundle refs

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

* fix: tighten skill bundle resolve safeguards

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

* feat: add task prepare lease

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

* fix: isolate prepare lease concurrent index migration

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

* fix: keep prepare lease active through start

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-23 16:19:16 +08:00
Naiyuan Qing
3ce97453b3 fix(issues): pre-trigger preview + run-confirm + handoff UX polish (MUL-3375) (#4454)
* fix(issues): stop issue-trigger preview flicker

The pre-trigger preview re-rendered/refetched on every workspace task
event: WS task lifecycle invalidated issueTriggerPreviewAll (staleTime 0),
forcing a background refetch whose isFetching was surfaced as isLoading,
collapsing and reopening CreateRunHint's reveal band.

The assign source (create / assignee change) cancels existing tasks before
enqueuing, so its verdict can't shift from a task event at all; the status
source's pending dedup could, but the preview is advisory and the write
path re-evaluates authoritatively, so a rare stale label is harmless. Drop
the WS invalidation so the preview refetches only on input (signature)
change. Keep the comment-trigger invalidation — its verdict genuinely
changes mid-compose and its chips drive an immediate, unconfirmed send.

Align the hook's data handling with the comment-trigger preview:
keepPreviousData so an input switch swaps in place instead of collapsing,
and treat only the first load (no prior data) as loading.

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

* fix(issues): skip run-confirm modal for backlog assign

Assigning a Backlog issue to an agent/squad never starts a run (the
parking lot — server/internal/service/issue_trigger.go), so the
pre-trigger confirm modal only rendered an empty "won't start" box with
a single Apply button. Apply directly instead: the single path checks
issue.status, the batch path skips only when every selected issue is
Backlog (mixed selections still confirm — the non-backlog ones trigger).
Mirrors the existing backlog short-circuit in handleBatchStatus.

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

* fix(modals): run-confirm loading state + submit spinner

The dialog grew in height after open: it rendered the short "won't
start" variant while POST /api/issues/preview-trigger was in flight, then
the note box appeared when the predicate landed. Keep the note box
mounted (disabled) during loading so assign mode opens at its resolved
height, and show a Spinner + 'checking' headline while loading.

Submit had no feedback — buttons only disabled, which read as frozen for
note assigns (the request starts an agent server-side). Track which
footer action is in flight and show a Spinner on the clicked button.

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

* feat(issues): show handoff note in execution-log trigger text

An assignment-triggered run that carried a handoff note showed the
generic "Initial run" label. Surface the note inline (truncated, like
comment triggers show their text) so the row reads as the handoff.

taskToResponse now populates handoff_note for all callers (dropping the
now-redundant explicit set in ClaimTaskByRuntime); the field is added to
the AgentTask type + zod schema (optional, additive — old clients ignore
it via the loose schema, new clients fall back to "Initial run").

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:15:44 +08:00
Bohan Jiang
09f90abb70 MUL-3568 feat(landing): show live GitHub star count on the header GitHub button (#4451)
* feat(landing): show live GitHub star count on the header GitHub button

Add a small client hook (useGithubStars) that fetches stargazers_count
from the GitHub API and a formatStarCount helper that renders it in
GitHub's compact repo-header style (e.g. "37.6k"). The landing header's
GitHub button now appends a star badge (faint divider + filled star +
count) on both the desktop and mobile menu entries.

Fetched client-side on purpose: LandingHeader is shared across every
marketing page, so one client fetch covers them all without threading a
server value through each render site, and each visitor calls the API
from their own IP, sidestepping the shared-outbound-IP rate limit the
server-side github-release fetcher works around with a PAT. The result
is memoized at module scope (plus in-flight dedupe); a failed fetch
caches null and the button degrades to the plain "GitHub" label.

* fix(landing): drop the star glyph from the GitHub star badge

In the GitHub button context the number already reads as the star count,
so the icon is redundant. Keep the divider + count only.
2026-06-23 15:51:14 +08:00
Bohan Jiang
a5636f0ff4 feat: add copy button to readonly code blocks (#4448)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 15:01:34 +08:00
Multica Eve
12ea1f6a8c MUL-3495: support custom runtime args and registration errors (#4408)
* feat: support custom runtime args

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

* fix: address custom runtime 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-23 14:20:18 +08:00
Reinhard Schneidewind
7008f4276d MUL-3558 feat: add 'issue usage' CLI command for aggregated issue token usage
`GET /api/issues/:id/usage` endpoint (handler `GetIssueUsage`). Returns the
aggregated token usage for an issue, summed across all of its task runs.

The usage is already captured server-side and shown in the issue detail view,
but was not reachable from the CLI, so it couldn't feed billing/cost scripts.
This closes that gap with no backend changes.

$ multica issue usage MUL-139
INPUT_TOKENS  OUTPUT_TOKENS  CACHE_READ  CACHE_WRITE  RUNS
5625          83880          9190806     154078       1

$ multica issue usage MUL-139 --output json
{ "total_input_tokens": 5625, ... "task_count": 1 }

Accepts an issue key (`MUL-139`) or UUID, mirroring `issue runs`.

- Table cells use the existing `formatMetadataValue` helper so large cache-token
  counts render as plain integers (not scientific notation).
- Scope is the per-issue aggregate the endpoint already returns (`task_count` =
  run count); a per-run token breakdown is out of scope.

- `go test ./cmd/multica/ -run TestRunIssueUsage` (added) 
- `go vet ./cmd/multica/` 
- Verified against a live self-hosted server; numbers match the issue UI.

- `server/cmd/multica/cmd_issue.go` — command + handler
- `server/cmd/multica/cmd_issue_test.go` — unit test
- `CLI_AND_DAEMON.md` — docs
2026-06-23 14:13:57 +08:00
Naiyuan Qing
4ab335b8a5 MUL-3416: Issue pre-trigger preview + Handoff Note (#4383)
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint

Collapse the issue update/batch enqueue copies into one service predicate
service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run
endpoint POST /api/issues/preview-trigger so the four entry points stop
drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate
stays at the HTTP boundary: write paths inject allow-all, preview injects the
real gate so it never leaks a private agent's readiness.

Add suppress_run to issue update/batch: the change applies but no run starts.
Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign /
isSquadLeaderReady. service.Create and the comment trigger chain are untouched.

Tests: preview behavior, preview<->write-path match, batch aggregation,
member no-trigger, suppress_run skip, malformed-body 400.

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

* feat(issues): inject handoff note into assigned runs via first-class task field

Add an optional handoff_note carried by issue assign/promote into the run's
opening prompt and issue_context.md, via a dedicated agent_task_queue column
(migration 122) and a daemon assignment-handoff render branch — never a
fabricated comment, never trigger_comment_id (MUL-3375 §6.1).

Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff
public variants and dispatchIssueRun; suppress_run or a parked write drops it
(no run = nothing to inject). Soft version gate: MinHandoffCLIVersion +
HandoffSupported, surfaced per-trigger as handoff_supported in the preview so
the UI can gray the note box on old daemons; the assignment never hard-fails.

Tests: daemon prompt + issue_context render via the assignment branch (not
quick-create/comment), version helper matrix, note persists on the task,
suppressed assign enqueues nothing.

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

* feat(issues): leave a display-only handoff record on the timeline

When an assign/promote with a handoff note starts a run, write one
type='handoff' timeline record via TaskService.RecordHandoff — a direct
Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so
it never reaches triggerTasksForComment and cannot start a second run
(MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who
handed off; body is the note. Migration 123 admits the 'handoff' comment type.
Recorded only on a real run start: suppress_run or a parked write writes
nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace
is gated on an actual dispatch.

Test: exactly one handoff record on assign-with-note, exactly one task (no
re-trigger), and no record when suppressed.

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

* feat(issues): frontend plumbing for issue-trigger preview + handoff (core)

Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).

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

* fix(issues): exclude handoff records from new-comment counting

type='handoff' is a display-only timeline record, not conversation. Exclude it
from CountNewCommentsSince so a handoff note never inflates the count of
"new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics
already excludes it (RecordHandoff is a direct write that emits no analytics
event), and the comment-trigger path is already bypassed.

Test: a handoff record does not bump the new-comment count; a real comment does.

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

* feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop)

Wire the §9 frontend onto the preview endpoint + handoff fields:
- Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type;
  the over-eager nag is gone. Backlog awareness is now a passive label.
- RunConfirmModal: single assign + batch assign/status route here. Shows the
  backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional
  handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 —
  then applies via update/batch. No frontend guessing.
- create modal: passive CreateRunHint ("将启动 @X" / backlog parked).
- single status change stays a direct apply (unchanged).
- timeline: render type='handoff' as a distinct, non-interactive handoff card.
- i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action
  keys; locale parity green.

Tests: use-issue-actions (assign → run-confirm modal, member → direct),
create-issue + comment-card suites updated/green; views typecheck + lint clean.

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

* test(issues): use a valid anchor in the handoff count-exclusion test

CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and
excludes every row, so an empty anchor made the control assertion read 0. The
production caller always passes a real anchor — mirror that with a non-matching
sentinel uuid.

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

* test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch)

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

* test(core): preview schema malformed/missing/null fallback coverage

Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed
parse, top-level + item default fills (empty/older backend), and fallback to
{ triggers: [], total_count: 0 } for malformed shapes, a dropped required
issue_id, a wrong-typed total_count, and null/non-object bodies — so the four
entry points degrade to "nothing will start" instead of throwing.

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

* refactor(issues): remove display-only handoff timeline record (留痕)

The handoff "留痕" timeline record (type='handoff' comment written on run
start) was judged superfluous and dropped per product call. This removes
only the display-only trace; the handoff NOTE injection into the run's
opening prompt + issue_context.md is untouched.

- backend: drop RecordHandoff + its call in dispatchIssueRun
- db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and
  migration 123 (comment_type_check reverts to the 4-type set from 001);
  no production data exists for this unreleased feature
- frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card
  i18n (all locales)
- tests: drop handoff_count_test.go and the record-write assertions in
  issue_trigger_preview_test.go (note-injection tests retained)

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

* feat(issues): dismissable run-confirm modal + team-handoff copy

Two fixes to the pre-trigger confirm modal (MUL-3375).

1. Dismissable: switch RunConfirmModal from AlertDialog to the standard
   shadcn Dialog so it has the close (X) button + Esc + click-outside.
   Previously the only choices were "start" / "don't start now" with no
   way to abort the action entirely; dismissing now cancels with no write.

2. Copy: rework the action-surface wording away from the backend term
   "run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on
   record surfaces). Unifies the note's three names to "交接说明", and
   parallels the rewrite across en/ja/ko.

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

* chore(agent): bump handoff note min CLI version to 0.3.28

The daemon release that renders handoff notes ships in 0.3.28 (0.3.27
was the prior tag), so move the soft-gate threshold up. Below this the
note is silently dropped and the frontend grays the note box — assignment
is never blocked.

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

* fix(issues): skip run-confirm when batch-moving issues to backlog

A move into backlog never starts a run (service/issue_trigger.go), so the
pre-trigger confirm modal degenerated to an empty "won't start" box with a
single Apply button — pure friction. Apply directly instead, matching the
single-issue status path. Other target statuses still route through the
modal.

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

* feat(issues): refine pre-trigger preview hint and copy

- Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the
  property toolbar. It was sharing the footer button row and, lacking a
  width constraint, reflowed the submit buttons whenever it appeared.
  Restyle to a borderless, comment-style avatar+caption that is purely a
  caption (non-interactive avatar).
- Distinguish squad from agent in the pre-trigger copy: a squad's leader
  evaluates and delegates rather than "starting work" itself. Add
  will_start_named_squad / will_start_squad / create_will_start_squad across
  en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and
  branch run-confirm + the create hint on squad assignees.
- Bold the assignee name in the run-confirm headline via a language-safe
  sentinel split (no per-language prefix/suffix keys).
- Align zh "开始处理" → "开始工作" on the single-assign copy.

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

* test(issues): stub ActorAvatar in create-issue suite

CreateRunHint now renders an ActorAvatar for agent/squad assignees, which
pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation
hook tree. This form-focused suite only stubbed getActorName, so the
squad-forwarding test crashed with "getActorInitials is not a function". Stub
the avatar inert — its own behavior is covered elsewhere.

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

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 13:17:13 +08:00
honki12345
4679217586 feat(cli): STR-208 오토파일럿 구독자 플래그 추가 (#4438)
* feat(cli): STR-208 오토파일럿 구독자 플래그 추가

* test(core): Issue fixture stage 기본값 추가

* test(views): Issue fixture stage 기본값 추가
2026-06-23 11:56:09 +08:00
Naiyuan Qing
45dae3185f fix(issues): eliminate optimistic-update drag flicker (board, list, batch, WS) (#4415)
* fix(issues): stop kanban card snapping back on drag

A cross-column drag on a non-position-sorted board left the card in its
origin column for the whole request, then jumped to the target only when
the mutation settled — the "snaps back, then moves" glitch. Root cause was
three coupled choices in the optimistic path:

- board-view never updated local columns on drop for sortBy != "position"
  (onDragOver is a no-op there), so the card relied on the settle refetch
  to move across.
- useUpdateIssue invalidated the whole list on settle, replacing the column
  and re-landing the card even on success.
- patchIssueInBuckets appended a moved card to the column tail instead of
  its position slot, so any later cache refresh teleported it to the end.

Fixes:
- board-view: optimistically move the card into the target column on drop
  for the non-position path (insertIdByPosition), and reconcile local
  columns from the cache on settle for both paths (revert on error now that
  the list is no longer refetched).
- mutations: reconcile via onSuccess surgical patch of the returned entity;
  drop the list/detail invalidation from onSettled (aggregates still flush).
- cache-helpers: patchIssueInBuckets inserts the moved/reordered card at its
  position slot; a plain field update still keeps its slot.

Adds cache-helpers and drag-utils unit tests.

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

* fix(issues): patch My-Issues / Project board caches on move too

The drag fix made the board reconcile local columns from its feeding cache
on settle. The workspace board rides issueKeys.list (patched by onMutate),
but the My-Issues and Project boards ride the myList cache, which the
mutation did not patch — so a successful move snapped back on those boards.

useUpdateIssue now patches/snapshots/rolls back every bucketed list cache
(workspace list + myList), selected by the ListIssuesCache `byStatus` shape
so grouped (assignee) and flat (gantt) caches are skipped. Adds renderHook
regression tests covering both-cache optimistic move, both-cache rollback,
and no-list-invalidation-on-settle.

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

* fix(issues): drop redundant WS position->list invalidate

onIssueUpdated already surgically patches the non-filtered workspace board
via patchIssueInBuckets (cross-status move + same-column reorder). The extra
`if (position) invalidateQueries(list)` re-pulled the whole board on top of
that, re-introducing drag flicker through the echoed-back WS event. Removed.
Filtered myAll lists still invalidate (membership can change there) — the
client-side membership reconciliation for those is a separate follow-up.

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

* fix(issues): batch update patches myList + stops list refetch on settle

- onMutate now patches both issueKeys.list and the filtered issueKeys.myAll
  bucketed caches, so a batch edit on a My-Issues / Project board is
  optimistic too. Previously only the workspace board was patched, so batch
  edits on those boards relied entirely on the settle refetch.
- onSettled no longer invalidates issueKeys.list: the optimistic patch is a
  complete reconcile for these bucketed boards (batch changes status /
  priority / project, never a server-computed value), so a full-board
  refetch only re-introduced the flicker the single-issue path removed.
  Aggregate / grouped caches still refresh.

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

* fix(issues): list view optimistically moves row on non-position drag

The sortBy != "position" branch called onMoveIssue without moving the row in
local columns, so the row sat in its origin group for the whole request and
only jumped across on settle -- the same snap-back the board view had before
its fix. Now mirrors board-view: setColumns(insertIdByPosition) on drop so
the settle rebuild is a visual no-op.

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

* fix(issues): keep My-Issues/Project boards in place on non-membership WS change

onIssueUpdated now surgically patches the filtered myList (myAll) caches and
only invalidates them when the change can actually move an issue in/out of the
filter: an assignee change (covers My-Issues direct-assignee + the involves leg
+ actor panels) or a project change (Project board). A pure status / position /
priority / label change reconciles in place -- no refetch -- removing the last
drag flicker on filtered boards.

Uses the assignee_changed flag the server already sends on issue:updated
(surfaced on IssueUpdatedPayload + forwarded by the realtime dispatch); project
change is diffed client-side against the cached value. No predicate replication,
no backend change.

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

* fix(issues): add settle-lock to swimlane drag (no clobber mid-flight)

The swimlane drag had no settle window: the resync useEffect (and the issueMap
freeze) guarded only isDraggingRef, so a cache change landing after drop but
before the move settled could rebuild localCells out from under the optimistic
move. Adds isSettlingRef + settleVersion (mirroring board-view / list-view): the
lock is held from drop until onMoveIssue settles, then released, forcing a
single resync from the reconciled cache.

onMoveIssue now accepts the same optional onSettled callback board/list already
use; the parent handleMoveIssue supplies it.

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

* refactor(issues): extract shared useDragSettle hook for board + list

board-view and list-view carried byte-identical drag/settle scaffolding (the
local columns mirror, the dragging/settling locks, the post-move animation-frame
throttle, and the settle callback). That duplication is exactly what let
list-view silently drift earlier (it had lost the optimistic-move half of the
fix, and its position-branch settle callback omitted the settleVersion bump).
Extract the primitive into useDragSettle so both surfaces share one
implementation and can't drift again.

Behavior-preserving for board-view. For list-view the one intended alignment:
its position-branch failed move now reverts, gaining the settleVersion bump
board-view already had.

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-23 09:20:01 +08:00
Bohan Jiang
48b8dbf439 feat(daemon): surface sub-issue stages in the always-on runtime brief (#4426)
Agents creating sub-issues only saw the runtime brief's Sub-issue
Creation section, which taught the manual todo/backlog serial chain and
never mentioned stages — the `--stage` flow was documented only in the
multica-working-on-issues skill, which an agent reads only if it opens
it. So agents defaulted to hand-managed backlog chains and rarely reached
for stages.

- Add an "Ordering with stages" paragraph to the brief's Sub-issue
  Creation section nudging agents to group ordered/waiting sub-issues
  with --stage instead of hand-promoting a backlog chain.
- List --stage on the brief's issue create / update command lines and
  add multica issue children to the Core command list for discoverability.
- Extend the brief test with the new stage assertions.

The Sub-issue Creation section stays gated to issue-bound runs (skipped
for chat/quick-create/autopilot), unconditional on parent_issue_id, and
free of parent-notification guidance — all existing canaries still pass.

MUL-3508

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 01:08:10 +08:00
Bohan Jiang
3a814bd74a fix(issues): refine Stage field icon and dropdown font (#4425)
Replace the # (Hash) icon for the Stage property with the Milestone icon
across the picker trigger, dropdown option rows, and the Add-property menu.

Shrink the Stage dropdown option font to text-xs (scoped to the Stage
picker only; the shared PickerItem keeps text-sm so other property
dropdowns are unaffected).
2026-06-23 00:41:36 +08:00
Bohan Jiang
8122ee3bcd MUL-3528: clarify repo-scoped skills docs (#4424)
* docs: clarify repo-scoped skills

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

* docs: mark repo-scoped skills as expected

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-23 00:33:41 +08:00
Bohan Jiang
a123dfc2df MUL-3508: stage sub-issues so the parent wakes per stage, not per child (#4410)
* feat(issues): stage sub-issues so the parent wakes per stage, not per child

Sub-issues under a parent can be grouped into ordered stages (issue.stage).
The child-done -> parent notification + assignee wake now fire only when a
stage barrier closes: every sub-issue in the lowest unfinished stage has
reached a terminal status (done/cancelled). An unstaged sibling set is one
implicit stage, so the parent is woken once when the last sub-issue finishes
instead of on every child — the default fix for the fire-on-every-child
cascade reported in discussion #4320 / MUL-3508.

Stage advancement stays agent-driven: the server only detects the closed
barrier and wakes the parent assignee, who decides whether to promote the
next stage.

- DB: nullable issue.stage (CHECK >= 1) + sqlc regen
- API: stage on issue create/update/response and batch update
- CLI: `issue create`/`issue update` --stage; new `issue children` command
  that lists sub-issues grouped by stage (table + json)
- stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the
  wake comment now stage-aware, plus unit tests
- skill docs (multica-working-on-issues SKILL.md + source map)

Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a
follow-up; the API already returns stage for it to consume.

MUL-3508

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

* fix(issues): address review on stage barrier (cancel, batch, unstaged)

Resolves the three blockers from the PR review:

1. Cancel can close a stage. The child-done barrier now fires on any
   non-terminal -> terminal transition (done OR cancelled), not just done.
   isTerminalChildStatus already treats cancelled as terminal (a cancelled
   sibling never finishes, so it must not hold a stage open), so a cancelled
   last-open child now closes its stage and wakes the parent. Keying on the
   transition also makes a later cancelled -> done edit a no-op, avoiding a
   lagging duplicate wake.

2. Batch update of stage no longer no-ops. `hasMutation` now includes
   "stage", so `{"updates":{"stage":N}}` persists instead of returning
   {"updated": 0}.

3. Unstaged children no longer participate in the staged frontier. In a
   staged sibling set, NULL-stage children neither hold a stage open nor fire
   on their own completion, and the wake comment no longer renders "Stage 0".
   This matches migration 123 ("NULL does not participate in staged
   grouping") and the CLI's separate unstaged group, removing the footgun
   where an unstaged backlog child silently blocked Stage 1.

Tests: cancellation closes a stage (staged + unstaged), unstaged ignored in a
staged set, stage summary skips unstaged, and a stage-only batch update
persists.

MUL-3508

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

* feat(web): stage UI — create picker, sidebar edit, group sub-issues by stage

Frontend for the sub-issue stage feature (web + desktop, shared via packages):

- core: `stage` on the Issue type + create/update request types; zod
  IssueSchema parses it (defaults to null for older backends) with schema
  tests for the numeric and omitted cases.
- StagePicker component (mirrors the other property pickers): "No stage" +
  Stage 1..N, offering one beyond the current/sibling max.
- Create-issue modal: a Stage pill, shown only when a parent is selected,
  threaded into the create payload.
- Issue detail sidebar: an editable Stage row + "add property" entry, gated to
  sub-issues (issues with a parent).
- Sub-issue list grouped by stage with per-stage headers (flat when unstaged).
- i18n: stage keys across en / zh-Hans / ja / ko (parity test passes).

Verified: full typecheck (6/6), core (591) + views (1433) vitest suites,
lint clean (no new findings). Backend/CLI shipped earlier in this PR.

MUL-3508

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

* test(issues): add stage to Issue fixtures merged from main

The merge brought in new Issue fixtures that predate the required `stage`
field: core issues/batch.test.ts, views batch-action-toolbar.test.tsx, and
the mobile EMPTY_ISSUE_FALLBACK sentinel. Add `stage: null` so they satisfy
the Issue type (mobile reuses core's IssueSchema for parsing, so only the
sentinel needs it).

MUL-3508

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

* fix(web): feed StagePicker the sibling max stage so higher stages stay selectable

The StagePicker accepts maxStage to extend its option list beyond the
floored Stage 1-3, but neither call site passed it, so a parent with an
existing Stage 4/5 child could not pick that stage when creating a new
sub-issue or editing one in the sidebar.

- Compute the sibling max stage at both call sites: the create modal now
  loads the parent's children (childIssuesOptions) and the detail sidebar
  reuses the already-loaded parentChildIssues.
- Extract maxSiblingStage + stageOptions as pure helpers on stage-picker
  and unit-test them (the regression: a Stage 5 sibling keeps Stage 5
  selectable and offers Stage 6).

MUL-3508

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-23 00:14:42 +08:00
Ivan Fokeev
ca43c83abc MUL-3523: fix(github): route PR/check_suite webhooks by repo
Fix GitHub pull_request and check_suite webhook routing so events are attributed to the workspace that registered the repository, with fallback to the installation workspace. Includes host-qualified repo matching, account-gated registry routing, deterministic matching, and regression coverage.
2026-06-22 23:44:46 +08:00
Bohan Jiang
da72e2fa22 feat(daemon): inject project description into the agent brief (MUL-3465) (#4395)
* feat(daemon): inject project description into the agent brief

Issues bound to a project only surfaced the project title in the runtime
brief; the project description (durable, project-wide context the owner
sets) was loaded but dropped. Carry it end-to-end:

- claim handler reads proj.Description onto the response (issue-bound and
  quick-create paths)
- new ProjectDescription field on AgentTaskResponse, daemon Task, and
  TaskContextForEnv
- rendered in the brief's `## Project Context` section and written to
  .multica/project/resources.json as project_description

Empty descriptions render nothing (no extra heading). Updated the
projects-and-resources built-in skill docs in the same change.

MUL-3465

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

* feat(projects): clarify project description is injected as agent context

The project description is now durable context injected into every task's
brief, but the UI still presented it as a plain "Description" field, so
existing descriptions could silently become agent input. Add a hint under
the description editor on the project detail page and in the create-project
modal, in all four locales, stating it is shared with agents as context for
every task in the project. No data-semantics change.

Addresses review feedback on PR #4395. MUL-3465

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

* test(handler): assert project description flows through task claim

The execenv tests cover brief rendering, but nothing pinned the claim
handler boundary where proj.Description is read onto the response. Add
two tests — issue-bound and quick-create paths — so a regression in that
assignment fails loudly instead of silently dropping the description.

Addresses review feedback on PR #4395. MUL-3465

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-22 23:39:27 +08:00
DylanLi
78342a39ce MUL-3305: feat(agent): add qoder CLI as a choice of agent provider. (#2461)
* feat(agent): Qoder ACP runtime, chat reconnect recovery, and task linkage

- Add Qoder CLI backend (ACP transport, model discovery, blocked-args policy)
- Wire daemon/runtime config, docs, and UI provider assets
- Retry terminal task reports; add backoff unit tests
- Chat: SQL attach user message to task; handler + optimistic cache reconcile
- Invalidate chat/task-messages caches on WS reconnect; extract helper + tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop non-Qoder changes (chat reconnect, task link, terminal report retries)

Keep only Qoder runtime, docs, daemon config/execenv, and UI provider assets.

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent): harden Qoder ACP drain and wire project skills path

- Stop streaming to msgCh after reader wait so grace timeout cannot race close
- Resolve injected skills to .qoder/skills per Qoder CLI discovery
- Update AGENTS.md skill copy and add execenv tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(qoder): add provider logo and wire MCP config into ACP sessions

- Add inline SVG QoderLogo component to provider-logo.tsx, replacing
  the generic Monitor icon placeholder
- Add convertMcpConfigForACP helper to convert Claude-style MCP server
  config (object map) into ACP array format for session/new and
  session/resume
- Add unit tests for convertMcpConfigForACP covering stdio, SSE,
  empty/nil, and multi-server cases

Co-authored-by: Orca <help@stably.ai>

* fix(test): capture both return values from InjectRuntimeConfig in Qoder test

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): preserve remote MCP headers and promote provider errors

Addresses review feedback on #2461 (Bohan-J): two runtime-correctness
issues in the Qoder ACP backend.

1. Remote MCP headers were dropped. The bespoke convertMcpConfigForACP
   only forwarded url/type, so an authenticated remote MCP server looked
   configured in Multica but failed inside the Qoder session. Replace it
   with the shared buildACPMcpServers helper (same path Hermes/Kimi/Kiro
   use), which preserves headers as [{name, value}], sorts for
   deterministic output, and handles remote transport aliases. Fail
   closed on malformed mcp_config instead of silently dropping servers.

2. Provider failures could report as completed tasks. stderr was wired
   via io.MultiWriter and the result was only promoted to failed when
   output was empty, so a terminal upstream error (HTTP 429 / expired
   token) racing a stopReason=end_turn with text still became
   "completed". Switch to StderrPipe + an explicit copier, drain it
   (bounded by the existing grace window, since qodercli can leave a
   child holding the inherited fds) before the decision, and run the
   shared promoteACPResultOnProviderError.

Tests: replace the convertMcpConfigForACP unit tests with two
end-to-end Qoder tests — one asserts the Authorization header reaches
the session/new payload as {name, value}, the other asserts a terminal
stderr error with non-empty output reports failed.

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): align ACP session handling

Co-authored-by: Orca <help@stably.ai>

* fix(agent): guard qoder late output after drain

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

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 18:55:45 +08:00
Naiyuan Qing
149cc9bd0a fix(issues): reflect real common value in batch toolbar pickers (#4403)
The batch action toolbar hardcoded status="todo", priority="none", and a
null assignee, so the status/priority/assignee pickers always checked a
fixed row regardless of the selected issues. The batch write itself worked,
but the picker mis-reported the current value, surfacing as "status always
defaults to todo" (MUL-3510). The same defect applied to priority and
assignee, across all five toolbar mount points.

Derive the shared status/priority/assignee of the selected issues via a new
commonIssueFields helper and feed it to the pickers; when the selection is
mixed, pass an empty value so no row is checked. Pickers now accept a
nullable current value, and AssigneePicker gains a `mixed` flag to
distinguish an all-unassigned selection (check "No assignee") from a mixed
one (check nothing). Each call site passes its issue universe, mirroring the
skill list's selected-rows approach.

Adds unit tests for commonIssueFields and a toolbar picker-wiring test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 18:23:05 +08:00
Bohan Jiang
637b6ee433 feat: add CLI comment resolve commands (#4404)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 18:16:38 +08:00
Multica Eve
b5a180b21e docs: update June 22 changelog (#4406)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 17:30:31 +08:00
YYClaw
329384f052 chore(makefile): expand clean target to remove build caches (#4394)
Extend the clean target beyond server binaries/temp files to also
remove Next.js/source/Expo/Electron build outputs, Turbo caches, and
tsbuildinfo files across apps and packages.
2026-06-22 17:20:42 +08:00
Jiayuan Zhang
916cee5c5d feat(issues): open agent activity chip on hover (#4405)
The header 'agent is working' chip previously required a click to reveal the
activity card. Open it on hover instead so the live signal reads as a
glanceable status surface. The hover config lives on Base UI's Popover.Trigger
(openOnHover + delay/closeDelay), and the trigger stays a real button so
click/keyboard access is retained for touch and a11y.

Add a regression test asserting openOnHover is wired on the trigger so a
click-only implementation can no longer pass.

MUL-3507

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 17:06:15 +08:00
Naiyuan Qing
4fe8b54e9b MUL-3446: keep chat output in chat (#4387)
* MUL-3446: keep chat output in chat

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

* MUL-3446: simplify chat output guidance

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 15:51:03 +08:00
BeliyDym
5fd3d01d13 MUL-3502: OST-1161: Bound assignment comment catch-up
Squashed PR #4392. Updates assignment/comment catch-up guidance to use recent 10 and aligns related examples.
2026-06-22 15:46:47 +08:00
Jiayuan Zhang
cf30991f91 feat(sidebar): add dismissible Join Discord card (#4400)
Add a Join Discord promo card pinned to the bottom of the left sidebar
(above the help launcher). Dismiss state persists per-user in
localStorage so it stays hidden once closed.

Extract the shared DiscordIcon + invite URL into layout/discord.tsx so
the help launcher and the card reuse one source. i18n copy added for
en / zh-Hans / ja / ko.

MUL-3505

Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 09:35:41 +02:00
YOMXXX
9d053c57f9 MUL-3420: fix(runtimes): clarify custom runtime deletion 2026-06-22 15:01:22 +08:00
Bohan Jiang
8a9f15dbc9 feat: add Discord community entry points (#4388)
Add a Discord invite (https://discord.gg/W8gYBn226t) in three places:
- Website footer: social icon + link in the Resources group (en/zh/ja/ko)
- In-app help menu: Discord item in the help launcher (all 4 locales)
- GitHub repo README: badge + link (README.md and README.zh-CN.md)

MUL-3492

Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 14:00:36 +08:00
Bohan Jiang
5556f4570b fix(issue): skip child-done notification when parent is in backlog (#4391)
When a sub-issue transitions to done, notifyParentOfChildDone posts a
system comment on the parent and wakes the parent's assignee. A parent
deliberately parked in backlog should stay inert: waking it lets the
assignee promote sibling backlog sub-issues into todo, which is the
unwanted auto-activation reported in GitHub Discussion #4320 (MUL-3497).

Add a backlog guard alongside the existing done/cancelled guard so the
whole notification (comment + mention + trigger) is skipped until the
user explicitly moves the parent out of backlog.

MUL-3497

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 13:56:48 +08:00
Bohan Jiang
b13e1808a4 refactor(codex): make permission approval auto-grant observable (#4390)
The daemon auto-grants Codex item/permissions/requestApproval requests by
echoing back the network / fileSystem profile scoped to the current turn.
Previously a malformed params payload and any permission key outside
network / fileSystem were dropped silently, so a future app-server
protocol that adds a new permission shape would be narrowed away with no
trace in daemon logs.

Log both cases (parse failure and dropped keys) without changing the
granted response. Addresses review nits on #4346 / MUL-3451.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 13:43:42 +08:00
lethean-kun
0aa3b53c25 MUL-3378 feat(lark): reply inside the originating thread (话题) instead of the group (#4262)
* feat(lark): reply inside the originating thread (话题) instead of the group

When a user @-mentions the bot inside a Lark topic/thread, the bot now
replies back into that thread rather than posting a fresh message at the
chat level. Behavior is automatic and scoped: only triggers that were
themselves inside a thread get a threaded reply, so normal group/p2p
chats are unchanged.

The outbound path is event-driven and decoupled from the inbound
message, so the trigger message_id + thread_id are persisted on
lark_chat_session_binding (migration 122) at ingest time. The patcher
then routes the agent reply (text / markdown card / error card) and the
OutcomeReplier notices (/issue confirmation, offline/archived) through
Lark's reply endpoint with reply_in_thread=true when a thread is present,
falling back to a chat-level send if the threaded reply fails.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lark): classify thread-reply failures before chat-level fallback

Only retry a threaded reply at the chat level when Lark returns an
explicit "this message/topic cannot receive a threaded reply" error
(recalled trigger, topic gone, topics disabled, aggregated message,
etc.). Transport errors, 5xx, timeouts, rate limits, and ambiguous
failures are now logged and returned as failures instead of being
retried, so we never duplicate a reply or leak a thread-only reply
into the main group chat.

The three reply-capable send methods now return a structured *APIError
carrying the Lark business code, and isThreadReplyUnsupported drives
the fallback via an allowlist. sendWithThreadFallback is promoted to a
package-level function so the immediate OutcomeReplier sends (/issue
confirmation, offline/archived notices) share the same classified
fallback path instead of silently swallowing thread-reply failures.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: kun <kuen@micous.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 13:34:40 +08:00
Bohan Jiang
39ab355585 fix(skills): authenticate raw.githubusercontent.com downloads for private repo imports (#4389)
Skill import builds raw.githubusercontent.com URLs by hand and fetches them
via fetchRawFile, which sent no Authorization header. GitHub API calls were
authenticated by #2215 (doGitHubAPIGet/addGitHubAuthHeader) but the raw
content download path was missed, so importing a skill from a private/internal
GitHub repo listed the directory fine and then 404'd on the actual file
download, surfacing as a generic 502.

Attach the existing GITHUB_TOKEN bearer header in fetchRawFile, but only when
the URL host is raw.githubusercontent.com. fetchRawFile is shared with
clawhub.ai / skills.sh downloads, so the token must not leak to those hosts.
The host gate is extracted into newRawFileRequest so it is unit-testable
without a live round-trip.

MUL-3496

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 13:34:06 +08:00
Bohan Jiang
81bde585ba MUL-3467: batch load squad roster skills (#4386)
* MUL-3467: batch load squad roster skills

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

* test: isolate redis-backed test databases

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-22 13:14:22 +08:00
hal9000botagent
91e6c779d6 feat(squad): surface member skills in leader briefing roster (#4363)
When an issue is assigned to a squad, only the leader is triggered. The
leader briefing's Squad Roster listed each member's name, type, role, and
mention link — but not the member's assigned skills, so the leader had to
infer capability from the free-text role label when deciding who to
delegate to.

renderMemberRow now loads each agent member's assigned skills via
ListAgentSkillSummaries and formatRosterRow renders them as
"skills: a, b" (or "no skills assigned" when the agent has none). Builtin
multica-* skills are excluded (they live outside agent_skill); human
members carry no skills segment; a skill-lookup error degrades to the
prior name+role row rather than asserting a misleading "no skills".
Operating-protocol step 1 now tells the leader to match the task to each
member's listed skills.

Updates the multica-squads builtin skill and its source map to document
the new roster content, and adds
TestBuildSquadLeaderBriefing_MemberSkillsInRoster.

Co-authored-by: hal9000botagent <hal9000botagent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:33:12 +08:00
Stiliyan Monev
9d7060caf1 fix(auth): autofocus OTP input on verification step (#4344)
* fix(auth): autofocus OTP input on verification step

The email-verification step renders the OTP input without focus, so
users must click the field before typing the code. This is friction on
every login, especially when switching accounts.

Add `autoFocus` to the InputOTP so the cursor lands in the field as
soon as the step mounts. Mirrors the existing email-step input and the
mobile OTP component, both of which already autofocus.

* test(web): polyfill document.elementFromPoint for input-otp in jsdom

Autofocusing the OTP input makes input-otp run its focus-time DOM
measurement, which calls document.elementFromPoint. jsdom doesn't
implement it, so the web login test threw an unhandled error.

packages/views/test/setup.ts already stubs this for the same reason;
mirror the stub in the web test setup (which already stubs
ResizeObserver for input-otp).

* test(auth): assert OTP input autofocuses on verification step

Guards the autofocus behavior: the test fails if the autoFocus prop is
removed from the verification-step InputOTP. Lives in packages/views
since it covers shared component behavior, mocking @multica/core.
2026-06-22 10:00:57 +08:00
Naiyuan Qing
6d0e875dbb feat: add opt-in react-grab dev element inspector (web + desktop) (#4381)
* feat(web): add opt-in react-grab dev element inspector

Loads the react-grab overlay (hold ⌘C / Ctrl+C + click to copy an
element's source path + component stack) only when REACT_GRAB is set in
a local, gitignored apps/web/.env.local. Both the NODE_ENV and REACT_GRAB
guards are evaluated server-side in the root layout, so the <Script> tag
is omitted from the HTML for anyone who hasn't opted in — no effect on
other developers or production.

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

* feat(desktop): add opt-in react-grab dev element inspector

Mirrors the web wiring for the Electron renderer: injects the react-grab
overlay (hold ⌘C / Ctrl+C + click to copy an element's source path +
component stack) only when VITE_REACT_GRAB is set in a local, gitignored
apps/desktop/.env.development.local. Guarded by import.meta.env.DEV so the
branch is tree-shaken out of production builds; never activates for other
developers. No CSP/sandbox blocks the unpkg script (webSecurity is off).

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

* refactor(web): unify react-grab opt-in var to VITE_REACT_GRAB

Use the same env var name as the desktop renderer so one variable name
controls both apps. The desktop renderer is bundled by Vite, which only
exposes VITE_-prefixed vars to client code, so the shared name must carry
the VITE_ prefix; web reads it server-side where the name is unconstrained.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:49:24 +08:00
YOMXXX
737c976b0d fix(cli): guide remote setup callbacks (#4360)
## Summary
- expose `--callback-host` on `multica setup` and `multica setup cloud`, reusing the existing login callback override
- print an SSH tunnel hint when browser login runs in an SSH session with a loopback callback
- show local flags in parent-command help so `multica setup --help` documents the callback option

Fixes #4357

## Tests
- `go test ./cmd/multica -run 'TestCallbackHostFlagValueReadsParentSetupFlag|TestSetupHelpShowsCallbackHostFlag|TestSetupCallbackHostFlagWiring|TestBrowserLoginInstructionsSSHRemoteHint'`
- `go run ./cmd/multica setup --help`
- `go run ./cmd/multica setup cloud --help`
- `git diff --check`
- `go test ./...`
2026-06-22 00:06:14 +08:00
beast
31d942d010 MUL-3438: fix(projects): require admin for project deletion (#4327)
* fix(projects): require admin for project deletion

* test(projects): clean up orphaned member rows in delete-permission helper

The schema uses no foreign keys or cascades, so deleting the test user
left its member row behind in the shared test workspace, polluting later
tests in the package. Delete the member row before the user in both the
pre-seed cleanup and t.Cleanup.

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-21 23:54:58 +08:00
Hzzzzzx
745832b536 MUL-3433: fix(daemon): restore claim slow-log payload observability without gzip
Squash merge PR #4322 after review approval.\n\nMUL-3433
2026-06-21 23:41:05 +08:00
Multica Eve
140a113c38 docs: add 2026-06-19 changelog entry (#4340)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-21 23:28:15 +08:00
YOMXXX
4bbaf5363c fix(codex): handle app-server permission requests (#4346) 2026-06-20 13:42:27 +08:00
Jiayuan Zhang
c0c41fa0b4 fix(views): gate right sidebar motion to toggles (#4335) 2026-06-19 16:40:49 +08:00
Jiayuan Zhang
06ae9b2a0c fix sidebar issue pin labels (#4334) 2026-06-19 16:23:10 +08:00
Jiayuan Zhang
49e1c2a8af docs: simplify agent guidance (#4333) 2026-06-19 16:22:16 +08:00
Jiayuan Zhang
27fcbb015f Polish desktop sidebar motion
Polish desktop chrome/sidebar alignment and add motion-based transitions for left and right sidebars.
2026-06-19 06:26:14 +02:00
Jiayuan Zhang
ba7be23f7c fix pinned sidebar active state
Fix sidebar active state so exact pinned routes do not also highlight their parent Workspace nav item.
2026-06-19 06:25:58 +02:00
LinYushen
e1c6754304 Revert "fix: gzip daemon claim responses (#4259)" (#4307)
This reverts commit 2f398c36ad.
2026-06-18 17:58:43 +08:00
Multica Eve
990e7a6d74 MUL-3421: add 2026-06-18 changelog entry (#4305)
* docs: add 2026-06-18 changelog entry

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

* docs: clarify 0.3.25 changelog title

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-06-18 17:27:14 +08:00
Naiyuan Qing
9eaea31892 fix(realtime): guard WS frames without a string type + drop benign ResizeObserver noise (MUL-3418) (#4304)
* fix(realtime): drop WS frames without a string type (MUL-3418)

The onmessage handler dispatched every parsed frame to onAny and the
ws.on subscribers without validating its shape. A frame whose parsed JSON
lacked a string `type` (an out-of-protocol frame injected by a proxy /
extension, or a bare JSON primitive) reached the realtime-sync onAny
dispatcher, where `msg.type.split(":")` threw an uncaught TypeError out of
onmessage. The browser reported it via window.onerror, flooding PostHog
`$exception` (~12k events). Non-fatal (no crash, connection stays up), but
pure noise.

Validate once at this trust boundary: a frame must be an object carrying a
string `type`, otherwise drop it as a no-op. The bad-frame log is
rate-limited to one entry per connection — a misbehaving source can repeat
the frame hundreds of times per session.

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

* feat(analytics): drop benign ResizeObserver exceptions from telemetry

ResizeObserver "loop ..." errors are the dominant `$exception` bucket
(~31k events / ~1.5k users). They are a benign, self-recovering browser
quirk with no actionable signal and otherwise drown real failures and burn
the event budget. Drop them entirely in before_send (ahead of redaction
and the dedupe fuse, which only caps repeats). The match is narrow — only
the benign "loop" phrasing — so a genuine ResizeObserver bug still reports.

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-18 17:24:48 +08:00
Multica Eve
8b3b054d17 test(agent): cover Hermes custom model IDs with colons (#4300)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 15:51:13 +08:00
Hzzzzzx
b4c9e4423c test: enable -race detector in Go test pipeline (WOR-61) (#4274)
* test: enable -race detector in Go test pipeline (WOR-61)

Add the -race flag to all three Go test invocation sites so the existing
concurrency regression harness (workdir_race_test.go for #3999,
runtime_gone_test.go, runtime_profile_drift_test.go) actually exercises
the race detector. The daemon package alone has 28+ goroutine launch
points with no automated race coverage before this change.

Sites updated:
- Makefile:299        (make test, local)
- .github/workflows/ci.yml:101      (CI backend job)
- .github/workflows/release.yml:55  (release verify job)

go test already runs a vet subset by default, so no separate -vet flag
is added. No production code touched.

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

* test(execenv): serialize runtimeGOOS-mutating test (WOR-61)

TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged called
t.Parallel() while mutating the package-level runtimeGOOS to drive the
windows/linux branches, racing with the other parallel tests that read
runtimeGOOS in buildMetaSkillContent. The -race flag enabled in the
prior commit surfaced it as 3 WARNING: DATA RACE reports and 11
"race detected" failures in CI (only the execenv package failed).

Drop t.Parallel() and add the "// Not parallel: mutates the package-level
runtimeGOOS." comment already used by the six sibling writer tests across
execenv_test.go and reply_instructions_test.go. This is test-isolation
only; no production code, no mutex/atomic, no signature change.

Verified locally:
  go test -race -count=1 ./internal/daemon/execenv/   -> ok 2.276s
  go test -race -count=1 ./internal/daemon/...        -> all 3 pkgs ok

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

---------

Co-authored-by: hzz <331380069@qq.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 15:50:24 +08:00
LinYushen
0d0edac32f feat(daemon): discover local skills from ~/.agents/skills (MUL-3333) (#4244)
* feat(daemon): discover local skills from ~/.agents/skills (MUL-3333)

Upgrade local skill discovery and import from a single provider root to an
ordered multi-root scan: the runtime's own skill directory (e.g.
~/.claude/skills) first, then the cross-tool universal root ~/.agents/skills.

- Rename localSkillRootForProvider -> localSkillRootsForProvider, returning
  ordered roots [provider, universal] with a kind classifier.
- listRuntimeLocalSkills iterates the roots, gives each root its OWN visited
  set (so a cross-root symlink alias is not collapsed), dedupes strictly by
  Key with the provider root winning, and sorts once after the merge.
- loadRuntimeLocalSkillBundle walks the same priority order and only falls
  through to the next root on os.IsNotExist; any other stat error is returned
  so import never silently resolves a different same-key skill.
- Add a Root ("provider" | "universal") field to the local skill summary
  (daemon + handler structs and the TS RuntimeLocalSkillSummary type) so the
  UI can label a skill's origin without a future schema break.

Backward compatible: every skill visible today keeps its Key, SourcePath and
FileCount; the universal root only surfaces additional, non-conflicting skills.

Out of scope (follow-up issues): execution-time injection of ~/.agents/skills
into runtimes (e.g. Codex seedUserCodexSkills) and workspace-relative
.agents/skills discovery.

Tests cover universal-root discovery + import, provider-wins conflict
priority, both-roots merge, missing/both-missing roots, nested layouts,
IsNotExist fallback, the no-fallthrough-on-read-error guarantee, and the
per-root visited cross-root symlink alias. Docs updated in en/zh/ja/ko.

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

* fix(daemon): fall through to next root when a same-key dir has no SKILL.md

loadRuntimeLocalSkillBundle previously only fell through to the next root on
os.IsNotExist for the skill DIRECTORY. A provider-root directory that shares a
skill's key but contains no SKILL.md (so listRuntimeLocalSkills descends past
it and surfaces the universal-root skill instead) made load stop on the
invalid provider dir and error — list and load disagreed, and the import the
user picked from the list could not be fetched.

Make the validity predicate match list: a root "has" the skill at a key only
when it is a directory containing a SKILL.md. A missing entry, a non-directory,
or a directory without a SKILL.md all mean "this root doesn't have it" and we
continue to the next root. Only a genuine non-IsNotExist stat error or an
unreadable existing SKILL.md (permission/IO) is returned, so we still never
silently substitute a different-content same-key skill from a lower-priority
root (Eve review #1, preserved by the existing read-error guard test).

Adds regression tests for the provider-dir-without-SKILL.md and provider-non-dir
fall-through cases.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 15:24:41 +08:00
Naiyuan Qing
e4b53eb5c0 fix(editor): stop chat @mention groups from overlapping (#4297)
In context mode (chat) a query merges context items (current page / recently
viewed) with search results into one popup. The old contextLayout made only the
"Recent" group scrollable (`min-h-0`) while every other group was `shrink-0`, and
the Recent `<section>` did not clip its own overflow. When the search groups
(Users/Issues) filled the height, the flex algorithm squeezed Recent toward zero
and its un-clipped rows painted on top of the groups below — the overlap users saw.

Collapse the two render branches into a single `overflow-y-auto` flex column so
every group just stacks and the whole popup scrolls; no group can collapse onto
another. The context variant only differs in width / max-height / chrome.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:58:00 +08:00
Bing2030
dd9e7bf19d fix(desktop): coerce commit-hash versions to valid semver (MUL-3314) (#4183)
* fix(desktop): coerce commit-hash versions to valid semver

normalizeGitVersion checked only the first character (/^\d/) to tell a real
version from a bare commit hash. A hash beginning with a digit (e.g.
'2f24057b') passed that check and was stamped as the app version, but bare
'2f24057b' is not valid semver, so electron-updater threw on launch.

Require a full major.minor.patch prefix; anything else (including a
digit-leading hash) falls back to 0.0.0-<hash> as before.

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

* fix(desktop): prefix bare-hash fallback with `g` for valid semver

normalizeGitVersion coerced an untagged build's bare commit hash into
`0.0.0-<hash>`, but that is not guaranteed valid semver: an all-digit
short hash with a leading zero (e.g. `0123456`) produced `0.0.0-0123456`,
and a numeric semver pre-release identifier must not have a leading zero.
electron-updater then threw on launch for exactly the untagged builds
this fallback exists to protect.

Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
shorthand) so the pre-release is always a single alphanumeric identifier.
Add a regression test for the all-digit leading-zero case.

Addresses PR #4183 review.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:17:50 +08:00
Naiyuan Qing
4c7fefd143 fix(editor): cap issue mention chip at container width, no clickable gap (#4295)
#4288 swapped the chip cap from a fixed `max-w-72` to `max-w-[min(18rem,100%)]`.
A percentage max-width on a flex item is dropped while its flex-container wrapper
(`<a class="inline-flex">`) computes its own max-content size, so the wrapper
ballooned to the untruncated title width while the chip truncated to the cap —
leaving an empty, clickable strip after the visible chip.

Fix it as standard atomic-inline behavior instead of a fixed magic cap:
- IssueChip caps at `max-w-full` with the title truncating to an ellipsis, so it
  wraps to the next line as a unit and only truncates once a whole line can't
  hold it.
- Drop `inline-flex` from the editor NodeView `<a>` (issue + project) and the
  markdown AppLink so the chip's only wrapper is a plain inline box with a
  definite (line-based) percentage basis — no second flex container to balloon.

ProjectChip uses a definite `max-w-72`, so it never hit the gap; left as-is.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:11:07 +08:00
Bohan Jiang
2d1313c5a4 MUL-3264: add Codex usage cache backfill
Adds a dry-run-first operator command for historical Codex usage cache correction, packages it in the backend image, documents the operator-job flow, and covers execution with DB-backed tests.
2026-06-18 13:34:10 +08:00
Bohan Jiang
e3a829f05e feat(daemon): disk-usage cross-root aggregation + migration FK/readiness fixes (MUL-3404) (#4290)
Bundles the MUL-3404 disk-usage feature with the two Preflight BLOCK fixes.

- feat(daemon): `disk-usage --all-profiles` aggregates across every workspace
  root (default + each ~/.multica/profiles/* root, incl. the Desktop app's),
  with a per-root breakdown and combined grand total; the cross-root hint now
  also fires when the current root is non-empty.
- fix(db): drop DB-level foreign keys/cascades from the new autopilot_subscriber
  and comment.source_task_id migrations (resolved in the app layer — autopilot
  delete now removes subscribers in a transaction); the autopilot_subscriber
  down-migration relabels reason='autopilot' to 'manual' instead of deleting.
- fix(server): readiness verifies every required migration is applied, not just
  the lexically-last one, so an out-of-order migration can't be masked.

MUL-3404.
2026-06-18 13:33:14 +08:00
Bohan Jiang
02c0298c18 MUL-3405: constrain issue mention chips in chat (#4288)
* fix: constrain issue mention chips

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

* docs: update issue chip width comment

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-18 11:26:05 +08:00
Bohan Jiang
e7daf876bd fix(daemon): reclaim autopilot_run workdir on terminal status (MUL-3403) (#4287)
* fix(daemon): reclaim autopilot_run workdir on terminal status (MUL-3403)

Autopilot run workdirs are never reused — there is no PriorWorkDir path
that hands a later run the same directory, so every run gets a fresh one.
Yet GC waited the full GCTTL (default 24h) before reclaiming a terminal
run's dir. Combined with one fresh dir per run, high-frequency autopilots
piled up hundreds of stale dirs (508 dirs / 22GB in the field report).

Drop the TTL gate so a terminal run (completed/failed/skipped/
issue_created) is reclaimed immediately, mirroring gcDecisionQuickCreate.
Existing safety constraints are untouched: active-env-root short-circuit,
404 -> orphanByMTime, non-404 error -> skip, and the local_directory
override all still apply.

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

* docs(daemon): fix GetAutopilotRunGCCheck comment — completed_at is not a TTL anchor

The endpoint comment still claimed the daemon uses completed_at as the TTL
anchor for terminal runs. GC now decides purely on terminal status (the
workdir is never reused, so a terminal run is reclaimed on sight);
completed_at is returned for the API contract / diagnostics only. Addresses
the review nit on #4287.

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-18 11:17:59 +08:00
Bohan Jiang
6f29a4c0a6 fix(workspace): derive workspace URL prefix from deployment host (MUL-3400) (#4286)
The create-workspace and onboarding UI hardcoded `multica.ai/` as the
workspace slug URL prefix, so self-hosted deployments showed the wrong
domain. Add a `workspaceUrlHost` helper that derives the host from the
deployment's app URL (`daemon_app_url` from `/api/config`, via the config
store) and falls back to the brand host when none is configured, then use
it in both views. Fixes #4263.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 11:12:10 +08:00
YYClaw
da4f278330 fix(usage): disambiguate model pricing by provider (MUL-3346)
Disambiguate client-side model pricing by provider: generic ids (e.g. `auto`) resolve ${provider}/${model} first, so they only price under their real provider instead of borrowing Cursor's rate. Provider is LOWER()-normalized on read and write so mixed-case historical rows merge.

Closes #4199. MUL-3346
2026-06-18 11:10:06 +08:00
Bohan Jiang
1279f22d1c MUL-3325: add background task safety brief (#4257)
* fix(daemon): add background task safety brief

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

* fix(agent): force Claude background tools foreground

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

* fix(agent): narrow Claude async launch detection

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-18 10:45:51 +08:00
Hzzzzzx
e9e97ee6d2 refactor(editor): dedupe upload-node scan and markdown normalization (#4267)
Extract three module-local helpers in content-editor.tsx and route the
duplicated call sites through them — no behavior change:

- normalizeMarkdown(md) / normalizeEditorMarkdown(editor): the single
  definition of the "strip blob URLs + trimEnd" canonical form, replacing
  the five editor-markdown sites and the one incoming-string site.
- hasUploadingNode(editor): replaces the two byte-identical document scans
  (content-sync Guard 0 and hasActiveUploads).

The imperative getMarkdown() is deliberately left untrimmed; a safety-net
test pins its exact current return value.

Scope: WOR-59 upload-readiness review. file-upload.ts untouched; follow-up
items B1/F4/F5 are out of scope.

Co-authored-by: hzz <331380069@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 10:43:26 +08:00
Naiyuan Qing
d74a0cb34c fix(runtimes): give Tencent CodeBuddy its own provider logo (MUL-3394) (#4279)
The `codebuddy` case in ProviderLogo was aliased to ClaudeLogo when the
backend was integrated (#3186), so CodeBuddy runtimes rendered the Claude
mark and were visually indistinguishable from Claude in the runtime list,
runtime detail, agent runtime picker, and onboarding.

Add a dedicated CodeBuddyLogo using the official mark shipped in Tencent's
own @tencent-ai/codebuddy-code CLI package (dist/web-ui/logo.svg) — the
same CLI this runtime spawns. The artwork overflows the 24×24 box and is
cropped by a clipPath whose id is per-instance (useId), mirroring KiroLogo,
so multiple logos on one page don't collide on a shared id.

Presentation-only: no provider string, backend/API, DB, daemon/runtime,
CLI, mobile, or copy changes. Shared views package, so web + desktop both
pick it up.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 10:19:28 +08:00
Naiyuan Qing
b7857a6aa3 feat(chat): workspace-scoped attachment binding + fire-and-forget send (#4249)
* feat(chat): workspace-scoped attachment binding + fire-and-forget send

Uploads are now workspace-scoped: the chat session is created and
attachments are bound to the message at send time, so a paste/drop no
longer creates an empty session the user never sends.

- LinkAttachmentsToChatMessage returns the ids it actually bound; the
  client diffs requested-vs-bound and warns on partial bind, replacing
  an extra listChatMessagesPage fetch.
- Cancelling an empty chat task detaches attachments before deleting the
  user message (attachment FK is ON DELETE CASCADE) and returns them via
  cancelled_chat_message.attachments, so a restored draft can re-bind.
- SendChatMessageResponse.attachment_ids has no omitempty: "requested but
  bound zero" serializes [] so the client can tell it apart from an older
  server and still warn.
- Send is fire-and-forget: it no longer steals focus when the user has
  navigated to another session (guarded on the live store + new-chat agent
  id); the reply surfaces via the unread dot. commitInput gets clearEditor
  so a navigated-away commit doesn't wipe the editor now showing another
  session, while still clearing the sent draft's data.
- Draft restore is session-aware so a failed fire-and-forget send restores
  into the session it was sent from, never the one the user moved to.
- Removed the now-unreferenced migrateInputDraft store action.

Verified: core/views typecheck, chat-input (15) / store (3) / api client
(24) unit tests, go build + vet, handler SendChatMessage + CancelTaskByUser
DB tests. Full make check / E2E left to CI.

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

* test(chat): guard attachment survival on empty-chat cancel

Cancelling an empty chat task deletes the user message, and
attachment.chat_message_id is ON DELETE CASCADE (migration 083), so the
detach-before-delete in finalizeCancelledChatMessage is the only thing
keeping the user's attachment from being silently destroyed. Nothing
covered it.

Add a DB regression test that binds an attachment to the cancelled user
message and asserts: the row survives the cascade (chat_message_id NULL,
chat_session_id retained), the cancel response returns it via
cancelled_chat_message.attachments, and a resend re-binds it to the new
message. Verified red when the detach step is removed.

Related issue: MUL-3364

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

* fix(comment): pessimistic submit for comment/reply composers

The comment and reply composers cleared the editor after `await onSubmit`
returned, with no in-flight lock. On a slow send the WS `comment:created`
event already dropped the real comment into the timeline while the box
still held the same text + spinner, so it read as two comments. And
because `submitComment`/`submitReply` swallow errors (toast, no rethrow),
a failed send still reached `clearContent` and silently discarded the
user's draft.

Recover the comment/reply portion of the closed #4236: make the submit
callback resolve a success boolean (true on success, false on the caught
failure), lock the editor while in flight (pointer-events-none + dimmed
wrapper + aria-busy, since ContentEditor can't toggle Tiptap `editable`
post-mount), keep the button spinning, and clear only on success — a
failed send keeps the draft. Chat composer is out of scope (already
reworked on this branch); attachment binding is untouched.

Adds two view tests (in-flight lock then clear-on-success; failed send
keeps the draft); both verified red against the un-fixed code.

Related issue: MUL-3364

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 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 09:40:38 +08:00
Jiayuan Zhang
63b9b10df5 MUL-3328: add retry button for failed agent comments (#4217)
* MUL-3328: add retry button for failed agent comments

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

* MUL-3328: cover child-done system comments

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

* MUL-3328: restrict retry affordance to failures

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

* MUL-3328: clean migration whitespace

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:18:44 +02:00
Jiayuan Zhang
2d71872daa feat(autopilot): default subscriber template (MUL-2533) (#3060)
* feat(autopilot): default subscriber template (MUL-2533) — server

Add per-autopilot member subscriber template that fans out to every
issue the autopilot spawns. New autopilot_subscriber table; extend
issue_subscriber.reason with 'autopilot' so the dispatch-time fanout
is distinguishable from manual subscriptions.

API: POST/PATCH /api/autopilots accept a `subscribers` array (member
user_type only for the first version); PATCH semantics are full-replace.
GET returns subscribers on the detail endpoint; the list endpoint omits
them to avoid an N+1.

Dispatch: dispatchCreateIssue lists the template inside the same tx as
the issue insert and writes the rows with reason='autopilot' before
EventIssueCreated fires, so notification listeners see the full
subscriber set on the first event.

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

* feat(autopilot): default subscriber template (MUL-2533) — frontend

New SubscriberMultiSelect picker (members-only search + chips) wired
into the create / edit AutopilotDialog. The detail page renders the
saved template as read-only chips; edits flow through the dialog.

TS types expose the new `subscribers` field on Autopilot, plus an
AutopilotSubscriberInput shape for the create/update wire payloads.

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

* fix(autopilot): notify template subscribers on issue creation (MUL-2533)

The autopilot create-issue path fans out template subscribers into
issue_subscriber inside the same tx as the issue insert, but the
issue:created notification listener only matches handler.IssueResponse
payloads and only direct-notifies the assignee + @mentions. The autopilot
publishes a map[string]any payload, so the listener falls through and the
template subscribers never receive an inbox item for the creation event —
breaking OQ3 ("reason='autopilot' subscribers receive all subscription
events, consistent with reason='manual'").

Fix it where the divergence lives: in dispatchCreateIssue, right after
EventIssueCreated fires, write an inbox_item (type='issue_subscribed',
severity='info') for each member subscriber and publish EventInboxNew so
the recipient's inbox WS feed updates in real time. The write is after
the tx commit so an inbox hiccup can't roll back the issue; failures are
logged, not propagated. The manual path is unchanged — manual subscribers
don't exist at creation time, so there is nothing to notify there.

Adds a new InboxItemType 'issue_subscribed' (en/zh labels) and two
covering tests in autopilot_subscriber_test.go: one asserts the inbox
row lands for a template subscriber on dispatch, the other asserts the
no-subscriber autopilot stays silent.

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

* fix(autopilot): align subscriber PR with current main

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

* fix autopilot subscriber template transaction

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:18:06 +02:00
Bohan Jiang
2f398c36ad fix: gzip daemon claim responses (#4259)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 18:48:33 +08:00
765 changed files with 64361 additions and 12250 deletions

View File

@@ -71,6 +71,28 @@ MULTICA_CODEX_MODEL=
MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Feature flags
# Optional path to a YAML file declaring feature flag rules. When unset,
# every flag falls through to the caller's default, which lets the server
# boot before any flag config is authored. When set, the file is read once
# at startup and a parse / IO error fails fast — same loud-failure shape as
# DATABASE_URL or JWT_SECRET misconfig. See docs/feature-flags.md for the
# full schema; the minimum example is:
#
# billing_new_invoice_email:
# default: true
# checkout_algo:
# default: false
# variant: experiment-v2
# percent: { percent: 25, by: user_id }
#
# Individual flags can also be overridden without touching the YAML by
# setting FF_<FLAG_KEY> env vars (FF_BILLING_NEW_INVOICE_EMAIL=false, 25%,
# or any variant string). The env override beats the YAML, which is the
# Ops kill-switch path — flip a flag without redeploying by restarting the
# process with the env var set.
MULTICA_FEATURE_FLAGS_FILE=
# Self-host image channel
# Default stable release channel. Pin to an exact release like v0.2.4 if you
# want to stay on a specific version. If the selected tag has not been
@@ -233,6 +255,10 @@ MULTICA_LARK_SECRET_KEY=
# clear these afterwards. See docs/lark-bot-integration.
MULTICA_LARK_HTTP_BASE_URL=
MULTICA_LARK_CALLBACK_BASE_URL=
# Optional fixed HTTP CONNECT proxy URL for Lark/Feishu WebSocket long-conn
# handshakes. Leave empty to use standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY
# environment handling.
MULTICA_LARK_WS_PROXY_URL=
# Frontend
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.

View File

@@ -11,28 +11,99 @@ concurrency:
cancel-in-progress: true
jobs:
frontend:
# Decides whether the (heavy, ~6min) frontend job has anything to do.
# The frontend job validates the web/desktop apps, the shared packages,
# the install graph, and the selfhost / reserved-slugs scripts it runs;
# a pure backend-only or docs-only PR touches none of those and gains
# nothing from a full web build. This job emits a single `frontend`
# output consumed by the frontend job below.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
frontend: ${{ steps.decide.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Filter paths
id: filter
uses: dorny/paths-filter@v3
with:
# apps/docs is excluded from the frontend turbo run, so a
# docs-only change does not need this job. apps/mobile has its
# own mobile-verify workflow. Everything else the frontend job
# touches is listed here; bias toward over-matching since a
# missed path silently skips validation.
filters: |
frontend:
- 'apps/web/**'
- 'apps/desktop/**'
- 'packages/**'
- 'package.json'
- '.npmrc'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- '.github/workflows/ci.yml'
- 'scripts/generate-reserved-slugs.mjs'
- 'server/internal/handler/reserved_slugs.json'
- 'scripts/selfhost-config.test.sh'
- 'scripts/check.sh'
- 'scripts/dev.sh'
- 'scripts/local-env.sh'
- '.env.example'
- 'docker-compose.selfhost.yml'
- name: Decide
id: decide
# Always run the frontend job on push to main (full validation);
# on pull_request, run only when frontend-relevant paths changed.
# The frontend job itself always runs and reports success — its
# steps are gated on this output rather than the job being skipped
# — so the required "frontend" status check is satisfied with a
# genuine green instead of being left pending on filtered PRs.
env:
EVENT_NAME: ${{ github.event_name }}
FRONTEND_CHANGED: ${{ steps.filter.outputs.frontend }}
run: |
if [ "$EVENT_NAME" != "pull_request" ] || [ "$FRONTEND_CHANGED" = "true" ]; then
echo "frontend=true" >> "$GITHUB_OUTPUT"
else
echo "frontend=false" >> "$GITHUB_OUTPUT"
fi
frontend:
needs: changes
runs-on: ubuntu-latest
steps:
- name: Checkout
if: ${{ needs.changes.outputs.frontend == 'true' }}
uses: actions/checkout@v6
- name: Setup pnpm
if: ${{ needs.changes.outputs.frontend == 'true' }}
uses: pnpm/action-setup@v4
- name: Setup Node.js
if: ${{ needs.changes.outputs.frontend == 'true' }}
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
if: ${{ needs.changes.outputs.frontend == 'true' }}
run: pnpm install
- name: Test self-host env derivation
if: ${{ needs.changes.outputs.frontend == 'true' }}
run: bash scripts/selfhost-config.test.sh
- name: Verify reserved-slugs.ts is up to date
if: ${{ needs.changes.outputs.frontend == 'true' }}
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
# source directly, so a passing diff here proves both sides
@@ -42,8 +113,9 @@ jobs:
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Build, type check, lint, and test
if: ${{ needs.changes.outputs.frontend == 'true' }}
# Mobile lives in a parallel mobile-verify workflow (path-filtered
# to apps/mobile/** + packages/core/types/**) so it doesn't add
# to apps/mobile/** + packages/core/**) so it doesn't add
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
# filter in sync with the root package.json scripts, which also
# exclude @multica/mobile.
@@ -98,7 +170,7 @@ jobs:
run: cd server && go run ./cmd/migrate up
- name: Test
run: cd server && go test ./...
run: cd server && go test -race ./...
installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy

View File

@@ -52,7 +52,7 @@ jobs:
cache-dependency-path: server/go.sum
- name: Run tests
run: cd server && go test ./...
run: cd server && go test -race ./...
release:
needs: verify

View File

@@ -3,8 +3,10 @@
This file provides guidance to AI agents when working with code in this repository.
> **Single source of truth:** This file is a concise pointer document.
> All authoritative architecture, coding rules, commands, and conventions
> All authoritative architecture, coding rules, and conventions
> live in **CLAUDE.md** at the project root. Read that file first.
> Use `Makefile`, `package.json`, and `pnpm-workspace.yaml` as the
> source of truth for the full command list.
## Quick Reference
@@ -12,27 +14,27 @@ This file provides guidance to AI agents when working with code in this reposito
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
- `server/` Go backend (Chi router, sqlc, gorilla/websocket)
- `apps/web/` Next.js frontend (App Router)
- `apps/desktop/` Electron desktop app
- `packages/core/` Headless business logic (Zustand stores, React Query hooks, API client)
- `packages/ui/` Atomic UI components (shadcn/Base UI, zero business logic)
- `packages/views/` Shared business pages/components
- `packages/tsconfig/` Shared TypeScript config
- `server/` - Go backend (Chi router, sqlc, gorilla/websocket)
- `apps/web/` - Next.js frontend (App Router)
- `apps/desktop/` - Electron desktop app
- `packages/core/` - Headless business logic (Zustand stores, React Query hooks, API client)
- `packages/ui/` - Atomic UI components (shadcn/Base UI, zero business logic)
- `packages/views/` - Shared business pages/components
- `packages/tsconfig/` - Shared TypeScript config
### State Management (critical)
- **React Query** owns all server state (issues, members, agents, inbox, workspace list)
- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals)
- All Zustand stores live in `packages/core/` never in `packages/views/` or app directories
- WS events invalidate React Query never write directly to stores
- All Zustand stores live in `packages/core/` - never in `packages/views/` or app directories
- WS events invalidate React Query - never write directly to stores
### Package Boundaries (hard rules)
- `packages/core/` zero react-dom, zero localStorage, zero process.env
- `packages/ui/` zero `@multica/core` imports
- `packages/views/` zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
- `apps/web/platform/` only place for Next.js APIs
- `packages/core/` - zero react-dom, zero localStorage, zero process.env
- `packages/ui/` - zero `@multica/core` imports
- `packages/views/` - zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
- `apps/web/platform/` - only place for Next.js APIs
### Commands
@@ -44,4 +46,4 @@ make test # Go tests
make check # Full verification pipeline
```
See CLAUDE.md for the complete command reference.
See CLAUDE.md for the authoritative rules and common commands.

507
CLAUDE.md
View File

@@ -1,427 +1,226 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code when working in this repository. Keep this file short and authoritative: rules here should be hard to infer from code or easy to get wrong.
## Conventions reference
## Conventions
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
The source of truth for code naming, i18n glossary, and Chinese product voice is:
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
- `apps/docs/content/docs/developers/conventions.mdx`
- `apps/docs/content/docs/developers/conventions.zh.mdx`
Read that page before:
Read it before editing translations in `packages/views/locales/`, naming routes/packages/files/DB columns/types, or writing Chinese UI/docs copy. Do not rely on `packages/views/locales/glossary.md`; it is only a redirect stub.
- Writing or editing translations (`packages/views/locales/`)
- Naming a new route, package, file, DB column, or TS type
- Writing Chinese product copy (UI strings, error messages, docs)
## Project Shape
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
Multica is an AI-native task management platform for small teams, with agents as first-class assignees that can own issues, comment, and change status.
## Project Context
- `server/`: Go backend, Chi router, sqlc, gorilla/websocket.
- `apps/web/`: Next.js App Router.
- `apps/desktop/`: Electron desktop app.
- `apps/mobile/`: Expo / React Native iOS app. Read `apps/mobile/CLAUDE.md` before touching it.
- `packages/core/`: headless business logic, API client, React Query hooks, Zustand stores.
- `packages/ui/`: atomic UI components only.
- `packages/views/`: shared business pages/components for web and desktop.
- `packages/tsconfig/`: shared TypeScript config.
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
Shared packages export raw `.ts` / `.tsx` and are compiled by consuming apps. Dependency direction is `views -> core + ui`; `core` and `ui` must stay independent.
- Agents can be assigned issues, create issues, comment, and change status
- Supports local (daemon) and cloud agent runtimes
- Built for 2-10 person AI-native teams
## State Rules
## Architecture
Keep server state and client state separate.
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
- TanStack Query owns server state: issues, users, workspaces, inbox, agents, members, and anything fetched from the API.
- Zustand owns client state: selected workspace, filters, drafts, modals, tab layout, and navigation history.
- Shared Zustand stores live in `packages/core/`, never in `packages/views/` or app directories.
- React Context is for platform plumbing only, such as `WorkspaceIdProvider` and `NavigationProvider`.
- Only auth/workspace stores may call `api.*` directly. Other server interaction belongs in queries/mutations.
- Workspace-scoped query keys must include `wsId`.
- Mutations should be optimistic by default: patch locally, send request, roll back on failure, invalidate on settle.
- WebSocket events invalidate or patch Query cache; they never write directly to Zustand stores.
- Persist durable preferences/drafts/layout. Do not persist server data or ephemeral UI state.
- Zustand selectors must return stable references. Do not return freshly allocated objects/arrays from selectors without shallow comparison.
- Hooks that need workspace context should accept `wsId`; do not call `useWorkspaceId()` internally unless the hook is guaranteed to run under the provider.
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js frontend (App Router)
- `apps/desktop/` — Electron desktop app (electron-vite)
- `apps/mobile/` — Expo / React Native iOS app. See `apps/mobile/CLAUDE.md`.
- `packages/core/` — Headless business logic (zero react-dom)
- `packages/ui/` — Atomic UI components (zero business logic)
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
- `packages/tsconfig/` — Shared TypeScript configuration
## Package Boundaries
What lives where for sharing purposes is documented in *Sharing Principles* below — read it once.
These are hard constraints:
### Key Architectural Decisions
- `packages/core/`: no `react-dom`, `localStorage` (use `StorageAdapter`), `process.env`, or UI libraries.
- `packages/ui/`: no `@multica/core` imports and no business logic.
- `packages/views/`: no `next/*`, no `react-router-dom`, no stores. Use `NavigationAdapter`, `useNavigation()`, and `<AppLink>`.
- `apps/web/platform/`: only place for Next.js navigation/platform APIs.
- `apps/desktop/src/renderer/src/platform/`: only place for `react-router-dom` navigation wiring.
- Every workspace under `apps/` and `packages/` must declare directly imported external packages in its own `package.json`.
- Shared dependencies use `catalog:` from `pnpm-workspace.yaml`; `apps/mobile/` pins Expo/React Native related versions directly.
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
## Sharing Rules
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*`, `react-router-dom`, or app-specific code.
Web and desktop share business logic, hooks, stores, components, and views through `packages/core/`, `packages/ui/`, and `packages/views/`.
**Platform bridge:** `packages/core/platform/` provides `CoreProvider` — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider>` and provides its own `NavigationAdapter` for routing.
If the same logic exists in both web and desktop, extract it unless it depends on platform APIs:
**pnpm catalog**`pnpm-workspace.yaml` defines `catalog:` for version pinning. All shared deps use `catalog:` references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
1. Next.js, Electron, or router APIs stay in the app/platform layer.
2. Headless logic belongs in `packages/core/`.
3. Shared UI or business views belong in `packages/views/`.
4. Shared primitives belong in `packages/ui/`.
### State Management
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so they're shared.
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
**Hard rules — these are how the architecture stays coherent:**
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
- **Workspace-scoped queries must key on `wsId`.** This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed.
- **Mutations are optimistic by default.** Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
- **Persist what's worth preserving across restarts** (user preferences, drafts, tab layout). **Don't persist ephemeral UI state** (modal open/close, transient selections) or server data.
**Common Zustand footguns to avoid:**
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
## Sharing Principles
The monorepo splits into two share zones:
- **Web and desktop** share business logic, components, hooks, stores, and views through `packages/core/`, `packages/ui/`, and `packages/views/`. Existing model — keep using it.
- **Mobile (`apps/mobile/`) is independent.** It shares only **types and pure functions** from `@multica/core/`, with `import type` for types (zero runtime coupling). UI, state, hooks, providers, i18n, React version, build pipeline, release cadence — all mobile-owned.
Mobile is locked to the React version that Expo SDK / React Native ships (which lags React main by 6-12 months). Coupling mobile to the root `catalog:` React would block mobile from upgrading on its own schedule.
See `apps/mobile/CLAUDE.md` for the mobile rules and tech-stack baseline.
Mobile is independent. It may import types and pure functions from `@multica/core`, with `import type` for types, but owns its UI, state, hooks, providers, i18n, React version, build pipeline, and release cadence.
## Commands
Use the repo scripts as the source of truth. Common commands:
```bash
# One-command dev (auto-setup + start everything)
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
# Explicit setup & run (if you prefer separate steps)
make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend (all commands go through Turborepo)
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm dev:desktop # Electron dev (electron-vite, HMR)
pnpm build # Build all frontend apps
pnpm typecheck # TypeScript check (all packages + apps via turbo)
pnpm lint # ESLint
pnpm test # TS tests (Vitest, all packages + apps via turbo)
# Backend (Go)
make server # Run Go server only (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries to server/bin/
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
make dev # auto-setup and start the app
make start # start backend + frontend
make stop # stop app processes for this checkout
make server # run Go server only
make daemon # run local daemon
make test # Go tests
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single TS test (works for any package with a test script)
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Mobile (Expo) — two environments only: dev and staging
pnpm dev:mobile # Metro, dev env (reads apps/mobile/.env.development.local)
pnpm dev:mobile:staging # Metro, staging env (reads apps/mobile/.env.staging)
pnpm ios:mobile # Native build + install dev-client to iOS Simulator, dev env
pnpm ios:mobile:staging # Native build + install dev-client to iOS Simulator, staging env
pnpm ios:mobile:device # Native build + install dev-client to USB iPhone, dev env
pnpm ios:mobile:device:staging # Native build + install dev-client to USB iPhone, staging env
# Daily flow: run `pnpm dev:mobile:staging` (or :dev). Only re-run `ios:mobile*` when
# native code or any expo-*/react-native-* dependency changes (lockfile drift counts).
# Desktop build & package
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
pnpm ui:add badge # Adds component to packages/ui/components/ui/
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
make sqlc # regenerate sqlc code after SQL changes
pnpm install
pnpm dev:web
pnpm dev:desktop
pnpm build
pnpm typecheck
pnpm lint
pnpm test # TS/Vitest tests through Turborepo
pnpm exec playwright test
pnpm ui:add badge # shadcn/Base UI component into packages/ui
```
### CI Requirements
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`. `pnpm dev:desktop` additionally self-isolates per worktree (its own renderer port + app name) automatically, independent of `.env.worktree`.
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
`make dev` auto-detects worktrees and handles everything. For explicit control:
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
make start-worktree # Start using .env.worktree
```
CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.
## Coding Rules
- TypeScript strict mode is enabled; keep types explicit.
- Go code follows standard Go conventions (gofmt, go vet).
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Go follows standard conventions: `gofmt`, `go vet`, checked errors.
- Code comments must be English.
- Prefer existing patterns/components over new parallel abstractions.
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
- When you change a CLI command or flag, an API request/response field, or product behavior that a built-in skill documents (`server/internal/service/builtin_skills/*`), update that skill's `SKILL.md` **and** its `references/*-source-map.md` in the same PR. The built-in skills are source-traced contracts shipped to agents — if the code moves and the skill doesn't, it silently teaches stale behavior.
- For internal, non-boundary code, do not add compatibility layers, fallback paths, dual writes, legacy adapters, or temporary shims unless explicitly requested.
- API boundaries are different: installed desktop clients can talk to newer backends, so response parsing must follow the API compatibility rules below.
- If a flow or API is being replaced and the product is not live, prefer removing the old path instead of preserving both.
- New global pre-workspace routes must be a single word (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Do not add hyphenated root routes like `/new-workspace`.
- Reserved slugs live in `server/internal/handler/reserved_slugs.json`. Edit it, run `pnpm generate:reserved-slugs`, and commit the generated `packages/core/paths/reserved-slugs.ts`.
- When changing CLI commands/flags, API fields, or product behavior documented by built-in skills under `server/internal/service/builtin_skills/*`, update the relevant `SKILL.md` and `references/*-source-map.md` in the same PR.
### API Response Compatibility
## API Compatibility
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
Frontend code must survive backend response drift, especially in installed desktop builds.
When writing code that consumes an API response, follow these rules:
- Parse API JSON with `parseWithFallback` in `packages/core/api/schema.ts` and a zod schema. Do not cast network JSON to `T`.
- Endpoint responses consumed by UI logic must pass through a schema before returning.
- Downstream UI should optional-chain and default fields defensively.
- Prefer explicit boolean checks (`=== true`) over truthy/falsy checks on server fields.
- Do not pin critical affordances to one backend boolean; combine signals when possible.
- Server-driven enum switches need a `default` branch.
- When adding or changing an endpoint, add/update the schema and include a malformed-response test.
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
## Backend UUID Rules
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
In `server/internal/handler/`, always know where a UUID came from before using it in write queries.
### Backend Handler UUID Parsing Convention
- Resource path params that may be UUIDs or human-readable IDs must be resolved through loaders such as `loadIssueForUser`, `loadSkillForUser`, `loadAgentForUser`, or `requireDaemonRuntimeAccess`; subsequent writes use the resolved `entity.ID`.
- Pure UUID inputs from request boundaries use `parseUUIDOrBadRequest(w, s, fieldName)` and return immediately on `ok=false`.
- Trusted UUID round-trips from sqlc results or test fixtures use `parseUUID(s)`, which panics on invalid input.
- Outside handlers, `util.ParseUUID(s) (pgtype.UUID, error)` is the safe variant; always check the error.
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
## Web/Desktop Features
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
When adding a shared page or feature for web and desktop:
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
1. Put the page/component in `packages/views/<domain>/`.
2. Add platform wiring in both `apps/web/app/` and the desktop router, unless the desktop flow is a transition overlay.
3. Use `useNavigation().push()` or `<AppLink>` in shared code.
4. Use shared guards/providers such as `DashboardGuard` from `packages/views/layout/`.
5. Keep platform-only UI in the app or inject it through props/slots.
6. Hooks that need workspace context should accept `wsId`.
### Dependency Declaration Rule
CSS for web/desktop is shared from `packages/ui/styles/`. Use semantic tokens such as `bg-background` and `text-muted-foreground`; avoid hardcoded Tailwind colors and duplicated base styles.
Every workspace (`apps/` and `packages/` directories) must explicitly declare all directly imported external packages in its own `package.json`. Relying on pnpm hoist to resolve undeclared imports (phantom deps) is prohibited — it causes production build failures when pnpm creates peer-dep variants.
## Desktop Rules
- Use `"pkg": "catalog:"` to reference the shared version from `pnpm-workspace.yaml`.
- CI enforces this via `eslint-plugin-import-x/no-extraneous-dependencies`.
- Exception: `apps/mobile/` uses pinned versions (not `catalog:`) for packages tied to its own React/Expo version.
Desktop routing has three categories:
### Package Boundary Rules
- Session routes: workspace-scoped tab destinations such as `/:slug/issues`.
- Transition flows: pre-workspace one-shot actions such as create workspace or accept invite. These are `WindowOverlay` state, not routes.
- Error/stale states: stale workspace tabs should auto-heal by dropping stale tab groups, not render desktop error pages.
These are hard constraints. Violating them breaks the cross-platform architecture:
More desktop constraints:
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **Shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
- New pre-workspace desktop flows register a `WindowOverlay` type in `stores/window-overlay-store.ts`; do not add them to `routes.tsx`.
- `setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the active workspace source of truth.
- Code that leaves workspace context must call `setCurrentWorkspace(null, null)` explicitly.
- Leave/delete workspace flow order: read cached destination, clear current workspace, navigate, then run the mutation.
- Cross-workspace navigation must go through the navigation adapter so it can call `switchWorkspace(slug, targetPath)`.
- Full-window desktop views outside the dashboard shell must mount `<DragStrip />` from `@multica/views/platform` as the first flex child. Interactive controls in the top 48px need `WebkitAppRegion: "no-drag"`.
### The No-Duplication Rule (web + desktop)
## Mobile Rules
**If the same logic exists in both web and desktop, it must be extracted to a shared package.**
Read `apps/mobile/CLAUDE.md` before touching `apps/mobile/`. It contains the mandatory pre-flight process, import limits, parity rules, tech stack, UI rules, data helpers, realtime strategy, and mobile release flow.
This applies to everything between web and desktop: components, hooks, guards, providers, utility functions. The decision process:
Root-level reminders:
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
3. Everything else → belongs in `packages/core/` (headless logic) or `packages/views/` (UI components).
- Mobile shares only `@multica/core` types and pure functions.
- Mobile must match web/desktop product semantics: counts, permissions, enums/transitions, and data identity.
- Mobile may differ in UI/interaction when the phone context requires it.
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
## UI Rules
### Cross-Platform Development Rules (web + desktop)
- Prefer shadcn/Base UI components over custom implementations. Add them with `pnpm ui:add <component>` from the repo root.
- Use design tokens and semantic classes; avoid hardcoded colors.
- Do not introduce extra local state unless the design requires it.
- Handle overflow, long text, scrolling, alignment, and spacing deliberately.
- If a component is identical between web and desktop, it belongs in a shared package.
When adding a new page or feature for web/desktop:
## Testing
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
Tests follow the code:
### CSS Architecture (web + desktop)
| What is tested | Location |
| --- | --- |
| Shared business logic, stores, queries, hooks | `packages/core/*.test.ts` |
| Shared UI components, pages, forms, modals | `packages/views/*.test.tsx` |
| Platform wiring such as cookies, redirects, search params | `apps/web/*.test.tsx` or `apps/desktop/` |
| End-to-end flows | `e2e/*.spec.ts` |
| Backend | `server/` Go tests |
Web and desktop share the same CSS foundation from `packages/ui/styles/`.
Rules:
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Mobile-specific Rules
Rules for `apps/mobile/` live in `apps/mobile/CLAUDE.md`. Read it before touching anything in `apps/mobile/` — it covers what may be imported from `@multica/core/`, the React version policy, the build/release pipeline, and the locked tech-stack baseline.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
### Route categories
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace context
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
1. Read destination from cached workspace list.
2. `setCurrentWorkspace(null, null)`.
3. `navigation.push(destination)`.
4. THEN `await mutation.mutateAsync(workspaceId)`.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS)
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
- Use shadcn design tokens for styling. Avoid hardcoded color values.
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
## Testing Rules
### Where to write tests
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
| What you're testing | Where the test lives | Why |
|---|---|---|
| Shared business logic (stores, queries, hooks) | `packages/core/*.test.ts` | No DOM needed, pure logic |
| Shared UI components (pages, forms, modals) | `packages/views/*.test.tsx` | jsdom, no framework mocks |
| Platform-specific wiring (cookies, redirects, searchParams) | `apps/web/*.test.tsx` or `apps/desktop/` | Needs framework-specific mocks |
| End-to-end user flows | `e2e/*.spec.ts` | Real browser, real backend |
**Never test shared component behavior in an app's test file.** If a test requires mocking `next/navigation` or `react-router-dom` to test a component from `@multica/views`, the test is in the wrong place — move it to `packages/views/` and mock `@multica/core` instead.
### Test infrastructure
- `packages/core/` — Vitest, Node environment (no DOM)
- `packages/views/` — Vitest, jsdom environment, `@testing-library/react`
- `apps/web/` — Vitest, jsdom environment, framework-specific mocks
- `e2e/` — Playwright
- `server/` — Go standard `go test`
All test deps are in the pnpm catalog for unified versioning.
### Mocking conventions
- Mock `@multica/core` stores with `vi.hoisted()` + `Object.assign(selectorFn, { getState })` pattern (Zustand stores are both callable and have `.getState()`).
- Never test shared component behavior in an app test file.
- `packages/views/` tests must not mock `next/*` or `react-router-dom`.
- Mock `@multica/core` stores with the Zustand callable-store shape (`selectorFn` plus `getState`).
- Mock `@multica/core/api` for API calls.
- In `packages/views/` tests: never mock `next/*` or `react-router-dom` — those don't exist here.
- In `apps/web/` tests: mock framework-specific APIs only for platform-specific behavior.
- E2E tests should use `TestApiClient` for setup/teardown.
- Prefer writing the failing test in the correct package before implementation when the change is behavioral.
### TDD workflow
## Verification
1. Write failing test in the **correct package** first.
2. Write implementation.
3. Run `pnpm test` (Turborepo discovers all packages).
4. Green → done.
For code changes, run the narrowest useful checks while iterating, then run broader verification when risk justifies it or when asked.
### Go tests
Standard `go test`. Tests should create their own fixture data in a test database.
### E2E tests
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi();
await loginAsDefault(page);
});
test.afterEach(async () => {
await api.cleanup();
});
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue");
await page.goto(`/issues/${issue.id}`);
});
```
## Commit Rules
- Use atomic commits grouped by logical intent.
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
## Minimum Pre-Push Checks
```bash
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
```
Run verification only when the user explicitly asks for it.
For targeted checks when requested:
```bash
pnpm typecheck # TypeScript type errors only
pnpm test # TS unit tests only (Vitest, all packages)
make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
## AI Agent Verification Loop
After writing or modifying code, always run the full verification pipeline:
Useful checks:
```bash
pnpm typecheck
pnpm test
make test
pnpm exec playwright test
make check
```
**Workflow:**
- Write code to satisfy the requirement
- Run `make check`
- If any step fails, read the error output, fix the code, and re-run
- Repeat until all checks pass
- Only then consider the task complete
Do not claim verification passed unless you ran it. If you skip checks because the change is docs-only or the user asked not to run them, say so.
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
## Commits and Releases
## CLI Release
- Commits should be atomic and use conventional prefixes: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
- A production deployment requires a CLI release tag on `main`: create `v0.x.x`, push it, and let `release.yml` publish binaries and the Homebrew tap.
- Bump patch by default unless the user specifies a version.
**Prerequisite:** A CLI release must accompany every Production deployment.
## Domain Reminders
1. Create a tag on the `main` branch: `git tag v0.x.x`
2. Push the tag: `git push origin v0.x.x`
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
By default, bump the patch version each release (e.g. `v0.1.12``v0.1.13`), unless the user specifies a specific version.
## Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
## Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
- All queries filter by `workspace_id`; membership gates access; `X-Workspace-ID` selects the workspace.
- Issue assignees are polymorphic: `assignee_type` plus `assignee_id` can reference a member or an agent.

View File

@@ -149,6 +149,7 @@ The daemon auto-detects these AI CLIs on your PATH:
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
| Kimi | `kimi` | Moonshot coding agent |
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
| [Qoder CLI](https://docs.qoder.com/) | `qodercli` | Qoder ACP coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -220,6 +221,10 @@ Agent-specific overrides:
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
| `MULTICA_QODER_PATH` | Custom path to the `qodercli` binary |
| `MULTICA_QODER_MODEL` | Override the Qoder model used |
The daemon launches Qoder as `qodercli --yolo --acp`, matching Qoders ACP “bypass permissions” mode so tool runs do not block on interactive approval in headless runs.
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
@@ -404,12 +409,12 @@ multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
# Most recently active threads (root + every descendant), grouped by
# thread. Returns N complete conversational arcs, oldest-active first so
# the freshest thread sits closest to "now" in an agent prompt.
multica issue comment list <issue-id> --recent 20
multica issue comment list <issue-id> --recent 10
# Scroll older threads. Under --recent, --before / --before-id are a
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
# `Next thread cursor: --before <ts> --before-id <root-id>`.
multica issue comment list <issue-id> --recent 20 \
multica issue comment list <issue-id> --recent 10 \
--before <ts> --before-id <root-id>
# Incremental polling. Combines with --thread or --recent; filters out
@@ -514,8 +519,14 @@ multica issue run-messages <task-id> --output json
# Incremental fetch (only messages after a given sequence number)
multica issue run-messages <task-id> --since 42 --output json
# Aggregated token usage for an issue (sum across all its task runs)
multica issue usage <issue-id>
multica issue usage <issue-id> --output json
```
The `usage` command returns the aggregated token usage for an issue, summed across all of its task runs: input tokens, output tokens, cache read/write tokens, and the run count (`task_count`). It wraps `GET /api/issues/<id>/usage` — the same figures the issue detail view shows. Use `--output json` to feed billing/cost tooling.
The `runs` command shows all past and current executions for an issue, including running tasks. Table output uses short task UUID prefixes by default; pass `--full-id` to print canonical task UUIDs. The `run-messages` command accepts full task UUIDs directly; copied short task prefixes must be scoped with `--issue <issue-id>` so the CLI only checks that issue's runs. It shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Projects
@@ -648,14 +659,18 @@ multica autopilot create \
--title "Nightly bug triage" \
--description "Scan todo issues and prioritize." \
--agent "Lambda" \
--mode create_issue
--mode create_issue \
--subscriber "Alice"
multica autopilot update <id> --status paused
multica autopilot update <id> --description "New prompt"
multica autopilot update <id> --subscriber "Alice" --subscriber "Bob"
multica autopilot update <id> --clear-subscribers
multica autopilot delete <id>
```
`--mode` accepts `create_issue` (creates a new issue on each run and assigns it to the agent) or `run_only` (enqueues a direct agent task without creating an issue). `--agent` accepts either a name or UUID.
`--subscriber` accepts a workspace member name or user ID and may be repeated; on update it replaces the autopilot's subscriber template. Subscribers receive inbox notifications for issues created by a `create_issue` autopilot. Use `--clear-subscribers` to remove all autopilot subscribers.
### Manual Trigger

View File

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

View File

@@ -20,6 +20,7 @@ RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSIO
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_codex_usage_cache ./cmd/backfill_codex_usage_cache
# --- Runtime stage ---
FROM alpine:3.21
@@ -32,6 +33,7 @@ COPY --from=builder /src/server/bin/server .
COPY --from=builder /src/server/bin/multica .
COPY --from=builder /src/server/bin/migrate .
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
COPY --from=builder /src/server/bin/backfill_codex_usage_cache .
COPY server/migrations/ ./migrations/
COPY docker/entrypoint.sh .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh

View File

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

View File

@@ -19,8 +19,9 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/W8gYBn226t)
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [Discord](https://discord.gg/W8gYBn226t) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**
@@ -30,7 +31,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, **Kiro CLI**, and **Qoder CLI**.
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
@@ -114,7 +115,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`, `qodercli`) on your PATH.
### 2. Verify your runtime
@@ -124,7 +125,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Antigravity). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, Antigravity, or Qoder CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -165,7 +166,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
OpenCode, OpenClaw, Hermes, Gemini,
Pi, Cursor Agent, Kimi, Kiro CLI)
Pi, Cursor Agent, Kimi, Kiro CLI, Qoder CLI)
```
| Layer | Stack |
@@ -173,7 +174,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Qoder CLI |
## Development

View File

@@ -19,8 +19,9 @@
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/W8gYBn226t)
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai) · [Discord](https://discord.gg/W8gYBn226t) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
@@ -30,7 +31,7 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi****Kiro CLI**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi****Kiro CLI** 与 **Qoder CLI**
面向更大的团队Squads小队提供稳定的路由层把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
@@ -115,7 +116,7 @@ multica setup # 连接 Multica Cloud登录启动 daemon
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``copilot``openclaw``opencode``hermes``gemini``pi``cursor-agent``kimi``kiro-cli`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``copilot``openclaw``opencode``hermes``gemini``pi``cursor-agent``kimi``kiro-cli``qodercli`)。
### 2. 确认运行时已连接
@@ -125,7 +126,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、KimiKiro CLI并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、KimiKiro CLI 或 Qoder CLI并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -147,7 +148,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
│ Agent Daemon │ 运行在你的机器上
└──────────────┘ Claude Code、Codex、GitHub Copilot CLI、
OpenCode、OpenClaw、Hermes、Gemini、
Pi、Cursor Agent、Kimi、Kiro CLI
Pi、Cursor Agent、Kimi、Kiro CLI、Qoder CLI
```
| 层级 | 技术栈 |
@@ -155,7 +156,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、KimiKiro CLI |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、KimiKiro CLI 或 Qoder CLI |
## 开发

View File

@@ -101,6 +101,7 @@ You also need at least one AI agent CLI installed:
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
- Kimi (`kimi` on PATH)
- Kiro CLI (`kiro-cli` on PATH)
- Qoder CLI (`qodercli` on PATH)
### b) One-command setup

View File

@@ -19,8 +19,8 @@
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
"dev": "node scripts/dev.mjs",
"dev:staging": "node scripts/dev.mjs --mode staging",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
@@ -49,6 +49,7 @@
"electron-updater": "^6.8.3",
"fix-path": "^5.0.0",
"lucide-react": "catalog:",
"motion": "^12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "^7.6.0",

View File

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

View File

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

View File

@@ -98,16 +98,29 @@ export function stripLeadingSeparator(argv) {
* - "v0.1.36" → "0.1.36"
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
* - "f1415e96" (no tag) → "0.0.0-gf1415e96" (fallback)
* - "2f24057b" (no tag, hash begins with a digit) → "0.0.0-g2f24057b"
* - "0123456" (no tag, all-digit hash w/ leading zero) → "0.0.0-g0123456"
*
* Leading `v` is stripped so the result is valid semver for package.json.
* The fallback matters because a bare commit hash is never valid semver —
* even one that happens to start with a digit (e.g. "2f24057b") — and
* electron-updater throws on launch if package.json carries such a version.
* The hash is prefixed with `g` so the pre-release identifier is always
* alphanumeric; a bare all-digit hash with a leading zero (e.g. "0123456")
* would otherwise form `0.0.0-0123456`, which is invalid semver.
*/
export function normalizeGitVersion(raw) {
if (!raw) return null;
const stripped = raw.replace(/^v/, "");
if (!/^\d/.test(stripped)) {
// No reachable tag — `git describe` fell back to just the commit hash.
return `0.0.0-${stripped}`;
// A real version begins with major.minor.patch. The bare commit hash
// that `git describe --always` falls back to (no reachable tag) does not,
// so coerce it to a 0.0.0 prerelease rather than passing it through.
// Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
// shorthand) so a hash like "0123456" yields "0.0.0-g0123456" — a single
// alphanumeric identifier — instead of the invalid "0.0.0-0123456".
if (!/^\d+\.\d+\.\d+/.test(stripped)) {
return `0.0.0-g${stripped}`;
}
return stripped;
}

View File

@@ -38,11 +38,27 @@ describe("normalizeGitVersion", () => {
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
});
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
it("falls back to 0.0.0-g<hash> when no tags are reachable", () => {
// `git describe --tags --always` returns just the short commit hash
// when there are no tags in the history at all.
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
// when there are no tags in the history at all. A hash that begins with
// a digit (e.g. "2f24057b") is still not valid semver and must fall
// through — otherwise electron-updater rejects it on launch. The `g`
// prefix mirrors git describe's own `g<hash>` shorthand and keeps the
// pre-release identifier a single alphanumeric token.
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-gf1415e96");
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-gabc1234");
expect(normalizeGitVersion("2f24057b")).toBe("0.0.0-g2f24057b");
});
it("prefixes an all-digit hash so the pre-release is valid semver", () => {
// A short hash that is all decimal digits with a leading zero would
// produce `0.0.0-0123456` — a numeric pre-release identifier must not
// have a leading zero, so that value is invalid semver and
// electron-updater would throw on the no-tag builds this fallback
// exists to protect. The `g` prefix makes it a single alphanumeric
// identifier, which is always valid.
expect(normalizeGitVersion("0123456")).toBe("0.0.0-g0123456");
expect(normalizeGitVersion("04567")).toBe("0.0.0-g04567");
});
});

View File

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

View File

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

View File

@@ -144,7 +144,7 @@ function createWindow(): void {
minWidth: 900,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 13 },
trafficLightPosition: { x: 16, y: 17 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option.

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
@@ -22,41 +23,69 @@ import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const TOP_BAR_HEIGHT_CLASS = "h-12";
const WINDOW_TOOLBAR_CLEARANCE = 184;
const toolbarMotion = {
type: "spring",
stiffness: 420,
damping: 38,
mass: 0.8,
} as const;
function WindowToolbar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
const navButtonClassName =
"flex size-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-30";
return (
<div
className="h-12 shrink-0 flex items-center justify-end px-2"
className={cn(
"fixed left-0 top-0 z-30 flex w-[184px] shrink-0 items-center px-3",
TOP_BAR_HEIGHT_CLASS,
)}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
className="flex items-center gap-0.5"
className="flex items-center gap-1 pl-[70px]"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
</button>
<SidebarTrigger
className="size-7 text-muted-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
title="Go back"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
title="Go forward"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
</div>
);
}
function SidebarTopSpacer() {
return <div className={cn("shrink-0", TOP_BAR_HEIGHT_CLASS)} />;
}
function useNativeNavigationGestures() {
const { goBack, goForward } = useTabHistory();
@@ -72,30 +101,31 @@ function useNativeNavigationGestures() {
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
// left side so tabs don't land under the macOS traffic lights (which live at
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
// the sidebar can be brought back without keyboard shortcut.
// is not occupying main-flow width, leave room for the fixed window toolbar
// so tabs do not land beneath the traffic lights / navigation controls.
function MainTopBar() {
const { state, isMobile } = useSidebar();
const sidebarHidden = state === "collapsed" || isMobile;
return (
<header
className={cn(
"h-12 shrink-0 flex items-center gap-2",
sidebarHidden && "pl-20",
)}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
<motion.header
animate={{ paddingLeft: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className={cn("relative shrink-0 flex items-center gap-2", TOP_BAR_HEIGHT_CLASS)}
initial={false}
transition={toolbarMotion}
>
{sidebarHidden && (
<SidebarTrigger
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
)}
<TabBar />
</header>
<motion.div
aria-hidden
animate={{ left: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className="absolute inset-y-0 right-0"
initial={false}
transition={toolbarMotion}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div className="relative z-10 flex h-full items-center">
<TabBar />
</div>
</motion.header>
);
}
@@ -183,9 +213,10 @@ export function DesktopShell() {
<DesktopInboxBridge />
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
{slug && <WindowToolbar />}
{slug && <AppSidebar topSlot={<SidebarTopSpacer />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<motion.div layout transition={toolbarMotion} className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
@@ -193,7 +224,7 @@ export function DesktopShell() {
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</div>
</motion.div>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}

View File

@@ -13,4 +13,18 @@ import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";
// react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click any
// element to copy its source path + line + component stack for pasting to an AI.
// Opt-in per developer: only loads when VITE_REACT_GRAB is set in a local,
// gitignored apps/desktop/.env.development.local — it never activates for anyone
// else, and the whole branch is tree-shaken out of production builds. The web app
// wires the same tool via next/script in apps/web/app/layout.tsx.
// See https://www.react-grab.com/
if (import.meta.env.DEV && import.meta.env.VITE_REACT_GRAB) {
const grab = document.createElement("script");
grab.src = "//unpkg.com/react-grab/dist/index.global.js";
grab.crossOrigin = "anonymous";
document.head.appendChild(grab);
}
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

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

View File

@@ -5,6 +5,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
import { DocsHero } from "@/components/hero";
import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/components/editorial";
import { VideoEmbed } from "@/components/video-embed";
import { i18n, type Lang } from "@/lib/i18n";
import { homeCopy } from "@/lib/translations";
import { docsAlternates } from "@/lib/site";
@@ -62,6 +63,7 @@ export default async function Page({
NumberedCard,
NumberedSteps,
Step,
VideoEmbed,
}}
/>
</DocsLocaleProvider>

View File

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

View File

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

@@ -54,14 +54,23 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
| `multica issue get <id>` | 単一のイシューを表示(イシューキーまたは UUID を受け取る) |
| `multica issue create --title "..."` | 新しいイシューを作成 |
| `multica issue update <id> ...` | イシューを更新(ステータス、優先度、担当者など) |
| `multica issue assign <id> --agent <slug>` | エージェントに割り当て(即座にタスクをトリガー) |
| `multica issue status <id> --set <status>` | ステータス変更のショートカット |
| `multica issue assign <id> --to <name>` | メンバー、エージェント、またはスクワッドに割り当て(エージェントへの割り当ては実行をトリガー) |
| `multica issue status <id> <status>` | ステータス変更のショートカット |
| `multica issue search <query>` | キーワード検索 |
| `multica issue children <id>` | サブイシューを stage ごとに一覧 |
| `multica issue pull-requests <id>` | 紐付いた pull request と状態を一覧 |
| `multica issue runs <id>` | イシュー上のエージェント実行を表示 |
| `multica issue run-messages <task-id>` | 1 回の実行メッセージを表示 |
| `multica issue usage <id>` | イシュー単位の集計 token 使用量を表示 |
| `multica issue rerun <id>` | イシューの現在のエージェント担当者向けに新しいタスクを再キューイング |
| `multica issue cancel-task <task-id>` | キュー中または実行中のタスクをキャンセル |
| `multica issue comment <id> ...` | ネスト: コメントの表示 / 投稿 |
| `multica issue comment resolve/unresolve <comment-id>` | コメントスレッドを解決済み / 未解決にする |
| `multica issue subscriber <id> ...` | ネスト: 購読 / 購読解除 |
| `multica issue metadata <id> ...` | ネスト: イシューメタデータの読み書き |
| `multica issue label <id> ...` | ネスト: イシューのラベルを管理 |
| `multica project list/get/create/update/delete/status` | プロジェクトの CRUD |
| `multica label list/create/update/delete` | ワークスペースラベルの CRUD |
## エージェントとスキル
@@ -74,6 +83,8 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
| `multica agent archive <slug>` | アーカイブ |
| `multica agent restore <slug>` | アーカイブ済みのエージェントを復元 |
| `multica agent tasks <slug>` | エージェントのタスク履歴を表示 |
| `multica agent avatar <slug>` | エージェントのアバターをアップロード |
| `multica agent env <slug> ...` | エージェントの custom environment variables を読み取り / 置換 |
| `multica agent skills ...` | ネスト: スキルのアタッチ / デタッチ |
| `multica skill list/get/create/update/delete` | スキルの CRUD |
| `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート |
@@ -117,6 +128,10 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica autopilot delete <id>` | 削除 |
| `multica autopilot runs <id>` | 実行履歴を表示 |
| `multica autopilot trigger <id>` | 手動で実行をトリガー |
| `multica autopilot trigger-add/update/delete <id>` | schedule または webhook trigger を管理 |
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | webhook trigger URL をローテート |
`multica autopilot create/update` は、`create_issue` モードの autopilot が作成するイシューの既定購読者を設定する `--subscriber` も受け取ります。`update` では `--clear-subscribers` で削除できます。
## デーモンとランタイム
@@ -130,7 +145,9 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica runtime list` | 現在のワークスペースのランタイムを一覧 |
| `multica runtime usage` | リソース使用量を表示 |
| `multica runtime activity` | 最近のアクティビティログ |
| `multica runtime update <id> ...` | ランタイムの構成を更新 |
| `multica runtime update <id> ...` | ランタイム上で CLI 更新を開始 |
| `multica runtime delete <id> [--cascade]` | ランタイムを削除し、必要なら紐付くエージェントもアーカイブ |
| `multica runtime profile ...` | カスタム runtime profile とローカルパス上書きを管理 |
## その他

View File

@@ -54,14 +54,23 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
| `multica issue get <id>` | 단일 이슈 표시(이슈 키 또는 UUID를 받음) |
| `multica issue create --title "..."` | 새 이슈 생성 |
| `multica issue update <id> ...` | 이슈 업데이트(상태, 우선순위, 담당자 등) |
| `multica issue assign <id> --agent <slug>` | 에이전트에게 할당(즉시 작업을 트리거) |
| `multica issue status <id> --set <status>` | 상태 변경 단축 명령 |
| `multica issue assign <id> --to <name>` | 멤버, 에이전트, 또는 스쿼드에 할당(에이전트에 할당하면 실행이 트리거) |
| `multica issue status <id> <status>` | 상태 변경 단축 명령 |
| `multica issue search <query>` | 키워드 검색 |
| `multica issue children <id>` | 하위 이슈를 stage별로 나열 |
| `multica issue pull-requests <id>` | 연결된 pull request와 상태 나열 |
| `multica issue runs <id>` | 이슈의 에이전트 실행 표시 |
| `multica issue run-messages <task-id>` | 한 실행의 메시지 표시 |
| `multica issue usage <id>` | 이슈의 집계 token 사용량 표시 |
| `multica issue rerun <id>` | 이슈의 현재 에이전트 담당자에게 새 작업을 다시 큐에 넣기 |
| `multica issue cancel-task <task-id>` | 대기 중이거나 실행 중인 작업 취소 |
| `multica issue comment <id> ...` | 중첩: 댓글 보기 / 작성 |
| `multica issue comment resolve/unresolve <comment-id>` | 댓글 스레드를 해결 / 미해결로 표시 |
| `multica issue subscriber <id> ...` | 중첩: 구독 / 구독 취소 |
| `multica issue metadata <id> ...` | 중첩: 이슈 metadata 읽기 / 쓰기 |
| `multica issue label <id> ...` | 중첩: 이슈의 label 관리 |
| `multica project list/get/create/update/delete/status` | 프로젝트 CRUD |
| `multica label list/create/update/delete` | 워크스페이스 label CRUD |
## 에이전트와 스킬
@@ -74,6 +83,8 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
| `multica agent archive <slug>` | 보관 |
| `multica agent restore <slug>` | 보관된 에이전트 복원 |
| `multica agent tasks <slug>` | 에이전트의 작업 기록 표시 |
| `multica agent avatar <slug>` | 에이전트 아바타 업로드 |
| `multica agent env <slug> ...` | 에이전트 custom environment variables 읽기 / 교체 |
| `multica agent skills ...` | 중첩: 스킬 연결 / 분리 |
| `multica skill list/get/create/update/delete` | 스킬 CRUD |
| `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 |
@@ -117,6 +128,10 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica autopilot delete <id>` | 삭제 |
| `multica autopilot runs <id>` | 실행 기록 표시 |
| `multica autopilot trigger <id>` | 수동으로 실행 트리거 |
| `multica autopilot trigger-add/update/delete <id>` | schedule 또는 webhook trigger 관리 |
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | webhook trigger URL 회전 |
`multica autopilot create/update`는 `create_issue` 모드 autopilot이 만드는 이슈의 기본 구독자를 설정하는 `--subscriber`도 받습니다. `update`에서는 `--clear-subscribers`로 제거할 수 있습니다.
## 데몬과 런타임
@@ -130,7 +145,9 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica runtime list` | 현재 워크스페이스의 런타임 나열 |
| `multica runtime usage` | 리소스 사용량 표시 |
| `multica runtime activity` | 최근 활동 로그 |
| `multica runtime update <id> ...` | 런타임의 구성 업데이트 |
| `multica runtime update <id> ...` | 런타임에서 CLI 업데이트 시작 |
| `multica runtime delete <id> [--cascade]` | 런타임 삭제, 필요하면 연결된 에이전트도 보관 |
| `multica runtime profile ...` | 사용자 지정 runtime profile과 로컬 경로 override 관리 |
## 기타

View File

@@ -54,14 +54,23 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica issue get <id>` | Show a single issue (accepts an issue key or a UUID) |
| `multica issue create --title "..."` | Create a new issue |
| `multica issue update <id> ...` | Update an issue (status, priority, assignee, etc.) |
| `multica issue assign <id> --agent <slug>` | Assign to an agent (triggers a task immediately) |
| `multica issue status <id> --set <status>` | Shortcut to change status |
| `multica issue assign <id> --to <name>` | Assign to a member, agent, or squad (assigning to an agent triggers a run) |
| `multica issue status <id> <status>` | Shortcut to change status |
| `multica issue search <query>` | Keyword search |
| `multica issue children <id>` | List sub-issues grouped by stage |
| `multica issue pull-requests <id>` | List linked pull requests and their status |
| `multica issue runs <id>` | Show agent runs on an issue |
| `multica issue run-messages <task-id>` | Show messages for one execution |
| `multica issue usage <id>` | Show aggregated token usage for an issue |
| `multica issue rerun <id>` | Re-enqueue a fresh task for the issue's current agent assignee |
| `multica issue cancel-task <task-id>` | Cancel a queued or running task |
| `multica issue comment <id> ...` | Nested: view / post comments |
| `multica issue comment resolve/unresolve <comment-id>` | Mark a comment thread resolved or unresolved |
| `multica issue subscriber <id> ...` | Nested: subscribe / unsubscribe |
| `multica issue metadata <id> ...` | Nested: read / write issue metadata |
| `multica issue label <id> ...` | Nested: manage labels on an issue |
| `multica project list/get/create/update/delete/status` | Project CRUD |
| `multica label list/create/update/delete` | Workspace label CRUD |
## Agents and skills
@@ -74,6 +83,8 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica agent archive <slug>` | Archive |
| `multica agent restore <slug>` | Restore an archived agent |
| `multica agent tasks <slug>` | Show an agent's task history |
| `multica agent avatar <slug>` | Upload an agent avatar |
| `multica agent env <slug> ...` | Read or replace an agent's custom environment variables |
| `multica agent skills ...` | Nested: attach / detach skills |
| `multica skill list/get/create/update/delete` | Skill CRUD |
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
@@ -123,6 +134,12 @@ See [Squads](/squads) for the full model.
| `multica autopilot delete <id>` | Delete |
| `multica autopilot runs <id>` | Show run history |
| `multica autopilot trigger <id>` | Trigger a run manually |
| `multica autopilot trigger-add/update/delete <id>` | Manage schedule or webhook triggers |
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | Rotate a webhook trigger URL |
`multica autopilot create/update` also accepts `--subscriber` to set default
subscribers for issues created by a `create_issue` autopilot; update accepts
`--clear-subscribers` to remove them.
## Daemon and runtimes
@@ -136,7 +153,9 @@ See [Squads](/squads) for the full model.
| `multica runtime list` | List runtimes in the current workspace |
| `multica runtime usage` | Show resource usage |
| `multica runtime activity` | Recent activity log |
| `multica runtime update <id> ...` | Update a runtime's configuration |
| `multica runtime update <id> ...` | Initiate a CLI update on a runtime |
| `multica runtime delete <id> [--cascade]` | Delete a runtime, optionally archiving bound agents |
| `multica runtime profile ...` | Manage custom runtime profiles and local path overrides |
## Miscellaneous

View File

@@ -54,14 +54,23 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica issue get <id>` | 查看单条 issue接受 issue key 或 UUID |
| `multica issue create --title "..."` | 创建新 issue |
| `multica issue update <id> ...` | 修改 issue状态、优先级、分配人等 |
| `multica issue assign <id> --agent <slug>` | 分配给智能体(立即触发任务 |
| `multica issue status <id> --set <status>` | 快捷改状态 |
| `multica issue assign <id> --to <name>` | 分配给成员、智能体或小队(分配给智能体会触发一次运行 |
| `multica issue status <id> <status>` | 快捷改状态 |
| `multica issue search <query>` | 关键字搜索 |
| `multica issue children <id>` | 按 stage 分组查看子 issue |
| `multica issue pull-requests <id>` | 查看关联 PR 及其状态 |
| `multica issue runs <id>` | 查看 issue 上智能体跑过的任务 |
| `multica issue run-messages <task-id>` | 查看某次执行的消息 |
| `multica issue usage <id>` | 查看单个 issue 聚合 token 用量 |
| `multica issue rerun <id>` | 给该 issue 当前的智能体分配人重新创建一条任务 |
| `multica issue cancel-task <task-id>` | 取消排队中或运行中的任务 |
| `multica issue comment <id> ...` | 嵌套:看 / 发评论 |
| `multica issue comment resolve/unresolve <comment-id>` | 标记评论线程已解决 / 未解决 |
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
| `multica issue metadata <id> ...` | 嵌套:读写 issue metadata |
| `multica issue label <id> ...` | 嵌套:管理 issue 上的 label |
| `multica project list/get/create/update/delete/status` | Project CRUD |
| `multica label list/create/update/delete` | Workspace label CRUD |
## 智能体和 Skill
@@ -74,6 +83,8 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica agent archive <slug>` | 归档 |
| `multica agent restore <slug>` | 恢复归档的智能体 |
| `multica agent tasks <slug>` | 查看智能体的任务历史 |
| `multica agent avatar <slug>` | 上传智能体头像 |
| `multica agent env <slug> ...` | 读取或替换智能体的 custom environment variables |
| `multica agent skills ...` | 嵌套:挂载 / 卸载 Skill |
| `multica skill list/get/create/update/delete` | Skill CRUD |
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
@@ -117,6 +128,10 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica autopilot delete <id>` | 删除 |
| `multica autopilot runs <id>` | 查看运行历史 |
| `multica autopilot trigger <id>` | 手动触发一次 |
| `multica autopilot trigger-add/update/delete <id>` | 管理 schedule 或 webhook trigger |
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | 轮换 webhook trigger URL |
`multica autopilot create/update` 还支持 `--subscriber`,为 `create_issue` 模式创建的 issue 设置默认订阅人;`update` 支持 `--clear-subscribers` 清空默认订阅人。
## 守护进程和运行时
@@ -130,7 +145,9 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica runtime list` | 列出当前工作区的 runtime |
| `multica runtime usage` | 查看资源使用情况 |
| `multica runtime activity` | 近期活动记录 |
| `multica runtime update <id> ...` | 更新 runtime 配置 |
| `multica runtime update <id> ...` | 在某个 runtime 上触发 CLI 更新 |
| `multica runtime delete <id> [--cascade]` | 删除 runtime可选择同时归档绑定的智能体 |
| `multica runtime profile ...` | 管理自定义 runtime profile 和本机路径覆盖 |
## 杂项

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 秒ごとにハートビートを送信**し続けます
@@ -58,6 +58,29 @@ graph TD
- **同じデーモン、ワークスペース、ツールは、ちょうど 1 つのランタイムを作ります** — デーモンを再起動しても重複レコードは生まれません
- Multica UI の**ランタイム**ページがこれらの行を一覧表示します
## カスタムランタイムプロファイル
組み込み provider 検出は一般的なツールを対象にしていますが、Multica が対応するプロトコルファミリーで動作し、ワークスペース固有の起動コマンドが必要な AI CLI には **custom runtime profile** を定義できます。Runtimes UI か CLI で管理します。
```bash
multica runtime profile list
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
multica runtime profile update <profile-id> --command-name agent
multica runtime profile delete <profile-id>
```
入力するコマンドは shell 文字列ではなく argv 形式です。Multica は実行ファイル名と固定引数を保存し、デーモンは `exec.Command(command_name, fixed_args...)` で直接起動します。通常の引数、引用符、バックスラッシュエスケープは使えますが、パイプ、リダイレクト、`&&`、`;`、バッククォート、`$VAR` / `$(...)` 展開は使えません。shell の動作が必要な場合は wrapper script を使ってください。
現在、コマンドと引数の解析は Runtimes UI が担当します。CLI の profile コマンドは profile 行とローカルのパス上書きを管理します。
デスクトップアプリから起動したデーモンが、ターミナルでは動くコマンドを見つけられない場合は、そのマシンで絶対パスを固定できます。
```bash
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
multica runtime profile unset-path <profile-id>
```
profile のコマンドや引数の変更は、デーモンが再登録された後に新しく取得するタスクへ適用されます。実行中のタスクは開始時の引数を使い続けます。混在デプロイでは、先に server をアップグレードしてから daemon を順次更新することを推奨します。`fixed_args` の入力は server 側の Runtimes UI が担い、`failed_profiles` 登録レポートも server が表示します。古いコンポーネントは未知のフィールドを明示的に失敗させず無視することがあるため、server を先に更新すると rollout を観測しやすくなります。
<Callout type="info">
**クラウドランタイムが近日提供されます。** 現在は順番待ちリストの段階です。提供が始まれば、ローカルのデーモンを実行せずに Multica Cloud 上で直接エージェントタスクを実行できるようになります。[ダウンロードページ](https://multica.ai/download)でメールアドレスを登録すると通知を受け取れます。
</Callout>

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초마다 하트비트를 전송**합니다
@@ -58,6 +58,29 @@ graph TD
- **같은 데몬, 워크스페이스, 도구는 정확히 하나의 런타임을 만듭니다.** 데몬을 재시작해도 중복 레코드가 생기지 않습니다
- Multica UI의 **런타임** 페이지가 이 행들을 나열합니다
## 사용자 지정 런타임 프로필
기본 provider 감지는 일반적인 도구를 다룹니다. 팀에서 Multica가 지원하는 프로토콜 패밀리로 동작하지만 워크스페이스별 실행 명령이 필요한 AI CLI를 쓰는 경우에는 **custom runtime profile**을 정의할 수 있습니다. Runtimes UI 또는 CLI에서 관리합니다.
```bash
multica runtime profile list
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
multica runtime profile update <profile-id> --command-name agent
multica runtime profile delete <profile-id>
```
명령은 shell 문자열이 아니라 argv 형식입니다. Multica는 실행 파일 이름과 고정 인수를 저장하고, 데몬은 `exec.Command(command_name, fixed_args...)`로 직접 실행합니다. 일반 인수, 따옴표, 백슬래시 이스케이프는 지원하지만 파이프, 리다이렉션, `&&`, `;`, 백틱, `$VAR` / `$(...)` 확장은 지원하지 않습니다. shell 동작이 필요하면 wrapper script를 사용하세요.
현재 명령과 인수 파싱은 Runtimes UI가 담당합니다. CLI의 profile 명령은 profile 행과 로컬 경로 override를 관리합니다.
데스크톱 앱에서 시작한 데몬이 터미널에서는 동작하는 명령을 찾지 못한다면, 해당 기기에서 절대 경로를 고정할 수 있습니다.
```bash
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
multica runtime profile unset-path <profile-id>
```
profile의 명령이나 인수 변경은 데몬이 다시 등록된 뒤 새로 가져오는 작업부터 적용됩니다. 이미 실행 중인 작업은 시작할 때의 인수를 유지합니다. 혼합 배포에서는 server를 먼저 업그레이드한 뒤 daemon을 순차적으로 업데이트하는 것을 권장합니다. `fixed_args` 입력은 server 쪽 Runtimes UI가 담당하고, `failed_profiles` 등록 보고도 server가 표시합니다. 오래된 구성요소는 알 수 없는 필드를 명확히 실패시키기보다 무시할 수 있으므로 server를 먼저 올리면 rollout을 더 잘 관찰할 수 있습니다.
<Callout type="info">
**클라우드 런타임이 곧 제공됩니다.** 현재는 대기자 명단 단계입니다. 제공이 시작되면 로컬 데몬을 실행하지 않고도 Multica Cloud에서 직접 에이전트 작업을 실행할 수 있습니다. [다운로드 페이지](https://multica.ai/download)에서 이메일로 등록하면 알림을 받을 수 있습니다.
</Callout>

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**
@@ -58,6 +58,44 @@ Key points:
- **The same daemon, workspace, and tool produces exactly one runtime** — restarting the daemon never creates duplicate records
- The **Runtimes** page in the Multica UI lists these rows
## Custom runtime profiles
Built-in provider detection covers the common tools, but teams can also define
**custom runtime profiles** for AI CLIs that speak one of Multica's supported
protocol families and need a workspace-specific launch command. Profiles are
managed from the Runtimes UI or the CLI:
```bash
multica runtime profile list
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
multica runtime profile update <profile-id> --command-name agent
multica runtime profile delete <profile-id>
```
The command is argv-oriented, not a shell string. Multica stores an executable
name plus fixed arguments, then the daemon launches it directly with
`exec.Command(command_name, fixed_args...)`. Plain arguments, quotes, and
backslash escaping are supported; pipes, redirects, `&&`, `;`, backticks, and
`$VAR` / `$(...)` expansion are not. Use a wrapper script when the runtime needs
shell behavior. Today the Runtimes UI owns command-and-argument parsing; the CLI
profile commands manage the profile row and local path overrides.
If a desktop-launched daemon cannot find a command that works in your terminal,
pin the absolute path on that machine:
```bash
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
multica runtime profile unset-path <profile-id>
```
Profile command or argument edits apply to newly claimed tasks after the daemon
re-registers. Running tasks keep the launch arguments they started with. For
mixed deployments, upgrade the server before rolling out newer daemons: the
server-side Runtimes UI stores `fixed_args`, and the server is what surfaces
`failed_profiles` registration reports. Older components may ignore fields they
do not understand instead of failing loudly, so treating the server upgrade as
the first step keeps the rollout observable.
<Callout type="info">
**Cloud runtimes are coming**, currently in a waitlist phase. Once available, you'll be able to execute agent tasks directly on Multica Cloud without running a local daemon. Sign up with your email on the [download page](https://multica.ai/download) to get notified.
</Callout>

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 秒发一次心跳**
@@ -58,6 +58,29 @@ graph TD
- **同一个守护进程在同一个工作区同一款工具上只会有一条运行时**——重启守护进程不会产生重复记录
- Multica 界面的 **Runtimes** 页面列的就是这些行
## 自定义运行时配置
内置 provider 探测覆盖常见工具;如果团队有一款兼容 Multica 已支持协议族、但需要工作区级启动命令的 AI CLI可以定义 **custom runtime profile**。你可以在 Runtimes UI 里管理,也可以用 CLI
```bash
multica runtime profile list
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
multica runtime profile update <profile-id> --command-name agent
multica runtime profile delete <profile-id>
```
这里填写的是 argv 风格命令,不是 shell 字符串。Multica 存的是可执行文件名和固定参数,守护进程会直接用 `exec.Command(command_name, fixed_args...)` 启动。支持普通参数、引号和反斜杠转义;不支持管道、重定向、`&&`、`;`、反引号、`$VAR` / `$(...)` 展开。需要 shell 行为时,用 wrapper script 包一层。
目前命令和参数的解析入口在 Runtimes UICLI 的 profile 命令负责管理 profile 记录和本机路径覆盖。
如果桌面应用拉起的守护进程找不到你在终端里能运行的命令,可以在这台机器上固定绝对路径:
```bash
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
multica runtime profile unset-path <profile-id>
```
修改 profile 的命令或参数后,已开始的任务仍使用启动时的参数;守护进程重新注册后,新领取的任务才会使用新配置。混合版本部署时,建议先升级 server再逐步升级 daemon`fixed_args` 的录入在 server 侧 Runtimes UI`failed_profiles` 注册报告也由 server 展示。旧组件可能会忽略自己不认识的字段,而不是明确报错;先升 server 能让 rollout 更可观察。
<Callout type="info">
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
</Callout>

View File

@@ -181,9 +181,19 @@ S3 の前段に CloudFront を置く場合、3 つの変数が適用されます
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 最大同時タスク数 |
| `MULTICA_<PROVIDER>_PATH` | CLI 名に一致 | 各 AI コーディングツールの実行ファイルへのパス(例: `MULTICA_CLAUDE_PATH` |
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI コーディングツールのデフォルトモデル |
| `MULTICA_<PROVIDER>_ARGS` | 空 | バックエンドごとのデーモン全体のデフォルト CLI 引数。各タスクに対し、各エージェント自身の `custom_args` より前に適用される。`MULTICA_CLAUDE_ARGS`、`MULTICA_CODEX_ARGS`、`MULTICA_CODEBUDDY_ARGS` をサポート |
各パラメータがデーモンの動作にどう影響するかの完全な説明は、[デーモンとランタイム](/daemon-runtimes)を参照してください。
### デフォルトのエージェント引数(`MULTICA_<PROVIDER>_ARGS`
バックエンドに対して**フリート全体のデフォルト**となる CLI フラグの層を設定します。各エージェントの `custom_args` を個別に編集することなく、デーモン上のすべてのエージェントにデフォルトのコスト・リソースのベースライン(例: `--max-turns`)を適用できる便利な手段です。これはデフォルトの層であり、超えられない上限ではありません。各エージェント自身の `custom_args` が後から追加され、これを上書きできます(下記の**優先順位**を参照)。
- **優先順位:** デフォルト引数が先に適用され、その後に各エージェント自身の `custom_args` が追加されます。値を取るフラグについては、下流 CLI 自身の引数パーサーが最終的な勝者を決めます(多くのツールでは最後の出現が優先)。そのため個々のエージェントはデーモンのデフォルトを引き上げられますが、エージェントが上書きしない箇所ではデフォルトが引き続き有効です。
- **パース:** 値は POSIX シェルワード規則で分割されるため、クォートが使えます——`MULTICA_CLAUDE_ARGS='--append-system-prompt "multi word"'` は 2 つのトークンに解析されます。
- **安全性:** デフォルト引数の層と各エージェントの `custom_args` の層は、いずれも同じ blocked-flags フィルターを通過します。そのためプロトコル上重要なフラグClaude の `-p`、`--output-format`、`--input-format`、`--permission-mode`、`--mcp-config`、および Codex の `--listen` など)はどちらの層からも注入できません。
- **未設定・空** の場合は動作に変化はありません。
## フロントエンドのアクセス制御
| 変数 | デフォルト | 説明 |

View File

@@ -181,9 +181,19 @@ S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLO
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 최대 동시 작업 수 |
| `MULTICA_<PROVIDER>_PATH` | CLI 이름과 일치 | 각 AI 코딩 도구 실행 파일의 경로 (예: `MULTICA_CLAUDE_PATH`) |
| `MULTICA_<PROVIDER>_MODEL` | 비어 있음 | 각 AI 코딩 도구의 기본 모델 |
| `MULTICA_<PROVIDER>_ARGS` | 비어 있음 | 백엔드별 데몬 전역 기본 CLI 인자. 각 작업에 대해 각 에이전트 자체의 `custom_args`보다 먼저 적용됩니다. `MULTICA_CLAUDE_ARGS`, `MULTICA_CODEX_ARGS`, `MULTICA_CODEBUDDY_ARGS`를 지원 |
각 파라미터가 데몬 동작에 어떻게 영향을 미치는지에 대한 전체 설명은 [데몬과 런타임](/daemon-runtimes)을 참고하세요.
### 기본 에이전트 인자 (`MULTICA_<PROVIDER>_ARGS`)
백엔드에 대해 **플릿 전역 기본값** 계층의 CLI 플래그를 설정합니다. 각 에이전트의 `custom_args`를 일일이 수정하지 않고도 데몬의 모든 에이전트에 기본 비용·리소스 기준선(예: `--max-turns`)을 적용할 수 있는 편리한 방법입니다. 이는 넘을 수 없는 상한이 아니라 기본 계층입니다. 각 에이전트 자체의 `custom_args`가 뒤에 추가되어 이를 덮어쓸 수 있습니다(아래 **우선순위** 참고).
- **우선순위:** 기본 인자가 먼저 적용되고, 그다음 각 에이전트 자체의 `custom_args`가 추가됩니다. 값을 받는 플래그의 경우 다운스트림 CLI 자체의 인자 파서가 최종 적용 값을 결정합니다(대부분의 도구는 마지막 항목이 우선). 따라서 개별 에이전트는 데몬 기본값을 높일 수 있지만, 에이전트가 덮어쓰지 않은 부분에는 기본값이 계속 적용됩니다.
- **파싱:** 값은 POSIX 셸 단어 규칙으로 분할되므로 따옴표를 사용할 수 있습니다 — `MULTICA_CLAUDE_ARGS='--append-system-prompt "multi word"'`는 두 개의 토큰으로 파싱됩니다.
- **안전성:** 기본 인자 계층과 에이전트별 `custom_args` 계층 모두 동일한 blocked-flags 필터를 통과합니다. 따라서 프로토콜에 중요한 플래그(Claude의 `-p`, `--output-format`, `--input-format`, `--permission-mode`, `--mcp-config` 및 Codex의 `--listen` 등)는 어느 계층을 통해서도 주입할 수 없습니다.
- **미설정/빈 값**은 동작에 변화가 없음을 의미합니다.
## 프론트엔드 액세스 제어
| 변수 | 기본값 | 설명 |

View File

@@ -181,9 +181,19 @@ The daemon runs on the user's local machine, and its config is read from local e
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | Max concurrent tasks |
| `MULTICA_<PROVIDER>_PATH` | matches the CLI name | Path to each AI coding tool's executable (for example `MULTICA_CLAUDE_PATH`) |
| `MULTICA_<PROVIDER>_MODEL` | empty | Default model for each AI coding tool |
| `MULTICA_<PROVIDER>_ARGS` | empty | Daemon-wide default CLI arguments for a backend, applied to every task before each agent's own `custom_args`. Supported for `MULTICA_CLAUDE_ARGS`, `MULTICA_CODEX_ARGS`, and `MULTICA_CODEBUDDY_ARGS` |
For a full explanation of how each parameter affects daemon behavior, see [Daemon and runtimes](/daemon-runtimes).
### Default agent arguments (`MULTICA_<PROVIDER>_ARGS`)
These set a **fleet-wide default** layer of CLI flags for a backend — a convenient way to apply a default cost or resource baseline (for example `--max-turns`) across every agent on a daemon without editing each agent's `custom_args` individually. This is a default layer, not a hard ceiling: per-agent `custom_args` are appended afterward and can override it (see **Precedence** below).
- **Precedence:** the default args are applied first, then each agent's own `custom_args` are appended after. For flags that take a value, the downstream CLI's own argument parser decides the winner (last occurrence wins for most tools), so an individual agent can raise a daemon default but the default still applies wherever the agent doesn't override it.
- **Parsing:** the value is split with POSIX shell-word rules, so quoting works — `MULTICA_CLAUDE_ARGS='--append-system-prompt "multi word"'` parses into two tokens.
- **Safety:** both the default-args and per-agent `custom_args` layers pass through the same blocked-flags filter, so protocol-critical flags (such as `-p`, `--output-format`, `--input-format`, `--permission-mode`, `--mcp-config` for Claude, and `--listen` for Codex) cannot be injected through either layer.
- **Unset/empty** means no change to behavior.
## Frontend access control
| Variable | Default | Description |

View File

@@ -184,9 +184,19 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap
| `MULTICA_AGENT_TOOL_WATCHDOG` | `2h` | 工具在途时的静默上限:某个工具调用发出后长时间无任何输出(疑似卡死的子进程)这么久就 force-stop。`0` = 关闭该兜底(在途工具永不被停)|
| `MULTICA_<PROVIDER>_PATH` | 对应 CLI 名 | 各 AI 编程工具的可执行文件路径(如 `MULTICA_CLAUDE_PATH`|
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI 编程工具的默认模型 |
| `MULTICA_<PROVIDER>_ARGS` | 空 | 守护进程级的默认 CLI 参数,作用于该后端的每个任务,并排在各智能体自身的 `custom_args` 之前。支持 `MULTICA_CLAUDE_ARGS`、`MULTICA_CODEX_ARGS`、`MULTICA_CODEBUDDY_ARGS` |
完整解释每个参数对守护进程行为的影响,见 [守护进程与运行时](/daemon-runtimes)。
### 默认智能体参数(`MULTICA_<PROVIDER>_ARGS`
为某个后端设置一层**全机队默认**的 CLI 参数——可以方便地给一台守护进程上的所有智能体应用一个默认的成本或资源基线(例如 `--max-turns`),而不必逐个修改每个智能体的 `custom_args`。这是一层默认值,而不是不可突破的硬上限:每个智能体自己的 `custom_args` 会追加在后面,并可以覆盖它(见下方**优先级**)。
- **优先级:** 默认参数先生效,随后追加各智能体自己的 `custom_args`。对于带取值的参数,由下游 CLI 自己的参数解析器决定最终生效值(多数工具采用「后者覆盖」),因此单个智能体可以调高某个守护进程默认值,但在智能体没有覆盖的地方,默认值依然生效。
- **解析:** 取值按 POSIX shell-word 规则切分,因此引号可用——`MULTICA_CLAUDE_ARGS='--append-system-prompt "multi word"'` 会解析为两个 token。
- **安全:** 默认参数层和各智能体的 `custom_args` 层都会经过同一套 blocked-flags 过滤,因此协议关键标志(如 Claude 的 `-p`、`--output-format`、`--input-format`、`--permission-mode`、`--mcp-config`,以及 Codex 的 `--listen`)无法从任何一层注入。
- **未设置 / 为空** 表示不改变行为。
## 前端访问控制
| 环境变量 | 默认值 | 说明 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
---
title: エージェントランタイムをインストールする
description: Multica はあなたのマシンにインストールされている AI コーディングツールを駆動します。このページでは、デーモンがそれらを検出できるように、サポートされている 12 種のツールをそれぞれインストールする方法を説明します。
description: Multica はあなたのマシンにインストールされている AI コーディングツールを駆動します。このページでは、デーモンがそれらを検出できるように、サポートされている 13 種のツールをそれぞれインストールする方法を説明します。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica における**ランタイム**とは、あなたのマシンのデーモンと、デーモンが `PATH` で見つけた AI コーディングツール 1 つが組になったものです。オンボーディングの「ランタイムを接続」ステップで **No supported tools detected** と表示される場合、それはデーモンが `PATH` をスキャンしたものの、駆動方法を知っている 12 種のツールのいずれも見つけられなかったことを意味します。以下のツールのいずれか(または複数)をインストールしてから、そのステップに戻って再スキャンしてください — 数秒以内にランタイムが表示されます。
Multica における**ランタイム**とは、あなたのマシンのデーモンと、デーモンが `PATH` で見つけた AI コーディングツール 1 つが組になったものです。オンボーディングの「ランタイムを接続」ステップで **No supported tools detected** と表示される場合、それはデーモンが `PATH` をスキャンしたものの、駆動方法を知っている 13 種のツールのいずれも見つけられなかったことを意味します。以下のツールのいずれか(または複数)をインストールしてから、そのステップに戻って再スキャンしてください — 数秒以内にランタイムが表示されます。
このページは次のドキュメントのインストール側の補完ドキュメントです。
@@ -31,13 +31,13 @@ multica daemon restart
または、デスクトップアプリではアプリを再起動するだけで構いません。デーモンは起動するたびに `PATH` を再スキャンします。
## サポートされている 12 種のツール
## サポートされている 13 種のツール
おおよそ利用者の多い順に並べています。すでに認証情報を持っているものを選んで使ってください — 12 種すべてをインストールする必要はありません。
おおよそ利用者の多い順に並べています。すでに認証情報を持っているものを選んで使ってください — 13 種すべてをインストールする必要はありません。
### Claude Code (Anthropic)
最も完全な連携です。セッション再開が動作し、MCP が動作し、**11 種のうちエージェントの `mcp_config` フィールドを実際に読み込む唯一のツール**です(詳しくは[マトリクス](/providers#mcp-configuration-only-claude-code-actually-reads-it)を参照)。
最も完全な連携です。セッション再開が動作し、MCP が動作し、エージェントの `mcp_config` フィールドを消費します(詳しくは[マトリクス](/providers)を参照)。
| | |
|---|---|
@@ -77,16 +77,6 @@ Cursor エディタに対応する CLI です。**セッション再開は動作
| 認証 | CLI を通じたブラウザベースの GitHub ログイン。 |
| 備考 | ログインしているアカウントに有効な GitHub Copilot サブスクリプションが必要です。 |
### Gemini (Google)
Gemini 2.5 および 3 シリーズをサポートします。セッション再開と MCP はありません — 単発のタスクに適しています。
| | |
|---|---|
| デーモンが探す名前 | `gemini` |
| インストール | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) の公式ガイドに従ってください。標準的な方法は npm パッケージ `@google/gemini-cli` です。 |
| 認証 | `gemini` を実行すると Google アカウントのログインを求められるか、`GEMINI_API_KEY` を設定してください。 |
### OpenCode (SST)
オープンソースの CLI エージェントです。独自の設定ファイルから利用可能なモデルを動的に発見します — 自分のモデルカタログを持ち込みたいユーザーによく合います。
@@ -147,6 +137,26 @@ ACP プロトコルのエージェントですKimi とトランスポート
| インストール | Inflection の CLI ドキュメント [pi.ai](https://pi.ai/) を参照してください。 |
| 認証 | ベンダーのドキュメントに従います。 |
### CodeBuddy (Tencent)
Claude Code 互換の CLI エージェントです。Multica は Claude Code と同じ stream-json プロトコルで駆動します: セッション再開は `--resume` で動作し、MCP 構成は `--mcp-config` で渡され、スキルは `.claude/skills/` に配置されます。モデルは動的に探索されます。
| | |
|---|---|
| デーモンが探す名前 | `codebuddy` |
| インストール | 公式 CLI ドキュメント [codebuddy.ai/cli](https://www.codebuddy.ai/cli) を参照してください。 |
| 認証 | ベンダーのドキュメントに従います。 |
### Qoder (Alibaba)
stdio 上で ACP プロトコルを使用するエージェント型のコーディング CLI ですHermes、Kimi、Kiro CLI とトランスポートを共有します)。セッション再開は ACP `session/resume` を通じて動作し、MCP 構成は ACP `mcpServers` として渡され、モデル選択は動的に探索され、スキルは `.qoder/skills/` にコピーされます。
| | |
|---|---|
| デーモンが探す名前 | `qodercli` |
| インストール | 公式 CLI ドキュメント [qoder.com/cli](https://qoder.com/cli) を参照してください。 |
| 認証 | ベンダーのドキュメントに従います。 |
### Antigravity (Google)
Google の Antigravity CLI`agy`です。Google の Antigravity サービスと組になり、Gemini ベースのモデルを実行します。セッション再開は `--conversation <id>` を通じて動作し、デーモンが CLI のログファイルからこれをキャプチャします。モデル選択は Antigravity CLI 自体の内部で管理されます — Multica はこのプロバイダーに対してエージェントごとのモデルピッカーを無効にします。スキルは `.agents/skills/` に書き込まれますCLI が Gemini CLI のワークスペーススキルレイアウトを継承します — [Antigravity ドキュメント](https://antigravity.google/docs/gcli-migration)を参照)。

View File

@@ -1,11 +1,11 @@
---
title: 에이전트 런타임 설치하기
description: Multica는 사용자 기기에 설치된 AI 코딩 도구를 구동합니다. 이 페이지에서는 데몬이 도구를 감지할 수 있도록 지원되는 12종의 도구를 각각 설치하는 방법을 설명합니다.
description: Multica는 사용자 기기에 설치된 AI 코딩 도구를 구동합니다. 이 페이지에서는 데몬이 도구를 감지할 수 있도록 지원되는 13종의 도구를 각각 설치하는 방법을 설명합니다.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica에서 **런타임**이란 사용자 기기의 데몬과, 데몬이 `PATH`에서 찾아낸 AI 코딩 도구 하나가 짝을 이룬 것입니다. 온보딩의 "런타임 연결" 단계에서 **지원되는 도구를 감지하지 못했습니다**라고 표시된다면, 데몬이 `PATH`를 스캔했지만 구동 방법을 아는 12종의 도구 중 어느 것도 찾지 못했다는 뜻입니다. 아래 도구 중 하나(또는 여러 개)를 설치한 다음 해당 단계로 돌아와 다시 스캔하세요. 몇 초 안에 런타임이 나타납니다.
Multica에서 **런타임**이란 사용자 기기의 데몬과, 데몬이 `PATH`에서 찾아낸 AI 코딩 도구 하나가 짝을 이룬 것입니다. 온보딩의 "런타임 연결" 단계에서 **지원되는 도구를 감지하지 못했습니다**라고 표시된다면, 데몬이 `PATH`를 스캔했지만 구동 방법을 아는 13종의 도구 중 어느 것도 찾지 못했다는 뜻입니다. 아래 도구 중 하나(또는 여러 개)를 설치한 다음 해당 단계로 돌아와 다시 스캔하세요. 몇 초 안에 런타임이 나타납니다.
이 페이지는 다음 문서의 설치 측면 동반 문서입니다.
@@ -31,9 +31,9 @@ multica daemon restart
또는 데스크톱 앱에서는 앱을 다시 실행하기만 하면 됩니다. 데몬은 시작될 때마다 `PATH`를 다시 스캔합니다.
## 지원되는 12종의 도구
## 지원되는 13종의 도구
대략 많이 쓰이는 순서대로 나열했습니다. 이미 자격 증명을 갖고 있는 것을 골라 사용하세요. 12종을 모두 설치할 필요는 없습니다.
대략 많이 쓰이는 순서대로 나열했습니다. 이미 자격 증명을 갖고 있는 것을 골라 사용하세요. 13종을 모두 설치할 필요는 없습니다.
### Claude Code (Anthropic)
@@ -77,16 +77,6 @@ Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니
| 인증 | CLI를 통한 브라우저 기반 GitHub 로그인. |
| 비고 | 로그인한 계정에 활성화된 GitHub Copilot 구독이 필요합니다. |
### Gemini (Google)
Gemini 2.5 및 3 시리즈를 지원합니다. 세션 재개와 MCP는 없습니다 — 단발성 작업에 적합합니다.
| | |
|---|---|
| 데몬이 찾는 이름 | `gemini` |
| 설치 | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)의 공식 가이드를 따르세요. 일반적인 방법은 npm 패키지 `@google/gemini-cli`입니다. |
| 인증 | `gemini`를 실행하면 Google 계정 로그인을 요청하거나, `GEMINI_API_KEY`를 설정하세요. |
### OpenCode (SST)
오픈 소스 CLI 에이전트입니다. 자체 설정 파일에서 사용 가능한 모델을 동적으로 발견합니다 — 자신만의 모델 카탈로그를 직접 가져오려는 사용자에게 잘 맞습니다. `OPENCODE_CONFIG_CONTENT`를 통해 에이전트의 `mcp_config` 필드도 소비합니다.
@@ -147,6 +137,26 @@ ACP 프로토콜 에이전트입니다(Kimi와 전송 방식을 공유). 세션
| 설치 | Inflection의 CLI 문서 [pi.ai](https://pi.ai/)를 참고하세요. |
| 인증 | 공급사 문서에 따릅니다. |
### CodeBuddy (Tencent)
Claude Code 호환 CLI 에이전트입니다. Multica는 Claude Code와 동일한 stream-json 프로토콜로 구동합니다: 세션 재개는 `--resume`로 동작하고, MCP 구성은 `--mcp-config`로 전달되며, 스킬은 `.claude/skills/`에 배치됩니다. 모델은 동적으로 탐색됩니다.
| | |
|---|---|
| 데몬이 찾는 이름 | `codebuddy` |
| 설치 | 공식 CLI 문서 [codebuddy.ai/cli](https://www.codebuddy.ai/cli)를 참고하세요. |
| 인증 | 공급사 문서에 따릅니다. |
### Qoder (Alibaba)
stdio 위에서 ACP 프로토콜을 사용하는 에이전트형 코딩 CLI입니다(Hermes, Kimi, Kiro CLI와 전송 계층을 공유합니다). 세션 재개는 ACP `session/resume`를 통해 동작하고, MCP 구성은 ACP `mcpServers`로 전달되며, 모델 선택은 동적으로 탐색되고, 스킬은 `.qoder/skills/`로 복사됩니다.
| | |
|---|---|
| 데몬이 찾는 이름 | `qodercli` |
| 설치 | 공식 CLI 문서 [qoder.com/cli](https://qoder.com/cli)를 참고하세요. |
| 인증 | 공급사 문서에 따릅니다. |
### Antigravity (Google)
Google의 Antigravity CLI(`agy`)입니다. Google의 Antigravity 서비스와 짝을 이루며 Gemini 기반 모델을 실행합니다. 세션 재개는 `--conversation <id>`를 통해 작동하며, 데몬이 CLI 로그 파일에서 이를 캡처합니다. 모델 선택은 Antigravity CLI 자체 내부에서 관리됩니다 — Multica는 이 제공자에 대해 에이전트별 모델 선택기를 비활성화합니다. 스킬은 `.agents/skills/`에 기록됩니다(CLI가 Gemini CLI의 워크스페이스 스킬 레이아웃을 상속함 — [Antigravity 문서](https://antigravity.google/docs/gcli-migration) 참고).

View File

@@ -1,11 +1,11 @@
---
title: Install an agent runtime
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 12 supported tools so the daemon can detect them.
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 13 supported tools so the daemon can detect them.
---
import { Callout } from "fumadocs-ui/components/callout";
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 12 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 13 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
This page is the install-side companion to:
@@ -31,9 +31,9 @@ multica daemon restart
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
## The 12 supported tools
## The 13 supported tools
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 12.
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 13.
### Claude Code (Anthropic)
@@ -77,16 +77,6 @@ Model routing goes through your GitHub account entitlement — the tool doesn't
| Authentication | Browser-based GitHub login through the CLI. |
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
### Gemini (Google)
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
| | |
|---|---|
| Daemon looks for | `gemini` |
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
### OpenCode (SST)
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog. Consumes the agent's `mcp_config` field through `OPENCODE_CONFIG_CONTENT`.
@@ -147,16 +137,36 @@ Minimalist. **Session resumption is unusual** — the resume id is the path to a
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
| Authentication | Per the vendor's docs. |
### CodeBuddy (Tencent)
A Claude Codecompatible CLI agent. Multica drives it with the same stream-json protocol as Claude Code: session resumption works via `--resume`, MCP config is passed through `--mcp-config`, and skills land in `.claude/skills/`. Models are discovered dynamically.
| | |
|---|---|
| Daemon looks for | `codebuddy` |
| Install | See the official CLI docs at [codebuddy.ai/cli](https://www.codebuddy.ai/cli). |
| Authentication | Per the vendor's docs. |
### Qoder (Alibaba)
Agentic coding CLI using the ACP protocol over stdio (shares the transport with Hermes, Kimi, and Kiro CLI). Session resumption works through ACP `session/resume`, MCP config is passed through ACP `mcpServers`, model selection is discovered dynamically, and skills are copied into `.qoder/skills/`.
| | |
|---|---|
| Daemon looks for | `qodercli` |
| Install | See the official CLI docs at [qoder.com/cli](https://qoder.com/cli). |
| Authentication | Per the vendor's docs. |
### 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

@@ -1,11 +1,11 @@
---
title: 安装一个 Agent 运行时
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 12 款工具,让守护进程能扫到。
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 13 款工具,让守护进程能扫到。
---
import { Callout } from "fumadocs-ui/components/callout";
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 12 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 13 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
这一页是装机的入口,和它配套的是:
@@ -31,9 +31,9 @@ multica daemon restart
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
## 12 款支持的工具
## 13 款支持的工具
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 12 个全装。
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 13 个全装。
### Claude CodeAnthropic
@@ -77,16 +77,6 @@ Cursor 编辑器的 CLI 对应物。**会话续接可用**——当前 Cursor Ag
| 认证 | CLI 里走 GitHub 浏览器登录。 |
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
### GeminiGoogle
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
| | |
|---|---|
| 守护进程扫描 | `gemini` |
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
### OpenCodeSST
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。会通过 `OPENCODE_CONFIG_CONTENT` 消费 agent 配置里的 `mcp_config` 字段。
@@ -147,16 +137,36 @@ ACP 协议 agent和 Kimi 共享传输层。会话续接可用MCP 配置
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
| 认证 | 按厂商文档。 |
### CodeBuddyTencent
一款兼容 Claude Code 的 CLI agent。Multica 用和 Claude Code 一样的 stream-json 协议驱动它:会话续接通过 `--resume` 可用MCP 配置通过 `--mcp-config` 传入skill 放在 `.claude/skills/`。模型为动态发现。
| | |
|---|---|
| 守护进程扫描 | `codebuddy` |
| 安装 | 看官方 CLI 文档 [codebuddy.ai/cli](https://www.codebuddy.ai/cli)。 |
| 认证 | 按厂商文档。 |
### QoderAlibaba
一款 agentic 编程 CLI在 stdio 上使用 ACP 协议(和 Hermes、Kimi、Kiro CLI 共享传输层)。会话续接通过 ACP `session/resume` 工作MCP 配置通过 ACP `mcpServers` 传入模型为动态发现skill 复制到 `.qoder/skills/`。
| | |
|---|---|
| 守护进程扫描 | `qodercli` |
| 安装 | 看官方 CLI 文档 [qoder.com/cli](https://qoder.com/cli)。 |
| 认证 | 按厂商文档。 |
### 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

@@ -19,6 +19,7 @@
"squads",
"---智能体怎么运行---",
"daemon-runtimes",
"install-agent-runtime",
"tasks",
"providers",
"---与智能体协作---",
@@ -29,8 +30,10 @@
"---收件箱---",
"inbox",
"---集成---",
"channels",
"github-integration",
"lark-bot-integration",
"slack-bot-integration",
"---自部署运维---",
"environment-variables",
"auth-setup",

View File

@@ -28,12 +28,15 @@ The default resource type — checked out per task into an isolated worktree:
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/owner/repo",
"ref": "release/v2",
"default_branch_hint": "main"
}
}
```
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
`ref` is optional — if present, `multica repo checkout <url>` uses it as the default branch, tag, or commit for tasks in this project. An explicit `multica repo checkout <url> --ref <other-ref>` still wins for that one checkout.
`default_branch_hint` is optional prompt context. It is not used for checkout; use `ref` when the project should pin a branch, tag, or SHA.
## Resource type: `local_directory`
@@ -168,6 +171,7 @@ multica project create \
# Manage resources later
multica project resource list <project-id>
multica project resource add <project-id> --type github_repo --url <url>
multica project resource add <project-id> --type github_repo --url <url> --ref <branch-or-sha>
multica project resource remove <project-id> <resource-id>
# Generic escape hatch for any resource_type the server understands —
@@ -251,7 +255,7 @@ The repo list shown to the agent (`## Repositories` block in `CLAUDE.md` / `AGEN
This keeps the agent's working set tight: when a project is explicit about its repos, that's the authoritative answer. The structured resource list at `.multica/project/resources.json` always carries the full set, so a skill that wants to inspect everything still can.
The daemon mirrors this on the checkout side: when a task arrives with project-scoped `github_repo` URLs, those URLs are merged into the per-workspace allowlist *and* synced into the local repo cache before the agent spawns. So a project repo URL that isn't bound at the workspace level is still a valid argument to `multica repo checkout` — the daemon won't reject it as "not configured." The allowlist split is internal: workspace-bound URLs and task-scoped URLs are tracked separately, so a workspace-repos refresh doesn't accidentally revoke a project URL mid-run.
The daemon mirrors this on the checkout side: when a task arrives with project-scoped `github_repo` URLs, those URLs are merged into the per-workspace allowlist *and* synced into the local repo cache before the agent spawns. So a project repo URL that isn't bound at the workspace level is still a valid argument to `multica repo checkout` — the daemon won't reject it as "not configured." If the project resource includes `ref`, that ref becomes the default for `multica repo checkout <url>` during that task; passing `--ref` to the checkout command overrides it. The allowlist split is internal: workspace-bound URLs and task-scoped URLs are tracked separately, so a workspace-repos refresh doesn't accidentally revoke a project URL mid-run.
## What's intentionally **not** in scope here

View File

@@ -1,11 +1,11 @@
---
title: AI コーディングツール対応表
description: Multica は 12 個の AI コーディングツールをサポートしています。すべて同じインターフェースを実装していますが、機能の詳細は大きく異なります。
description: Multica は 13 個の AI コーディングツールをサポートしています。すべて同じインターフェースを実装していますが、機能の詳細は大きく異なります。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica は **12 個の AI コーディングツール**を標準でサポートしています。これらはすべて同じインターフェース(キューへの投入、ディスパッチ、実行、結果の返却)を実装しているため、同じ Multica ボードからどれでも動かすことができます。**しかし機能の詳細は大きく異なります**: セッション再開が実際に動作するか、MCP をサポートするか、スキルファイルがどこに置かれるか、モデルをどう選択するか。このページがその完全な対応表です。
Multica は **13 個の AI コーディングツール**を標準でサポートしています。これらはすべて同じインターフェース(キューへの投入、ディスパッチ、実行、結果の返却)を実装しているため、同じ Multica ボードからどれでも動かすことができます。**しかし機能の詳細は大きく異なります**: セッション再開が実際に動作するか、MCP をサポートするか、スキルファイルがどこに置かれるか、モデルをどう選択するか。このページがその完全な対応表です。
エージェントを作成するときにツールを選ぶ際のガイダンスは、[エージェントの作成と構成](/agents-create)を参照してください。
@@ -15,16 +15,17 @@ Multica は **12 個の AI コーディングツール**を標準でサポート
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 動的探索(`agy models` |
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静的 + flag |
| **CodeBuddy** | Tencent | ✅ | ✅ | `.claude/skills/` | 動的探索 |
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静的 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静的(アカウントの権限で決定) |
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 動的探索 |
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静的 |
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/`(フォールバック) | 動的探索 |
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 動的探索 |
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | 動的探索 |
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | 動的探索 + variant |
| **OpenClaw** | オープンソース | ✅ | ✅ | `.agent_context/skills/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
| **Pi** | Inflection AI | ✅(セッションがファイルパス) | ❌ | `.pi/skills/` | 動的探索 |
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | 動的探索 |
## 各ツールの用途
@@ -36,6 +37,10 @@ Google が提供します。CLI バイナリ名は `agy` です。Google の Ant
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、MCP 構成を読み取り、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
### CodeBuddy
Tencent が提供します。Claude Code 互換の CLI エージェントです — Multica は Claude Code と同じ stream-json プロトコルで駆動するため、セッション再開が動作し(`--resume` 経由、MCP 構成は `--mcp-config` で渡され、スキルは Claude Code の `.claude/skills/` レイアウトを使用します。モデルは動的に探索されます。
### Codex
OpenAI が提供します。JSON-RPC 2.0 を使用し、ステートフルな能力がより強く、よりきめ細かい承認メカニズム(`exec_command` および `patch_apply` に対する手動承認を備えています。MCP 構成はタスクごとの `$CODEX_HOME/config.toml` に書き込まれます。**セッション再開は動作します** — Multica は Codex app-server の `thread/resume` で再開します。保存済み thread が見つからない、または古い場合は、新しい thread にフォールバックしてタスクを続行します。
@@ -48,14 +53,18 @@ GitHub が提供します。モデルルーティングは GitHub アカウン
Anysphere が提供し、Cursor エディターに対応する CLI です。**セッション再開は動作します** — 現在の Cursor Agent の stream-json イベントには `session_id` が含まれ、Multica は次回実行時に `--resume <id>` でそれを渡します。MCP 構成はタスクワークスペースの `.cursor/mcp.json` に書き込まれ、Cursor のプロジェクト approval ファイルはタスクごとの `CURSOR_DATA_DIR` 配下に置かれるため、管理対象 MCP server はユーザーのグローバル Cursor approvals に依存しません。
### Gemini
Google が提供し、Gemini 2.5 および 3 シリーズをサポートします。**セッション再開も MCP もサポートしません** — 長いコンテキストの記憶が不要なワンショットタスクに適しています。
### Hermes
Nous Research が提供します。ACP プロトコルを使用しますKimi とトランスポート層を共有します。セッション再開が動作し、MCP 構成は ACP `mcpServers` として渡されます。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
**Hermes profile を選択する。** 特定の profile で Hermes を起動するには、エージェントの `custom_args` に profile フラグと profile 名を 2 つの独立したエントリとして設定します。たとえば `research` という profile を使う場合:
```json
["-p", "research"]
```
`"-p research"` のように 1 つの文字列へまとめないでください。Multica は配列の各要素を 1 つの argv エントリとしてツールへ渡します。`custom_args` はエージェントごとに設定します — [エージェントの作成と構成](/agents-create)を参照してください。
### Kimi
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有し、MCP 構成も ACP `mcpServers` として渡されますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
@@ -76,22 +85,19 @@ SST が提供するオープンソースです。利用可能なモデルと mod
Inflection AI が提供し、ミニマルです。**セッション再開の方式が独特です** — セッション ID が文字列 ID ではなく、ディスク上のファイルパス(`~/.pi/...`)です。他のツールでは再開 id は CLI が返す文字列ですが、Pi では再開 id はセッションファイルそのものです。
### Qoder
Alibaba が提供します。エージェント型のコーディング CLI です。stdio 上で ACP プロトコルを使用しますHermes、Kimi、Kiro CLI とトランスポートを共有します)。セッション再開は ACP `session/resume` を通じて動作し、MCP 構成は ACP `mcpServers` として渡され、モデル選択は動的に探索され、スキルはネイティブ探索のために `.qoder/skills/` にコピーされます。
## セッション再開: 実際にサポートするツール
セッション再開のメカニズムは[タスク](/tasks#can-a-task-continue-from-the-previous-context)で扱います。以下はツールごとの**正確な現在の状態**です
| 状態 | ツール | 意味 |
|---|---|---|
| ✅ 実際に動作 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 再開 id を渡すと以前のコンテキストから続行します |
| ❌ なし | Gemini | CLI に再開メカニズムがありません |
**意思決定のために**: ワークフローでエージェントがタスク間でコンテキストを保持する必要がある場合(失敗時のリトライ、手動の再実行、対話的な反復)、✅ の行にあるツールだけを選んでください。
セッション再開のメカニズムは[タスク](/tasks#can-a-task-continue-from-the-previous-context)で扱います。**サポートされているすべてのツールがセッションを再開できます** — 再開 id を渡すと、タスクは以前のコンテキストから続行します。唯一の例外は Pi で、再開 id が文字列 ID ではなくディスク上のセッションファイルへのパスです(上記の [Pi](#pi) を参照)
## MCP 構成: ツールごとの対応
**12 個のツールのうち、`mcp_config` を実際に消費するのは 8 個です: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。残りの 4 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
**13 個のツールのうち、`mcp_config` を実際に消費するのは 10 個です: Claude Code、CodeBuddy、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Qoder**。残りの 3 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
接続方式はツールごとに異なります: Claude Code は `--mcp-config` と `--strict-mcp-config` で受け取り、Codex は daemon 管理の `mcp_servers` ブロックをタスクごとの `$CODEX_HOME/config.toml` に書き込み、Cursor は `.cursor/mcp.json` とタスクごとの `CURSOR_DATA_DIR` 配下のプロジェクト approval を書き込みます。Hermes、Kimi、Kiro CLI は ACP `mcpServers` で受け取ります。OpenCode は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン構成を受け取り、OpenClaw は Multica のタスクごとの config wrapper 経由で `mcp.servers` を受け取ります。OpenCode の経路はプロジェクトの `opencode.json` を書き換えません。
接続方式はツールごとに異なります: Claude Code と CodeBuddy は `--mcp-config` と `--strict-mcp-config` で受け取り、Codex は daemon 管理の `mcp_servers` ブロックをタスクごとの `$CODEX_HOME/config.toml` に書き込み、Cursor は `.cursor/mcp.json` とタスクごとの `CURSOR_DATA_DIR` 配下のプロジェクト approval を書き込みます。Hermes、Kimi、Kiro CLI、Qoder は ACP `mcpServers` で受け取ります。OpenCode は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン構成を受け取り、OpenClaw は Multica のタスクごとの config wrapper 経由で `mcp.servers` を受け取ります。OpenCode の経路はプロジェクトの `opencode.json` を書き換えません。
<Callout type="warning">
エージェント構成で `mcp_config` を設定しても、MCP 列に ✅ がないツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。MCP 連携はツールごとに実装されています。
@@ -104,6 +110,7 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
| ツール | パス | ネイティブ探索か |
|---|---|---|
| Claude Code | `.claude/skills/` | ✅ ネイティブ |
| CodeBuddy | `.claude/skills/` | ✅ ネイティブ |
| Codex | `$CODEX_HOME/skills/` | ✅ ネイティブ |
| Copilot | `.github/skills/` | ✅ ネイティブ |
| Cursor | `.cursor/skills/` | ✅ ネイティブ |
@@ -111,12 +118,14 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
| Kiro CLI | `.kiro/skills/` | ✅ ネイティブ |
| OpenCode | `.opencode/skills/` | ✅ ネイティブ |
| Pi | `.pi/skills/` | ✅ ネイティブ |
| Qoder | `.qoder/skills/` | ✅ ネイティブ |
| Antigravity | `.agents/skills/` | ✅ ネイティブGemini CLI のワークスペースレイアウトを継承 — [Antigravity ドキュメント](https://antigravity.google/docs/gcli-migration)を参照) |
| Gemini | `.agent_context/skills/` | ⚠️ 汎用フォールバック |
| Hermes | `.agent_context/skills/` | ⚠️ 汎用フォールバック |
| OpenClaw | `.agent_context/skills/` | ⚠️ 汎用フォールバック |
フォールバックパスを使うツールが実際にこのディレクトリを読み取るかどうかは、そのツール自体のドキュメントによって異なり、保証されません。Gemini / Hermes / OpenClaw でスキルが適用されない場合は、まずこの点を確認してください。
フォールバックパスを使うツールが実際にこのディレクトリを読み取るかどうかは、そのツール自体のドキュメントによって異なり、保証されません。Hermes / OpenClaw でスキルが適用されない場合は、まずこの点を確認してください。
ネイティブなプロジェクトレベルのパスでは、リポジトリスコープの探索は想定された挙動です。チェックアウトされたリポジトリが対応するディレクトリをすでに含んでいる場合、基盤となるツールはそのコミット済みスキルを自分で検出できます。そのリポジトリで使うためだけに、これらの repo skills を Multica へインポートする必要はありません。Multica はそれらのリポジトリファイルをそのまま保持します。ワークスペーススキルの自然なディレクトリ名が同じ場合、デーモンは `review-helper-multica` のような衝突しない兄弟ディレクトリへワークスペースコピーを書き込みます。
スキルの作成と使用については、[スキル](/skills)を参照してください。
@@ -125,4 +134,4 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
- [エージェントの作成と構成](/agents-create) — エージェントに使うツールを選ぶ
- [タスク](/tasks) — タスクのライフサイクルとセッション再開のメカニズム
- [デーモンとランタイム](/daemon-runtimes) — ツールが実行される場所と Multica への接続方法
- [エージェントランタイムのインストール](/install-agent-runtime) — サポートされる 12 個のツールそれぞれのインストールと認証
- [エージェントランタイムのインストール](/install-agent-runtime) — サポートされる 13 個のツールそれぞれのインストールと認証

View File

@@ -1,11 +1,11 @@
---
title: AI 코딩 도구 대조표
description: Multica는 12개의 AI 코딩 도구를 지원합니다. 모두 동일한 인터페이스를 구현하지만, 기능 세부사항은 크게 다릅니다.
description: Multica는 13개의 AI 코딩 도구를 지원합니다. 모두 동일한 인터페이스를 구현하지만, 기능 세부사항은 크게 다릅니다.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은 모두 동일한 인터페이스(대기열 적재, 디스패치, 실행, 결과 반환)를 구현하므로, 같은 Multica 보드에서 어느 것이든 구동할 수 있습니다. **하지만 기능 세부사항은 크게 다릅니다**: 세션 재개가 실제로 동작하는지, MCP를 지원하는지, 스킬 파일이 어디에 위치하는지, 모델을 어떻게 선택하는지. 이 페이지가 전체 대조표입니다.
Multica는 **13개의 AI 코딩 도구**를 기본 지원합니다. 이들은 모두 동일한 인터페이스(대기열 적재, 디스패치, 실행, 결과 반환)를 구현하므로, 같은 Multica 보드에서 어느 것이든 구동할 수 있습니다. **하지만 기능 세부사항은 크게 다릅니다**: 세션 재개가 실제로 동작하는지, MCP를 지원하는지, 스킬 파일이 어디에 위치하는지, 모델을 어떻게 선택하는지. 이 페이지가 전체 대조표입니다.
에이전트를 생성할 때 도구를 고르는 방법은 [에이전트 생성 및 구성](/agents-create)을 참고하세요.
@@ -15,16 +15,17 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 동적 탐색(`agy models`) |
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 정적 + flag |
| **CodeBuddy** | Tencent | ✅ | ✅ | `.claude/skills/` | 동적 탐색 |
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 정적 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 정적 (계정 권한으로 결정) |
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 동적 탐색 |
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 정적 |
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | 동적 탐색 |
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 동적 탐색 |
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | 동적 탐색 |
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | 동적 탐색 + variant |
| **OpenClaw** | 오픈소스 | ✅ | ✅ | `.agent_context/skills/` (fallback) | 에이전트에 바인딩되어 작업마다 전환 불가 |
| **Pi** | Inflection AI | ✅ (세션이 파일 경로) | ❌ | `.pi/skills/` | 동적 탐색 |
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | 동적 탐색 |
## 각 도구의 용도
@@ -36,6 +37,10 @@ Google에서 제공합니다. CLI 바이너리 이름은 `agy`입니다. Google
Anthropic에서 제공합니다. **신규 사용자에게 첫 번째 선택지**이며, 가장 완전한 기능 세트를 갖추고 있습니다: 세션 재개가 실제로 동작하고, MCP 구성을 읽으며, `--max-turns`와 `--append-system-prompt` 같은 세부 조정 flag를 지원합니다. Anthropic API 키가 필요합니다.
### CodeBuddy
Tencent에서 제공합니다. Claude Code 호환 CLI 에이전트입니다 — Multica는 Claude Code와 동일한 stream-json 프로토콜로 구동하므로 세션 재개가 동작하고(`--resume` 경유), MCP 구성은 `--mcp-config`로 전달되며, 스킬은 Claude Code의 `.claude/skills/` 레이아웃을 사용합니다. 모델은 동적으로 탐색됩니다.
### Codex
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개합니다. 저장된 thread가 없거나 오래된 경우에는 새 thread로 폴백해 작업을 계속 실행합니다.
@@ -48,14 +53,18 @@ GitHub에서 제공합니다. 모델 라우팅은 GitHub 계정 권한을 거칩
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent의 stream-json 이벤트에는 `session_id`가 포함되며, Multica는 다음 실행 때 이를 `--resume <id>`로 다시 전달합니다. MCP 구성은 작업 워크스페이스의 `.cursor/mcp.json`에 기록되고, Cursor의 프로젝트 approval 파일은 작업별 `CURSOR_DATA_DIR` 아래에 기록되므로, 관리되는 MCP 서버는 사용자의 전역 Cursor approval에 의존하지 않습니다.
### Gemini
Google에서 제공하며, Gemini 2.5 및 3 시리즈를 지원합니다. **세션 재개도 MCP도 지원하지 않습니다** — 긴 컨텍스트 기억이 필요 없는 일회성 작업에 적합합니다.
### Hermes
Nous Research에서 제공합니다. ACP 프로토콜을 사용합니다(Kimi와 전송 계층을 공유합니다). 세션 재개가 동작하고, MCP 구성은 ACP `mcpServers`로 전달됩니다. 하지만 **스킬 주입 경로는 전용 경로가 아니라 범용 fallback**(`.agent_context/skills/`)입니다 — Hermes CLI 자체가 이 경로를 읽지 않으면 스킬이 적용되지 않을 수 있습니다. 테스트로 확인하세요.
**Hermes profile 선택.** 특정 profile로 Hermes를 실행하려면 에이전트의 `custom_args`에 profile 플래그와 profile 이름을 두 개의 독립된 항목으로 설정하세요. 예를 들어 `research`라는 profile을 사용하려면:
```json
["-p", "research"]
```
`"-p research"`처럼 하나의 문자열로 합치지 마세요. Multica는 배열의 각 항목을 하나의 argv 항목으로 도구에 전달합니다. `custom_args`는 에이전트별로 설정합니다 — [에이전트 생성 및 구성](/agents-create)을 참고하세요.
### Kimi
Moonshot에서 제공하며, 중국 시장을 겨냥합니다. Hermes와 ACP 프로토콜을 공유하고 MCP 구성도 ACP `mcpServers`로 전달되지만, 스킬 경로 `.kimi/skills/`는 Kimi CLI의 기본 탐색 메커니즘으로 Hermes의 fallback과는 다릅니다.
@@ -76,22 +85,19 @@ SST에서 제공하는 오픈소스입니다. 사용 가능한 모델과 모델
Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이 특이합니다** — 세션 ID가 문자열 ID가 아니라 디스크상의 파일 경로(`~/.pi/...`)입니다. 다른 도구에서는 재개 id가 CLI가 반환하는 문자열이지만, Pi에서는 재개 id가 세션 파일 그 자체입니다.
### Qoder
Alibaba에서 제공합니다. 에이전트형 코딩 CLI입니다. stdio 위에서 ACP 프로토콜을 사용합니다(Hermes, Kimi, Kiro CLI와 전송 계층을 공유합니다). 세션 재개는 ACP `session/resume`를 통해 동작하고, MCP 구성은 ACP `mcpServers`로 전달되며, 모델 선택은 동적으로 탐색되고, 스킬은 네이티브 탐색을 위해 `.qoder/skills/`로 복사됩니다.
## 세션 재개: 실제로 지원하는 도구
세션 재개 메커니즘은 [작업](/tasks#can-a-task-continue-from-the-previous-context)에서 다룹니다. 다음은 도구별 **정확한 현재 상태**입니다:
| 상태 | 도구 | 의미 |
|---|---|---|
| ✅ 실제로 동작 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
| ❌ 없음 | Gemini | CLI에 재개 메커니즘이 없습니다 |
**의사결정을 위해**: 워크플로에서 에이전트가 작업 간에 컨텍스트를 유지해야 한다면(실패 재시도, 수동 재실행, 대화형 반복), ✅ 행에 있는 도구만 선택하세요.
세션 재개 메커니즘은 [작업](/tasks#can-a-task-continue-from-the-previous-context)에서 다룹니다. **지원되는 모든 도구가 세션을 재개합니다** — 재개 id를 전달하면 작업이 이전 컨텍스트에서 이어집니다. 유일한 예외는 Pi로, 재개 id가 문자열 ID가 아니라 디스크상의 세션 파일 경로입니다(위의 [Pi](#pi) 참고).
## MCP 구성: 도구별 지원
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 8개입니다: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 4개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
**13개 도구 중 `mcp_config`를 실제로 소비하는 것은 10개입니다: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Qoder**. 나머지 3개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Cursor는 `.cursor/mcp.json`과 작업별 `CURSOR_DATA_DIR` 아래의 프로젝트 approval을 기록합니다. Hermes/Kimi/Kiro CLI는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
각 도구의 연결 방식은 다릅니다: Claude Code와 CodeBuddy는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Cursor는 `.cursor/mcp.json`과 작업별 `CURSOR_DATA_DIR` 아래의 프로젝트 approval을 기록합니다. Hermes/Kimi/Kiro CLI/Qoder는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
<Callout type="warning">
에이전트 구성에서 `mcp_config`를 설정했더라도 MCP 열에 ✅가 없는 도구를 선택하면, MCP 서버가 해당 에이전트에 **아무런 효과**도 미치지 않습니다. MCP 연동은 도구별로 구현됩니다.
@@ -104,6 +110,7 @@ Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이
| 도구 | 경로 | 기본 탐색 여부 |
|---|---|---|
| Claude Code | `.claude/skills/` | ✅ 기본 |
| CodeBuddy | `.claude/skills/` | ✅ 기본 |
| Codex | `$CODEX_HOME/skills/` | ✅ 기본 |
| Copilot | `.github/skills/` | ✅ 기본 |
| Cursor | `.cursor/skills/` | ✅ 기본 |
@@ -111,12 +118,14 @@ Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이
| Kiro CLI | `.kiro/skills/` | ✅ 기본 |
| OpenCode | `.opencode/skills/` | ✅ 기본 |
| Pi | `.pi/skills/` | ✅ 기본 |
| Qoder | `.qoder/skills/` | ✅ 기본 |
| Antigravity | `.agents/skills/` | ✅ 기본 (Gemini CLI의 워크스페이스 레이아웃을 따름 — [Antigravity 문서](https://antigravity.google/docs/gcli-migration) 참고) |
| Gemini | `.agent_context/skills/` | ⚠️ 범용 fallback |
| Hermes | `.agent_context/skills/` | ⚠️ 범용 fallback |
| OpenClaw | `.agent_context/skills/` | ⚠️ 범용 fallback |
fallback 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는 해당 도구 자체의 문서에 따라 달라지며 — 보장되지 않습니다. Gemini / Hermes / OpenClaw에서 스킬이 적용되지 않는다면, 먼저 이 점을 확인하세요.
fallback 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는 해당 도구 자체의 문서에 따라 달라지며 — 보장되지 않습니다. Hermes / OpenClaw에서 스킬이 적용되지 않는다면, 먼저 이 점을 확인하세요.
기본 프로젝트 수준 경로에서는 저장소 범위 탐색이 의도된 동작입니다. 체크아웃된 저장소가 이미 해당 디렉터리를 포함하고 있으면, 기반 도구가 커밋된 스킬을 자체적으로 탐색할 수 있습니다. 해당 저장소에서 사용하기 위해 이러한 repo skills를 Multica로 먼저 가져올 필요는 없습니다. Multica는 이러한 저장소 파일을 그대로 둡니다. 워크스페이스 스킬의 자연 디렉터리 이름이 같으면 데몬은 `review-helper-multica` 같은 충돌 없는 형제 디렉터리에 워크스페이스 사본을 씁니다.
스킬의 생성과 사용은 [스킬](/skills)을 참고하세요.
@@ -125,4 +134,4 @@ fallback 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는
- [에이전트 생성 및 구성](/agents-create) — 에이전트에 사용할 도구를 선택하세요
- [작업](/tasks) — 작업 생명주기와 세션 재개 메커니즘
- [데몬과 런타임](/daemon-runtimes) — 도구가 실행되는 곳과 Multica에 연결되는 방식
- [에이전트 런타임 설치](/install-agent-runtime) — 지원되는 12개 도구 각각의 설치 및 인증
- [에이전트 런타임 설치](/install-agent-runtime) — 지원되는 13개 도구 각각의 설치 및 인증

View File

@@ -1,11 +1,11 @@
---
title: AI coding tools matrix
description: Multica supports 12 AI coding tools; they implement the same interface, but the capability details diverge significantly.
description: Multica supports 13 AI coding tools; they implement the same interface, but the capability details diverge significantly.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica ships with built-in support for **12 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
Multica ships with built-in support for **13 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create).
@@ -15,27 +15,32 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Dynamic discovery (`agy models`) |
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | Static + flag |
| **CodeBuddy** | Tencent | ✅ | ✅ | `.claude/skills/` | Dynamic discovery |
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | Static |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | Dynamic discovery |
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | Static |
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | Dynamic discovery |
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | Dynamic discovery |
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | Dynamic discovery |
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | Dynamic discovery + variants |
| **OpenClaw** | Open source | ✅ | ✅ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | Dynamic discovery |
## What each tool is for
### Antigravity
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. Multica launches Antigravity with `agy -p` because that is the daemon-compatible non-interactive mode; `agy -i` needs an attached TTY and is not suitable for background task execution. Current Antigravity CLI releases can still execute tools from this mode, but stdout is plain assistant text rather than a structured event stream, so Multica relays the transcript as text and cannot show per-tool telemetry for Antigravity today. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
### Claude Code
From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it reads MCP configuration, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
### CodeBuddy
From Tencent. A Claude Codecompatible CLI agent — Multica drives it with the same stream-json protocol as Claude Code, so session resumption works (via `--resume`), MCP config is passed through `--mcp-config`, and skills use Claude Code's `.claude/skills/` layout. Models are discovered dynamically.
### Codex
From OpenAI. Uses JSON-RPC 2.0, has stronger statefulness, and a finer-grained approve mechanism (manual approval for `exec_command` and `patch_apply`). MCP config is materialized into the per-task `$CODEX_HOME/config.toml`. **Session resumption works** through Codex app-server `thread/resume`; if the saved thread is missing or stale, Multica falls back to a fresh thread so the task can still run.
@@ -48,14 +53,18 @@ From GitHub. Model routing goes through your GitHub account entitlement — the
From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption works** with current Cursor Agent releases: the stream-json event includes a `session_id`, and Multica passes it back with `--resume <id>` on the next run. MCP config is materialized into the task workspace's `.cursor/mcp.json`, with Cursor's project approval file written under a per-task `CURSOR_DATA_DIR` so managed MCP servers do not depend on the user's global Cursor approvals.
### Gemini
From Google, supports the Gemini 2.5 and 3 series. **No session resumption and no MCP** — suitable for one-shot tasks that don't need long context memory.
### Hermes
From Nous Research. Uses the ACP protocol (shares a transport with Kimi). Session resumption works, and MCP config is passed through ACP `mcpServers`. But the **skill injection path is the generic fallback** (`.agent_context/skills/`), not a dedicated one — if the Hermes CLI itself doesn't read this path, skills may not take effect. Verify by testing.
**Selecting a Hermes profile.** To launch Hermes under a specific profile, set the agent's `custom_args` to the profile flag and the profile name as two separate entries — for example, for a profile named `research`:
```json
["-p", "research"]
```
Don't combine them into one string like `"-p research"`; Multica passes each array item as a separate argv entry. `custom_args` is configured per agent — see [Creating and configuring agents](/agents-create).
### Kimi
From Moonshot, aimed at the Chinese market. Shares the ACP protocol with Hermes, including MCP config through ACP `mcpServers`, but the skill path `.kimi/skills/` is Kimi CLI's native discovery mechanism — different from Hermes's fallback.
@@ -76,22 +85,19 @@ Open-source project, a CLI agent orchestrator. MCP config is materialized throug
From Inflection AI, minimalist. **Session resumption is unusual** — the session ID is a file path on disk (`~/.pi/...`) rather than a string ID. In other tools, the resume id is a string returned by the CLI; in Pi, the resume id is the session file itself.
### Qoder
From Alibaba. An agentic coding CLI. Uses the ACP protocol over stdio (shares a transport with Hermes, Kimi, and Kiro CLI). Session resumption works through ACP `session/resume`, MCP config is passed through ACP `mcpServers`, model selection is discovered dynamically, and skills are copied into `.qoder/skills/` for native discovery.
## Session resumption: who really supports it
The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continue-from-the-previous-context). Here's the **exact current state** per tool:
| Status | Tools | Meaning |
|---|---|---|
| ✅ Really works | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
| ❌ None | Gemini | The CLI has no resume mechanism |
**For your decision**: if your workflow needs agents to preserve context across tasks (failure retries, manual reruns, conversational iteration), pick only from the ✅ row.
The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continue-from-the-previous-context). **Every supported tool resumes sessions** — pass the resume id and the task continues from the previous context. The one quirk is Pi, whose resume id is a session file path on disk rather than a string id (see [Pi](#pi) above).
## MCP configuration: provider-specific support
**Of the 12 tools, eight consume `mcp_config`: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other four accept the field but **ignore it** — no error, no warning, the config just has no effect.
**Of the 13 tools, ten consume `mcp_config`: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Qoder**. The other three accept the field but **ignore it** — no error, no warning, the config just has no effect.
The runtime paths are provider-specific: Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, and Kiro CLI receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
The runtime paths are provider-specific: Claude Code and CodeBuddy receive it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, Kiro CLI, and Qoder receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
<Callout type="warning">
If you set `mcp_config` in an agent configuration but pick a tool not marked ✅ in the MCP column, your MCP servers have **no effect** on that agent. MCP integration is provider-specific.
@@ -104,6 +110,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
| Tool | Path | Native discovery? |
|---|---|---|
| Claude Code | `.claude/skills/` | ✅ Native |
| CodeBuddy | `.claude/skills/` | ✅ Native |
| Codex | `$CODEX_HOME/skills/` | ✅ Native |
| Copilot | `.github/skills/` | ✅ Native |
| Cursor | `.cursor/skills/` | ✅ Native |
@@ -111,12 +118,14 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
| Kiro CLI | `.kiro/skills/` | ✅ Native |
| OpenCode | `.opencode/skills/` | ✅ Native |
| Pi | `.pi/skills/` | ✅ Native |
| Qoder | `.qoder/skills/` | ✅ Native |
| Antigravity | `.agents/skills/` | ✅ Native (inherits Gemini CLI's workspace layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)) |
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
| OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback |
Whether a fallback-path tool actually reads this directory depends on the tool's own documentation — no guarantees. If your skills aren't taking effect for Gemini / Hermes / OpenClaw, check this first.
Whether a fallback-path tool actually reads this directory depends on the tool's own documentation — no guarantees. If your skills aren't taking effect for Hermes / OpenClaw, check this first.
For native project-level paths, repo-scoped discovery is expected: if the checked-out repository already contains a matching directory, the underlying tool can discover those committed skills on its own. You do not need to import those repo skills into Multica just to use them in that repo. Multica keeps the repo files intact. If a workspace skill has the same natural directory name, the daemon writes the workspace copy to a collision-free sibling such as `review-helper-multica`.
For creating and using skills, see [Skills](/skills).
@@ -125,4 +134,4 @@ For creating and using skills, see [Skills](/skills).
- [Creating and configuring agents](/agents-create) — pick a tool for your agent
- [Tasks](/tasks) — task lifecycle and session-resumption mechanics
- [Daemon and runtimes](/daemon-runtimes) — where the tools run and how they connect to Multica
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 12 supported tools
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 13 supported tools

View File

@@ -1,11 +1,11 @@
---
title: AI 编程工具对照
description: Multica 支持 12 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
description: Multica 支持 13 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
Multica 内置支持 **13 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
创建智能体时挑选工具的指引见 [创建和配置智能体](/agents-create)。
@@ -15,27 +15,32 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅(`--conversation <id>`| ❌ | `.agents/skills/` | 动态发现(`agy models`|
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静态 + flag |
| **CodeBuddy** | Tencent | ✅ | ✅ | `.claude/skills/` | 动态发现 |
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静态 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 动态发现 |
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静态 |
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` fallback| 动态发现 |
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 动态发现 |
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | 动态发现 |
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | 动态发现 + variant |
| **OpenClaw** | 开源项目 | ✅ | ✅ | `.agent_context/skills/` fallback| 绑定在智能体上,不能在任务里切换 |
| **Pi** | Inflection AI | ✅session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | 动态发现 |
## 每款工具的定位
### 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
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,会读 MCP 配置,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
### CodeBuddy
Tencent 出品。一款兼容 Claude Code 的 CLI agent——Multica 用和 Claude Code 一样的 stream-json 协议驱动它,所以会话恢复可用(通过 `--resume`MCP 配置通过 `--mcp-config` 传入skill 沿用 Claude Code 的 `.claude/skills/` 布局。模型为动态发现。
### Codex
OpenAI 出品。使用 JSON-RPC 2.0 协议状态化更强approve 机制更细(手动批准 `exec_command` 和 `patch_apply`。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话恢复可用**——Multica 通过 Codex app-server 的 `thread/resume` 续接;如果已保存的 thread 不存在或过期,会回退到新 thread让任务继续执行。
@@ -48,14 +53,18 @@ GitHub 出品。模型路由走你的 GitHub 账号权益——工具自己不
Anysphere 出品Cursor 编辑器的 CLI 对应物。**会话恢复可用**——当前 Cursor Agent 的 stream-json 事件会返回 `session_id`Multica 会在下一次运行时通过 `--resume <id>` 传回去。MCP 配置会写入任务工作区的 `.cursor/mcp.json`Cursor 的项目 approval 文件写在单次任务的 `CURSOR_DATA_DIR` 下,因此托管的 MCP server 不依赖用户全局 Cursor approvals。
### Gemini
Google 出品,支持 Gemini 2.5 和 3 系列。**不支持会话恢复也不支持 MCP**——适合一次性、不需要长上下文记忆的任务。
### Hermes
Nous Research 出品。使用 ACP 协议(和 Kimi 共享传输层。会话恢复真用MCP 配置通过 ACP `mcpServers` 传入。但 **skill 注入路径是通用 fallback**`.agent_context/skills/`),不是专用路径——如果 Hermes CLI 本身不读这路径skill 对它可能不起作用。需要结合实测再确认。
**指定 Hermes profile。** 要让 Hermes 使用某个 profile 启动,把智能体的 `custom_args` 设成 profile flag 和 profile 名两个独立条目。例如使用名为 `research` 的 profile
```json
["-p", "research"]
```
不要合成一个字符串 `"-p research"`Multica 会把数组里的每一项作为一个独立 argv 参数传给工具。`custom_args` 是按智能体配置的——见 [创建和配置智能体](/agents-create)。
### Kimi
Moonshot 出品,中国市场向。和 Hermes 共享 ACP 协议MCP 配置同样通过 ACP `mcpServers` 传入;但 skill 路径 `.kimi/skills/` 是 Kimi CLI 的原生发现机制——和 Hermes 的 fallback 不一样。
@@ -76,22 +85,19 @@ SST 出品,开源。动态发现可用模型和模型 variant扫 CLI 的配
Inflection AI 出品,极简主义。**会话恢复机制特殊**——session ID 是磁盘上的文件路径(`~/.pi/...`),而不是字符串 ID。其他工具里resume id 是 CLI 返回的字符串Pi 里resume id 就是会话文件本身。
### Qoder
Alibaba 出品。一款 agentic 编程 CLI。使用 ACP 协议(和 Hermes、Kimi、Kiro CLI 共享传输层)。会话恢复通过 ACP `session/resume` 工作MCP 配置通过 ACP `mcpServers` 传入模型为动态发现skill 复制到 `.qoder/skills/` 做原生发现。
## 会话恢复:谁真的支持
会话恢复的机制在 [执行任务](/tasks#任务能接着上次的上下文继续吗) 里讲过。这里按工具列**精确现状**
| 状态 | 工具 | 含义 |
|---|---|---|
| ✅ 真用 | Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id会从上次上下文接着继续 |
| ❌ 无 | Gemini | CLI 无 resume 机制 |
**对你的决策**:如果工作流需要智能体在多次任务之间保持上下文(失败重试、手动重跑、对话式迭代),只选 ✅ 那一行的工具。
会话恢复的机制在 [执行任务](/tasks#任务能接着上次的上下文继续吗) 里讲过。**所有支持的工具都能恢复会话**——传 resume id任务就会从上次的上下文接着继续。唯一的特例是 Pi它的 resume id 是磁盘上的会话文件路径,而不是字符串 ID见上文 [Pi](#pi))。
## MCP 配置:按工具不同
**12 款工具里有 8 款实际消费 `mcp_config`Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 4 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
**13 款工具里有 10 款实际消费 `mcp_config`Claude Code、CodeBuddy、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Qoder**。其他 3 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
各工具的接入方式不同Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`Cursor 会写入 `.cursor/mcp.json`,并把项目 approval 写到单次任务的 `CURSOR_DATA_DIR`Hermes、Kimi、Kiro CLI 通过 ACP `mcpServers` 接收OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
各工具的接入方式不同Claude Code 和 CodeBuddy 通过 `--mcp-config` 加 `--strict-mcp-config` 接收Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`Cursor 会写入 `.cursor/mcp.json`,并把项目 approval 写到单次任务的 `CURSOR_DATA_DIR`Hermes、Kimi、Kiro CLI、Qoder 通过 ACP `mcpServers` 接收OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
<Callout type="warning">
如果你在智能体配置里设置了 `mcp_config`,但选了矩阵 MCP 列没有标 ✅ 的工具,你的 MCP server 对这个智能体**没有效果**。MCP 集成是按工具实现的。
@@ -104,6 +110,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| 工具 | 路径 | 是否原生发现 |
|---|---|---|
| Claude Code | `.claude/skills/` | ✅ 原生 |
| CodeBuddy | `.claude/skills/` | ✅ 原生 |
| Codex | `$CODEX_HOME/skills/` | ✅ 原生 |
| Copilot | `.github/skills/` | ✅ 原生 |
| Cursor | `.cursor/skills/` | ✅ 原生 |
@@ -111,12 +118,14 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
| OpenCode | `.opencode/skills/` | ✅ 原生 |
| Pi | `.pi/skills/` | ✅ 原生 |
| Qoder | `.qoder/skills/` | ✅ 原生 |
| Antigravity | `.agents/skills/` | ✅ 原生(沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration)|
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
| OpenClaw | `.agent_context/skills/` | ⚠️ 通用 fallback |
fallback 路径对应的工具是否真的读取这个目录,取决于工具本身的文档——没保证。如果你的 skill 对 Gemini / Hermes / OpenClaw 没起效,先查这个问题。
fallback 路径对应的工具是否真的读取这个目录,取决于工具本身的文档——没保证。如果你的 skill 对 Hermes / OpenClaw 没起效,先查这个问题。
对原生项目级路径来说repo-scoped discovery 是预期行为:如果检出的仓库已经包含对应目录,底层工具可以自己发现这些提交在仓库里的 Skill。你不需要为了在这个仓库里使用这些 repo skills 而先把它们导入 Multica。Multica 会保持这些仓库文件不变。如果某个工作区 Skill 的自然目录名相同,守护进程会把工作区副本写到类似 `review-helper-multica` 的无冲突 sibling 目录。
skill 的创建和使用详见 [技能](/skills)。

View File

@@ -12,10 +12,16 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica は 2 つのスキルソースに対応しています。
- **ワークスペーススキル** — Multica のクラウドに保存されます。エージェントに取り付けると、タスク実行時にあなたのデーモンへ同期されます。これが**チーム全体でスキルを共有する標準的な方法**です。
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。デーモンは**2 つのルートを優先順位順に**確認します。まずランタイム自身のスキルディレクトリ(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`)、次にツール横断の汎用ディレクトリ `~/.agents/skills/`Codex や Gemini CLI などのエコシステムが共有する場所)です。同じスキル名が両方に存在する場合は**プロバイダー専用ディレクトリが優先**されるため、汎用ルートは*追加の*スキルを表示するだけで、既存スキルの解決結果を変えることはありません
ほとんどの場合は**ワークスペーススキル**が望ましいでしょう。一度インポートすれば、すべてのチームメイトのエージェントが使えるからです。ローカルスキルは、まずローカルでテストしたい場合や、内容に機密性の高いローカル資料が含まれる場合に適しています。
## リポジトリスコープのスキル
リポジトリスコープのスキルは想定された挙動です。一部の AI コーディングツールは、`.claude/skills/`、`.cursor/skills/`、`.opencode/skills/`、`.agents/skills/` など、**リポジトリにコミットされたプロジェクトレベルのスキル**を検出します。Multica のタスクがそのリポジトリをチェックアウトすると、それらのファイルは作業ディレクトリに残り、基盤となるツールが自身のネイティブ探索ルールで読み込めます。そのリポジトリで使うためだけに、これらの repo skills を Multica へインポートする必要はありません。また Multica は、それらをワークスペーススキルレジストリへ自動インポートしません。
プロジェクトレベルの探索に対応するツールでは、ワークスペーススキルも同じプロバイダー固有の場所へ同期されます。ワークスペーススキルが既存のリポジトリスキルディレクトリと衝突する場合、Multica はリポジトリのファイルを上書きせず、`review-helper-multica` のような兄弟名でワークスペースコピーを書き込みます。そのためツールには、調整後の名前を持つワークスペースコピーを含めて両方のスキルが見えることがあります。
## スキルをインポートする
ワークスペーススキルは 4 つのソースから取得します。

View File

@@ -12,10 +12,16 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica는 두 가지 스킬 소스를 지원합니다.
- **워크스페이스 스킬** — Multica 클라우드에 저장됩니다. 에이전트에 연결되면 작업 실행 시점에 여러분의 데몬으로 동기화됩니다. 이것이 **팀 전체에서 스킬을 공유하는 표준 방식**입니다.
- **로컬 스킬** — 여러분의 기기에 있는 디렉터리에 존재합니다(각 AI 코딩 도구마다 관례적인 기본 경로가 있습니다. 예: Claude Code의 `~/.claude/skills/`). 여러분이 요청하면 [데몬](/daemon-runtimes)이 기기를 스캔하고, 어떤 스킬을 워크스페이스로 가져올지 직접 고릅니다.
- **로컬 스킬** — 여러분의 기기에 있는 디렉터리에 존재합니다. 여러분이 요청하면 [데몬](/daemon-runtimes)이 기기를 스캔하고, 어떤 스킬을 워크스페이스로 가져올지 직접 고릅니다. 데몬은 **두 개의 루트를 우선순위 순서로** 확인합니다. 먼저 런타임 자체의 스킬 디렉터리(각 AI 코딩 도구마다 관례적인 기본 경로가 있습니다. 예: Claude Code의 `~/.claude/skills/`), 그다음 도구 간 공용 디렉터리 `~/.agents/skills/`(Codex, Gemini CLI 등 생태계가 공유하는 위치)입니다. 동일한 스킬 이름이 양쪽에 모두 있으면 **프로바이더 전용 디렉터리가 우선**하므로, 공용 루트는 *추가* 스킬만 노출할 뿐 기존 스킬의 해석 결과를 절대 바꾸지 않습니다.
대부분의 경우 **워크스페이스 스킬**을 원하게 됩니다. 한 번만 가져오면 모든 팀원의 에이전트가 사용할 수 있기 때문입니다. 로컬 스킬은 먼저 로컬에서 테스트하고 싶거나, 콘텐츠에 민감한 로컬 자료가 포함된 경우에 적합합니다.
## 저장소 범위 스킬
저장소 범위 스킬은 의도된 동작입니다. 일부 AI 코딩 도구는 `.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, `.agents/skills/`처럼 **저장소에 커밋된 프로젝트 수준 스킬**을 탐색합니다. Multica 작업이 해당 저장소를 체크아웃하면 이 파일들은 작업 디렉터리에 남아 있고, 기반 도구가 자체 기본 탐색 규칙에 따라 읽어 들일 수 있습니다. 해당 저장소에서 사용하기 위해 이러한 repo skills를 Multica로 먼저 가져올 필요는 없습니다. Multica도 이를 워크스페이스 스킬 레지스트리로 자동 가져오지 않습니다.
프로젝트 수준 탐색을 지원하는 도구에서는 워크스페이스 스킬도 같은 프로바이더 기본 위치로 동기화됩니다. 워크스페이스 스킬이 기존 저장소 스킬 디렉터리와 충돌하면, Multica는 저장소 파일을 덮어쓰지 않고 `review-helper-multica` 같은 형제 이름으로 워크스페이스 사본을 씁니다. 그러면 도구에는 조정된 이름을 가진 워크스페이스 사본을 포함해 두 스킬이 모두 보일 수 있습니다.
## 스킬 가져오기
워크스페이스 스킬은 네 가지 소스에서 가져옵니다.

View File

@@ -12,10 +12,16 @@ A Skill is a **knowledge pack** for an [agent](/agents) — a `SKILL.md` plus op
Multica supports two skill sources:
- **Workspace skill** — stored in Multica's cloud. Once attached to an agent, it's synced down to your daemon at task execution time. This is the **standard way to share skills across a team**.
- **Local skill** — lives in a directory on your machine (each AI coding tool has a conventional default path, e.g. Claude Code's `~/.claude/skills/`). On your request, the [daemon](/daemon-runtimes) scans your machine, and you manually pick which ones to bring into the workspace.
- **Local skill** — lives in a directory on your machine. On your request, the [daemon](/daemon-runtimes) scans your machine, and you manually pick which ones to bring into the workspace. The daemon checks **two roots, in priority order**: first the runtime's own skill directory (each AI coding tool has a conventional default path, e.g. Claude Code's `~/.claude/skills/`), then the cross-tool universal directory `~/.agents/skills/` — a shared location used by ecosystems like Codex and Gemini CLI. When the same skill name exists in both, the **provider-specific directory wins**, so the universal root only ever surfaces *additional* skills and never changes what an existing skill resolves to.
Most of the time you want **workspace skills**: import once, every teammate's agent can use it. Local skills are a fit when you want to test locally first, or when the content involves sensitive local material.
## Repository-scoped skills
Repo-scoped skills are expected behavior. Some AI coding tools discover **project-level skills committed inside a repository**, such as `.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, or `.agents/skills/`. When a Multica task checks out that repository, those files remain in the workdir and the underlying tool can load them through its native discovery rules. You do **not** need to import those repo skills into Multica just to use them in that repo; Multica does **not** import them into the workspace skill registry automatically.
Workspace skills still sync into the same provider-native location for tools that support project-level discovery. If a workspace skill would collide with an existing repo skill directory, Multica writes the workspace copy to a sibling name such as `review-helper-multica` instead of overwriting the repo's files. The tool may then see both skills, with the workspace copy carrying the adjusted name.
## Importing a skill
Workspace skills come from four sources:

View File

@@ -12,10 +12,16 @@ Skill 是给 [智能体](/agents) 的**专业知识包**——一个 `SKILL.md`
Multica 支持两种 Skill 来源:
- **工作区 Skillworkspace skill** —— 存在 Multica 云端。挂到智能体后,任务执行时自动同步到你本机的守护进程。这是**团队共享 Skill 的标准方式**。
- **本机 Skilllocal skill** —— 直接存在你本机的某个目录里(每款 AI 编程工具有约定的默认路径,比如 Claude Code 的 `~/.claude/skills/`。[守护进程](/daemon-runtimes) 按你的请求扫描本机,发现后由你手动选入工作区。
- **本机 Skilllocal skill** —— 直接存在你本机的某个目录里。[守护进程](/daemon-runtimes) 按你的请求扫描本机,发现后由你手动选入工作区。守护进程会**按优先级扫描两个根目录**:先扫该运行时自己的 skill 目录(每款 AI 编程工具有约定的默认路径,比如 Claude Code 的 `~/.claude/skills/`),再扫跨工具通用目录 `~/.agents/skills/`——这是 Codex、Gemini CLI 等生态共用的位置。当同名 skill 在两处都存在时,**provider 专用目录优先**,所以通用根目录只会*额外*带出新的 skill绝不会改变已有 skill 的解析结果
大多数情况用**工作区 Skill**:导入一次,团队所有成员的智能体都能用。本机 Skill 适合先在本地测试、或涉及敏感本地内容的场景。
## 仓库级 Skill
仓库级 Skill 是预期行为。有些 AI 编程工具会发现**提交在仓库里的项目级 Skill**,例如 `.claude/skills/`、`.cursor/skills/`、`.opencode/skills/` 或 `.agents/skills/`。当 Multica 任务检出这个仓库时,这些文件会留在工作目录里,底层工具可以按自己的原生发现规则加载它们。你不需要为了在这个仓库里使用这些 repo skills 而先把它们导入 MulticaMultica 也不会自动把它们导入工作区 Skill 注册表。
对支持项目级发现的工具,工作区 Skill 也会同步到同一个 provider 原生位置。如果某个工作区 Skill 会和已有的仓库 Skill 目录冲突Multica 会把工作区副本写到类似 `review-helper-multica` 的 sibling 名称下,而不是覆盖仓库文件。工具随后可能会同时看到两个 Skill其中工作区副本使用调整后的名称。
## 导入 Skill
工作区 Skill 有四种来源:

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

@@ -47,6 +47,7 @@ const PRIORITY_LABEL: Record<IssuePriority, string> = {
// Mirrors useTypeLabels in packages/views/inbox/components/inbox-detail-label.tsx
const TYPE_LABEL: Record<InboxItemType, string> = {
issue_assigned: "Assigned",
issue_subscribed: "Subscribed",
unassigned: "Unassigned",
assignee_changed: "Reassigned",
status_changed: "Status changed",

View File

@@ -366,5 +366,6 @@ export function commentToTimelineEntry(comment: Comment): TimelineEntry {
resolved_at: comment.resolved_at,
resolved_by_type: comment.resolved_by_type,
resolved_by_id: comment.resolved_by_id,
source_task_id: comment.source_task_id,
};
}

View File

@@ -89,6 +89,7 @@ export const CommentSchema = z.object({
resolved_at: z.string().nullable().default(null),
resolved_by_type: z.string().nullable().default(null),
resolved_by_id: z.string().nullable().default(null),
source_task_id: z.string().nullable().optional(),
}).loose() as unknown as z.ZodType<Comment>;
export const EMPTY_COMMENT: Comment = {
@@ -663,6 +664,7 @@ export const EMPTY_ISSUE_FALLBACK: import("@multica/core/types").Issue = {
parent_issue_id: null,
project_id: null,
position: 0,
stage: null,
start_date: null,
due_date: null,
metadata: {},

View File

@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
@@ -116,6 +117,24 @@ export default async function RootLayout({
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
>
<body className="h-full overflow-hidden">
{/*
react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click
any element to copy its source path + line + component stack for pasting
to an AI. Opt-in per developer: only loads when VITE_REACT_GRAB is set in
a local, gitignored apps/web/.env.local — it never activates for anyone
else. Both guards are read server-side, so the <Script> is omitted from
the HTML entirely unless you opted in. The VITE_ prefix is shared with the
desktop renderer (apps/desktop/src/renderer/src/main.tsx), where Vite only
exposes VITE_-prefixed vars to client code, so one var name covers both
apps. See https://www.react-grab.com/
*/}
{process.env.NODE_ENV === "development" && process.env.VITE_REACT_GRAB && (
<Script
src="//unpkg.com/react-grab/dist/index.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
)}
<ThemeProvider>
<WebProviders locale={locale} resources={resources}>
{children}

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

@@ -5,7 +5,14 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@multica/core/auth";
import { captureDownloadIntent } from "@multica/core/analytics";
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
import {
XMark,
GitHubMark,
DiscordMark,
githubUrl,
twitterUrl,
discordUrl,
} from "./shared";
import { useLocale, locales, localeLabels } from "../i18n";
export function LandingFooter() {
@@ -46,6 +53,15 @@ export function LandingFooter() {
>
<GitHubMark className="size-4" />
</Link>
<Link
href={discordUrl}
target="_blank"
rel="noreferrer"
aria-label="Discord"
className="text-white/40 transition-colors hover:text-white"
>
<DiscordMark className="size-4" />
</Link>
</div>
<div className="mt-6">
<Link

View File

@@ -7,6 +7,7 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@multica/core/auth";
import { docsHrefForLocale, useLocale } from "../i18n";
import { formatStarCount, useGithubStars } from "../utils/use-github-stars";
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
export function LandingHeader({
@@ -16,6 +17,8 @@ export function LandingHeader({
}) {
const { t, locale } = useLocale();
const user = useAuthStore((s) => s.user);
const stars = useGithubStars();
const starsLabel = stars != null ? formatStarCount(stars) : null;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const docsHref = docsHrefForLocale(locale);
const navLinks = [
@@ -99,6 +102,7 @@ export function LandingHeader({
>
<GitHubMark className="size-3.5" />
{t.header.github}
{starsLabel ? <GitHubStarsBadge label={starsLabel} /> : null}
</Link>
<Link
href={ctaHref}
@@ -145,6 +149,7 @@ export function LandingHeader({
>
<GitHubMark className="size-3.5" />
{t.header.github}
{starsLabel ? <GitHubStarsBadge label={starsLabel} /> : null}
</Link>
</div>
</div>
@@ -153,6 +158,20 @@ export function LandingHeader({
);
}
/** Star-count segment appended to the header's GitHub button — a faint
* divider and the compact count (e.g. "37.6k"). No star glyph: in the GitHub
* button context the number reads as the star count on its own. Inherits the
* button's text color so it adapts to both the dark and light header
* variants. */
function GitHubStarsBadge({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-1.5 tabular-nums">
<span aria-hidden className="h-3 w-px bg-current opacity-25" />
{label}
</span>
);
}
function navLinkClassName(variant: "dark" | "light") {
return cn(
"inline-flex h-9 items-center rounded-[9px] px-3 text-[13px] font-medium transition-colors",

View File

@@ -2,6 +2,7 @@ import { cn } from "@multica/ui/lib/utils";
export const githubUrl = "https://github.com/multica-ai/multica";
export const twitterUrl = "https://x.com/MulticaAI";
export const discordUrl = "https://discord.gg/W8gYBn226t";
export function GitHubMark({ className }: { className?: string }) {
return (
@@ -16,6 +17,19 @@ export function GitHubMark({ className }: { className?: string }) {
);
}
export function DiscordMark({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
fill="currentColor"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
);
}
export function XMark({ className }: { className?: string }) {
return (
<svg

View File

@@ -1,4 +1,4 @@
import { githubUrl } from "../components/shared";
import { githubUrl, discordUrl } from "../components/shared";
import type { LandingDict } from "./types";
export function createEnDict(allowSignup: boolean): LandingDict {
@@ -244,6 +244,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{ label: "Documentation", href: "/docs" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "Discord", href: discordUrl },
],
},
company: {
@@ -292,6 +293,177 @@ 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",
title: "Cross-workspace unread dot, Composio toolkit foundation, and a friendlier editor",
changes: [],
features: [
"The workspace switcher shows a dot when another workspace has unread inbox items.",
"New Composio toolkit foundation that prepares the upcoming third-party integrations.",
"You can run desktop dev on multiple checkouts side by side without them clashing.",
"The Chinese docs homepage now opens with a short intro video.",
],
improvements: [
"Contributor docs note that the desktop dev command isolates per checkout.",
],
fixes: [
"Tab now reliably indents selected list items in the Issue editor and keeps focus in place.",
"Squad leaders boot with the full squad briefing when you @-mention them in a comment, and replies that inherit the parent mention no longer trigger them again.",
"Code-block selections in Issues stay put while the page re-renders.",
"Assigning an Issue directly to an agent opens the handoff note instantly instead of waiting on a check.",
"The workspace switcher's unread dot now matches what you actually see in your inbox.",
"The edit-comment save button shows a loading state until the change is saved.",
"Search results load reliably again.",
"Self-hosting fails fast with a clear hint when Docker Compose v2 is missing.",
],
},
{
version: "0.3.30",
date: "2026-06-25",
title: "Slack Channel Integration, a Smoother Editor, and Many Reliability Fixes",
changes: [],
features: [
"Slack conversations now run on the new unified collaboration channel, putting Slack on the same reliable footing as Feishu and Lark",
"The Issue composer now accepts the highlighted @mention or suggestion when you press Tab, so picking the right teammate or Issue is a single keypress",
"Task list items can be toggled from a one-click button in the editor's floating menu",
],
improvements: [
"Frontend continuous integration now skips automatically when a pull request does not touch frontend code, freeing up build time for the changes that actually need it",
"Command line subcommands have broader automated test coverage so everyday workflows stay stable across releases",
"Provider-specific default agent arguments now have explicit documentation, and a one-time Lark cutover flag was retired now that the unified channel adapter is fully in production",
],
fixes: [
"OpenClaw is more forgiving about config file mismatches and supports the newer 2026.6.x agents schema, keeping existing OpenClaw runtimes connected",
"Moving an Issue between projects now removes it from the old project list right away, and board column counts stay accurate when an Issue's status changes off-screen",
"Attachment previews open correctly even when files are served from a different origin",
"Command line agents wait for the daemon to be ready before falling back to a personal access token, and the self-host setup flow now respects existing configuration and surfaces server URL changes",
"Lark messages now link to the configured app URL instead of falling back to a generic web address",
"Codex runs clean up correctly even when their output overflows, Kiro runs preserve their goal completion state through close errors, and agent shutdown now terminates the entire opencode process group before closing",
"Quick-create reliably keeps every uploaded file attached when several uploads happen at the same time",
"Redis webhook rate limiting no longer throttles unrelated webhooks together, and daemon skill bundles load reliably even for large skill libraries",
"Issue label names now reject control characters so labels stay readable everywhere",
],
},
{
version: "0.3.29",
date: "2026-06-24",
title: "Feishu Channel Upgrade, Feature Rollout Controls, and More Reliable Autopilots",
changes: [],
features: [
"Feishu conversations now run on a new unified collaboration channel, making message handling more stable and consistent and laying the groundwork for more chat platforms",
"New feature rollout controls cover both the app and the daemon, so teams can open up risky changes gradually and to a limited audience",
"When agents read long Issue discussions, resolved threads now fold down to their key conclusion to keep the context focused",
"Feishu users can start a fresh conversation with the `/new` command, and Feishu WebSocket connections can use a configured proxy",
],
improvements: [
"Scheduled autopilots are more dependable: even with missed schedules, retries, or several runners working at once, they settle on the intended single run",
"Agent runtime briefings can switch to a slimmer version that drops redundant detail, with the full version still available as a fallback",
"Runtime provider docs now match the current provider list, with Qoder, CodeBuddy, and Antigravity guidance added and the outdated Gemini CLI runtime removed",
"The branch or version pinned in a project's repository settings now takes effect during local agent work, so agents no longer end up on the wrong branch",
],
fixes: [
"Sub-Issues now stay in stable creation order inside a parent Issue",
"Attachment previews now open correctly inside Issues",
"The @mention picker now selects the highlighted person or Issue even when search results reorder",
"Cancelled chat drafts stay deleted after you navigate away and come back",
"Autopilot cold starts, the agent status in the Issue header, and Antigravity provider errors now report more accurately",
],
},
{
version: "0.3.28",
date: "2026-06-23",
title: "Staged Sub-Issues and Qoder Runtime Support",
changes: [],
features: [
"Sub-Issues can now be organized into stages, so parallel work moves forward together and the parent Issue is updated only when a stage is complete",
"Assigning or batch-updating an Issue now confirms upfront whether it will start an agent — and which one — so you can apply the change without launching a run; when a run does start, you can attach a handoff note that the agent receives as context for that run",
"Qoder is now available as an agent provider, including model discovery and provider branding",
"Custom runtimes can include fixed launch arguments, with clearer feedback when a saved runtime cannot register",
],
improvements: [
"Project descriptions now travel with agent work, giving agents more durable context from the project they are working in",
"Command line workflows now cover comment resolve actions, Issue usage summaries, and autopilot subscriber management",
"Readonly code blocks include a copy button, and the marketing header now shows the live GitHub star count",
"Agent skill delivery is more efficient for newer daemons while keeping older daemons compatible",
],
fixes: [
"Issue batch edit menus now show the real shared status, priority, and assignee for the selected Issues",
"Dragging Issues across board and list views no longer snaps cards back before settling",
"GitHub PR links and check updates are routed to the workspace that owns the repository",
"Live task transcripts now keep updating while a run is still in progress",
"Custom runtime deletion now removes the saved profile instead of only removing a row that could return later",
],
},
{
version: "0.3.27",
date: "2026-06-22",
title: "Threaded Lark Replies and Smoother Team Workflows",
changes: [],
features: [
"Lark conversations now reply inside the original topic when a message starts from a topic, keeping team discussions easier to follow",
"Squad leaders can see member skills in the roster, making delegation more precise",
"Discord is now available from the website footer, help menu, README, and a dismissible in-app sidebar card",
],
improvements: [
"Agent activity in Issue headers opens on hover, so live work is easier to check at a glance",
"Desktop sidebars and pinned navigation feel smoother, clearer, and less noisy",
"Chat replies, assignment catch-up, and contributor guidance are tighter so agent work stays in the right place with less noise",
"Remote CLI setup and custom runtime deletion now give clearer guidance before users continue",
],
fixes: [
"Backlog parent Issues stay parked when child work finishes, avoiding unexpected follow-up automation",
"Project deletion now requires an owner or admin, and private GitHub skill imports work when a valid token is available",
"Login verification focuses the code field automatically, and detail sidebars no longer animate unexpectedly when pages open",
"Codex and daemon diagnostics are more reliable when permissions or slow task claims need investigation",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "More Reliable Agent Work Across Skills, Autopilots, and Chat",
changes: [],
features: [
"Local skill libraries on a developer machine can now be picked up automatically for agent runs",
"Autopilots can include default subscribers so the right teammates are included when new Issues are created",
"Chat attachments now stay tied to the current workspace and messages can continue sending without blocking the conversation",
"Failed agent comments can be retried directly from the Issue timeline",
],
improvements: [
"Usage reporting is more accurate when the same model name is available from different providers",
"Older Codex usage records can be filled in for more complete usage history",
"Runtime storage reporting is more complete across multiple workspace locations",
"Background task guidance and release checks are stricter, helping catch risky changes earlier",
],
fixes: [
"Issue mention chips in chat and comments now fit their container and no longer overlap nearby text",
"Workspace links now use the correct deployment host more reliably",
"Autopilot run folders are cleaned up after terminal runs finish",
"Desktop builds now handle commit-based version names correctly",
"Tencent CodeBuddy shows the correct provider logo",
"Daemon claim responses are smaller and faster to transfer",
],
},
{
version: "0.3.24",
date: "2026-06-17",

View File

@@ -1,4 +1,4 @@
import { githubUrl } from "../components/shared";
import { githubUrl, discordUrl } from "../components/shared";
import { createEnDict } from "./en";
import type { LandingDict } from "./types";
@@ -244,6 +244,7 @@ export function createJaDict(allowSignup: boolean): LandingDict {
{ label: "ドキュメント", href: "/docs/ja" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "Discord", href: discordUrl },
],
},
company: {
@@ -268,6 +269,177 @@ 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",
title: "ワークスペース横断の未読ドット、Composio ツールキット基盤、より使いやすいエディター",
changes: [],
features: [
"ワークスペース切替メニューで、別のワークスペースに未読のインボックスがあるとドットが表示されます。",
"これから提供するサードパーティ ツールキット連携のための Composio 基盤が組み込まれました。",
"複数のチェックアウトでデスクトップ開発環境を並列に起動しても衝突しなくなりました。",
"中国語ドキュメントのトップに短いイントロ動画を追加しました。",
],
improvements: [
"コントリビューター ドキュメントに、デスクトップ開発コマンドがチェックアウトごとに自動で隔離されることを明記しました。",
],
fixes: [
"Issue エディター内のリストで Tab を押すと、選択した項目が安定して字下げされ、カーソルがリストの外に飛ばなくなりました。",
"コメントの @メンションでスクワッド リーダーに依頼すると、スクワッドのブリーフィングを携えて起動し、親メンションを引き継いだ返信が再度トリガーすることもありません。",
"Issue やコメント内のコード ブロックで選択したテキストが、画面の別領域が再描画されても解除されなくなりました。",
"Issue を特定のエージェントに直接アサインすると、ハンドオフ メモ欄がそのまますぐに開きます。",
"ワークスペース切替メニューの未読ドットが、実際のインボックス表示と一致するようになりました。",
"Issue のコメント編集時、保存ボタンに明確なローディング表示が出るようになりました。",
"検索結果が再び安定して読み込まれます。",
"セルフホストで Docker Compose v2 が見つからないときは、すぐに分かりやすい案内とともに停止します。",
],
},
{
version: "0.3.30",
date: "2026-06-25",
title: "Slack 連携チャネルの追加、より使いやすいエディター、多数の安定性修正",
changes: [],
features: [
"Slack の会話が新しい統合連携チャネル上で動くようになり、Feishu や Lark と同じ安定感でメッセージをやり取りできます。",
"Issue エディター上で Tab を押すと、ハイライト中の @メンションや候補がそのまま挿入され、相手や Issue を 1 回のキー操作で選べます。",
"エディターのフローティングメニューに追加されたワンクリックボタンで、段落をタスクリストに素早く切り替えられます。",
],
improvements: [
"フロントエンドのコードを変更していないプルリクエストはフロントエンド CI を自動的にスキップし、本当に検証が必要な変更にビルド時間を回せます。",
"コマンドラインのサブコマンドに対する自動テストの範囲が広がり、リリースを重ねても日常の作業フローが安定して動きます。",
"プロバイダーごとのデフォルト エージェント引数を制御する環境変数が公式に文書化され、統合連携チャネルが完全に定着したことで使われなくなった Lark 切り替えスイッチを整理しました。",
],
fixes: [
"OpenClaw が設定ファイルの差異により寛容になり、新しい 2026.6.x の agents スキーマに対応したため、既存の OpenClaw ランタイムが切断されにくくなりました。",
"Issue を別プロジェクトに移すと旧プロジェクトの一覧からすぐに外れ、ボード表示外でステータスが変わってもカラムの件数が正しく揃います。",
"添付ファイルが別オリジンから配信されている場合でも、プレビューが正しく開けます。",
"コマンドライン エージェントはデーモンの準備完了を待ってから個人アクセストークンへフォールバックするため、認証が静かにダウングレードしなくなり、セルフホスティングのセットアップも既存設定を尊重しつつサーバー URL の変更をはっきり知らせます。",
"Lark メッセージの Web リンクは汎用 URL ではなく、設定したアプリ URL を使うようになりました。",
"Codex の実行は出力があふれても適切にクリーンアップされて止まらなくなり、Kiro の実行は終了時にエラーが出ても目標達成状態を保持し、エージェント終了時には opencode プロセスグループ全体を先に終了させてから出力を閉じます。",
"Issue のクイック作成で複数ファイルを同時にアップロードしても、すべての添付が確実に残ります。",
"Redis ベースの Webhook レート制限が無関係な Webhook を巻き込まなくなり、大きなスキルパックを含めてデーモンが安定して読み込めるようになりました。",
"Issue ラベル名は制御文字を受け付けなくなり、どの画面でもラベルが読みやすく保たれます。",
],
},
{
version: "0.3.29",
date: "2026-06-24",
title: "Feishu 連携チャネルの刷新、機能ロールアウト、オートパイロットの信頼性向上",
changes: [],
features: [
"Feishu の会話が新しい統合連携チャネル上で動くようになり、メッセージの送受信がより安定・一貫し、今後さらに多くのチャットプラットフォームを追加しやすくなりました。",
"機能ロールアウトの制御がアプリとデーモンの両方に広がり、チームは影響の大きい変更を段階的かつ限定的に有効化できます。",
"エージェントが長い Issue 議論を読むとき、解決済みスレッドが要点となる結論まで自動で折りたたまれ、コンテキストがより集中します。",
"Feishu では `/new` コマンドで新しい会話を始められ、Feishu の WebSocket 接続には指定のプロキシを使えます。",
],
improvements: [
"スケジュールされたオートパイロットの信頼性が向上しました。取り逃し、再試行、複数ランナーの同時処理があっても、意図したとおり 1 回だけ実行されます。",
"エージェントのランタイム説明は、より簡潔な版に切り替えて冗長な内容を省けます。必要なときは従来の詳しい版に戻せます。",
"ランタイムプロバイダーのドキュメントを現在の対応プロバイダーに合わせ、Qoder・CodeBuddy・Antigravity の案内を追加し、古い Gemini CLI ランタイムを削除しました。",
"プロジェクトのリポジトリ設定で指定したブランチ / バージョンが、ローカルエージェントの作業時に正しく反映され、誤ったブランチを取得しなくなります。",
],
fixes: [
"親 Issue 内の子 Issue が作成順で安定して表示されます。",
"Issue 内の添付ファイルプレビューが正しく開けるようになりました。",
"@メンション候補は、検索結果の並びが変わってもハイライト中の人または Issue を正しく選びます。",
"キャンセルしたチャット下書きを削除すると、画面を移動して戻っても再表示されなくなりました。",
"オートパイロットのコールドスタート、Issue ヘッダーのエージェント状態、Antigravity のプロバイダーエラー表示がより正確になりました。",
],
},
{
version: "0.3.28",
date: "2026-06-23",
title: "子 Issue のステージ対応と Qoder ランタイム対応",
changes: [],
features: [
"子 Issue をステージごとに整理できるようになり、同じステージの作業を並行して進め、ステージ完了時だけ親 Issue に更新できます。",
"Issue を割り当て・一括更新する際に、エージェントを起動するか(どのエージェントか)を事前に確認できるようになり、実行を起こさずに変更だけ適用できます。起動する場合は、その実行のコンテキストとしてエージェントに渡される引き継ぎメモを添えられます。",
"Qoder をエージェントプロバイダーとして選べるようになり、モデル検出とプロバイダー表示にも対応しました。",
"カスタムランタイムに固定の起動引数を設定でき、保存済みランタイムの登録に失敗した場合も分かりやすく表示されます。",
],
improvements: [
"プロジェクト説明がエージェント作業に引き継がれるようになり、プロジェクトの背景をより安定して参照できます。",
"コマンドラインでコメントの解決状態、Issue の利用状況サマリー、オートパイロットの購読者管理を扱えるようになりました。",
"読み取り専用コードブロックにコピーボタンが付き、ウェブサイトの GitHub ボタンには現在のスター数が表示されます。",
"新しいデーモンではエージェントスキルの受け渡しがより効率的になり、古いデーモンとの互換性も保たれます。",
],
fixes: [
"Issue の一括編集メニューで、選択した Issue に共通するステータス、優先度、担当者が正しく表示されます。",
"ボードやリストで Issue をドラッグしても、カードが元の位置に戻ってから移動するちらつきが起きにくくなりました。",
"GitHub PR のリンクとチェック更新は、そのリポジトリを所有するワークスペースに正しく届きます。",
"実行中のタスク記録ダイアログは、タスク完了やページ更新を待たずに新しい内容を表示し続けます。",
"カスタムランタイムの削除は、後から戻ってくる可能性のある行だけでなく保存済み設定を削除します。",
],
},
{
version: "0.3.27",
date: "2026-06-22",
title: "Lark のスレッド返信とチームワークフローの改善",
changes: [],
features: [
"Lark のトピックから始まった会話は元のトピック内に返信され、議論の流れを追いやすくなりました。",
"小隊リーダーはメンバーのスキルを一覧で確認でき、より適切に作業を任せられます。",
"Discord への入口がウェブサイトのフッター、ヘルプメニュー、README、閉じられるアプリ内サイドバーカードに追加されました。",
],
improvements: [
"Issue ヘッダーのエージェント活動はホバーで開けるようになり、進行中の作業をすばやく確認できます。",
"デスクトップのサイドバーと固定ナビゲーションがよりなめらかで分かりやすくなり、表示のノイズが減りました。",
"チャット返信、割り当て時のコメント確認、コントリビューター向けガイドが整理され、エージェント作業が適切な場所に収まりやすくなりました。",
"リモート環境での CLI セットアップとカスタムランタイム削除の案内がより分かりやすくなりました。",
],
fixes: [
"親 Issue がバックログにある場合、子タスク完了後も意図しない自動処理は起動しません。",
"プロジェクト削除にはオーナーまたは管理者権限が必要になり、有効なトークンがあれば private GitHub リポジトリからのスキル取り込みも動作します。",
"ログイン確認コード欄に自動でフォーカスし、詳細ページを開くときのサイドバーも意図せず動きません。",
"Codex の権限処理とデーモンの遅いタスク受け取り診断がより信頼しやすくなりました。",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "スキル、オートパイロット、チャットでのエージェント作業をより信頼性高く",
changes: [],
features: [
"開発者のマシンにあるローカルスキルライブラリを自動で見つけ、エージェント実行で使いやすくなりました。",
"オートパイロットに既定の購読者を設定でき、新しい Issue の確認に必要なチームメイトを含めやすくなりました。",
"チャット添付ファイルは現在のワークスペースに結び付き、送信中も会話を続けやすくなりました。",
"失敗したエージェントコメントを Issue タイムラインから直接再試行できます。",
],
improvements: [
"同じモデル名が複数のプロバイダーから提供される場合も、利用状況がより正確に集計されます。",
"過去の Codex 利用状況を補完でき、利用履歴がより完全になります。",
"複数のワークスペース場所にまたがるランタイムのストレージ使用量が分かりやすくなりました。",
"バックグラウンドタスクの案内とリリース前チェックがより厳密になり、リスクのある変更を早く見つけやすくなりました。",
],
fixes: [
"チャットとコメント内の Issue メンションチップが枠内に収まり、周辺テキストと重なりません。",
"ワークスペースリンクが正しいデプロイ先ホストをより安定して使います。",
"オートパイロット実行フォルダーは、ターミナル実行が終わると片付けられます。",
"デスクトップビルドはコミット由来のバージョン名を正しく扱います。",
"Tencent CodeBuddy に正しいプロバイダーロゴが表示されます。",
"デーモンのタスク受け取り応答が小さくなり、転送が速くなりました。",
],
},
{
version: "0.3.24",
date: "2026-06-17",

View File

@@ -1,4 +1,4 @@
import { githubUrl } from "../components/shared";
import { githubUrl, discordUrl } from "../components/shared";
import { createEnDict } from "./en";
import type { LandingDict } from "./types";
@@ -243,6 +243,7 @@ export function createKoDict(allowSignup: boolean): LandingDict {
{ label: "문서", href: "/docs/ko" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "Discord", href: discordUrl },
],
},
company: {
@@ -267,6 +268,177 @@ 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",
title: "워크스페이스 간 미확인 점, Composio 툴킷 기반, 더 편한 에디터",
changes: [],
features: [
"워크스페이스 전환기에서 다른 워크스페이스에 미확인 인박스가 있으면 점이 표시됩니다.",
"곧 도입될 서드파티 툴킷 연동을 위한 Composio 기반이 추가되었습니다.",
"여러 체크아웃에서 데스크톱 개발 환경을 동시에 실행해도 충돌이 없습니다.",
"중국어 문서 홈에 짧은 소개 영상이 추가되었습니다.",
],
improvements: [
"기여자 문서가 데스크톱 개발 명령이 체크아웃별로 자동 격리된다는 점을 안내합니다.",
],
fixes: [
"Issue 에디터 목록에서 Tab을 누르면 선택한 항목이 안정적으로 들여쓰기되고, 커서가 목록 밖으로 빠지지 않습니다.",
"댓글에서 @멘션으로 스쿼드 리더에게 작업을 맡기면 전체 스쿼드 브리핑과 함께 시작하며, 부모 멘션을 그대로 이어받은 답글은 리더를 다시 트리거하지 않습니다.",
"Issue와 댓글의 코드 블록에서 선택한 텍스트가 페이지의 다른 부분이 다시 렌더링되어도 풀리지 않습니다.",
"Issue를 특정 에이전트에 바로 할당하면 핸드오프 메모가 기다림 없이 곧바로 열립니다.",
"워크스페이스 전환기의 미확인 점이 실제 인박스 화면과 일치합니다.",
"Issue 댓글 편집 시 저장 버튼에 로딩 상태가 표시됩니다.",
"검색 결과가 다시 안정적으로 로드됩니다.",
"자체 호스팅에서 Docker Compose v2가 없으면 곧바로 명확한 안내와 함께 멈춥니다.",
],
},
{
version: "0.3.30",
date: "2026-06-25",
title: "Slack 협업 채널 추가, 더 편한 에디터, 다수의 안정성 개선",
changes: [],
features: [
"Slack 대화가 새로운 통합 협업 채널 위에서 동작해 Feishu·Lark와 동일한 안정성으로 메시지를 주고받을 수 있습니다.",
"Issue 작성기에서 Tab을 누르면 현재 강조된 @멘션이나 추천 항목이 바로 입력되어, 동료나 Issue를 한 번의 키 입력으로 고를 수 있습니다.",
"에디터의 플로팅 메뉴에 추가된 원클릭 버튼으로 단락을 할 일 목록으로 빠르게 전환할 수 있습니다.",
],
improvements: [
"프런트엔드 코드를 건드리지 않은 풀 리퀘스트는 프런트엔드 CI를 자동으로 건너뛰어, 실제로 검증이 필요한 변경에 빌드 시간이 돌아갑니다.",
"명령줄 서브커맨드의 자동화 테스트 범위가 넓어져서, 릴리스를 거듭해도 일상적인 작업 흐름이 안정적으로 유지됩니다.",
"제공자별 기본 에이전트 인자 환경 변수에 대한 공식 문서가 추가되었고, 통합 협업 채널이 완전히 정착함에 따라 일회성 Lark 전환 스위치를 정리했습니다.",
],
fixes: [
"OpenClaw가 설정 파일 차이에 더 너그러워졌고, 새로운 2026.6.x agents 스키마를 지원해 기존 OpenClaw 런타임이 끊기지 않습니다.",
"Issue를 다른 프로젝트로 옮기면 이전 프로젝트 목록에서 즉시 빠지고, 보드 화면 밖에서 상태가 바뀌어도 컬럼 카운트가 정확하게 맞춰집니다.",
"파일이 다른 출처에서 제공되더라도 첨부 파일 미리보기가 정상적으로 열립니다.",
"명령줄 에이전트는 데몬이 준비된 뒤에야 개인 액세스 토큰으로 폴백하므로 인증이 조용히 다운그레이드되지 않고, 자체 호스팅 설정도 기존 구성을 존중하면서 서버 URL 변경을 분명히 보여 줍니다.",
"Lark 메시지의 웹 링크는 일반 주소로 떨어지지 않고, 구성된 앱 URL을 사용합니다.",
"Codex 실행은 출력이 넘쳐도 깔끔하게 정리되어 더 이상 멈추지 않고, Kiro 실행은 종료 중 오류가 발생해도 목표 완료 상태를 유지하며, 에이전트 종료 시에는 opencode 프로세스 그룹 전체를 먼저 끝낸 뒤 출력을 닫습니다.",
"Issue 빠른 생성 시 동시에 업로드되는 여러 파일이 안정적으로 모두 첨부됩니다.",
"Redis 기반 Webhook 속도 제한이 더 이상 서로 무관한 Webhook을 한꺼번에 제한하지 않으며, 데몬은 큰 스킬 묶음도 안정적으로 로드합니다.",
"Issue 라벨 이름은 제어 문자를 거부해 모든 화면에서 라벨이 깔끔하게 표시됩니다.",
],
},
{
version: "0.3.29",
date: "2026-06-24",
title: "Feishu 협업 채널 개선, 기능 출시 제어, 더 안정적인 오토파일럿",
changes: [],
features: [
"Feishu 대화가 새로운 통합 협업 채널에서 동작해 메시지 송수신이 더 안정적이고 일관되며, 앞으로 더 많은 채팅 플랫폼을 붙이기 쉬워졌습니다.",
"기능 출시 제어가 앱과 데몬 양쪽으로 확장되어, 팀이 영향이 큰 변경을 단계적으로 그리고 제한된 범위로 켤 수 있습니다.",
"에이전트가 긴 Issue 토론을 읽을 때 해결된 스레드가 핵심 결론으로 자동으로 접혀 컨텍스트가 더 집중됩니다.",
"Feishu 사용자는 `/new` 명령으로 새 대화를 시작할 수 있고, Feishu WebSocket 연결은 지정한 프록시를 사용할 수 있습니다.",
],
improvements: [
"예약 오토파일럿의 안정성이 향상되었습니다. 놓친 일정, 재시도, 여러 러너의 동시 처리가 있어도 의도한 대로 한 번만 실행됩니다.",
"에이전트 런타임 안내를 더 간결한 버전으로 전환해 불필요한 내용을 줄일 수 있으며, 필요하면 기존의 자세한 버전으로 되돌릴 수 있습니다.",
"런타임 제공자 문서를 현재 지원 목록에 맞추고 Qoder·CodeBuddy·Antigravity 안내를 추가했으며, 오래된 Gemini CLI 런타임을 제거했습니다.",
"프로젝트 저장소 설정에서 지정한 브랜치 / 버전이 로컬 에이전트 작업에 올바르게 반영되어, 더 이상 잘못된 브랜치를 가져오지 않습니다.",
],
fixes: [
"부모 Issue 안의 하위 Issue가 생성 순서대로 안정적으로 표시됩니다.",
"Issue 안의 첨부 파일 미리보기가 올바르게 열립니다.",
"@멘션 선택기는 검색 결과 순서가 바뀌어도 현재 강조된 사람이나 Issue를 정확히 선택합니다.",
"취소한 채팅 초안을 삭제하면 화면을 이동했다가 돌아와도 다시 나타나지 않습니다.",
"오토파일럿 콜드 스타트, Issue 헤더의 에이전트 상태, Antigravity 제공자 오류 표시가 더 정확해졌습니다.",
],
},
{
version: "0.3.28",
date: "2026-06-23",
title: "하위 Issue 단계 지원과 Qoder 런타임 지원",
changes: [],
features: [
"하위 Issue를 단계별로 정리할 수 있어 같은 단계의 작업을 함께 진행하고, 단계가 끝났을 때만 부모 Issue가 업데이트됩니다.",
"이제 Issue를 할당하거나 일괄 업데이트할 때 에이전트를 시작할지(어떤 에이전트인지)를 먼저 확인할 수 있어, 실행을 일으키지 않고 변경만 적용할 수 있습니다. 시작할 때는 그 실행의 컨텍스트로 에이전트에 전달되는 인계 메모를 첨부할 수 있습니다.",
"Qoder를 에이전트 제공자로 선택할 수 있으며, 모델 검색과 제공자 표시도 함께 지원됩니다.",
"사용자 지정 런타임에 고정 실행 인수를 둘 수 있고, 저장된 런타임이 등록되지 못할 때 더 명확한 피드백을 제공합니다.",
],
improvements: [
"프로젝트 설명이 에이전트 작업에 함께 전달되어 프로젝트 배경을 더 안정적으로 참고할 수 있습니다.",
"명령줄에서 댓글 해결 상태, Issue 사용량 요약, 오토파일럿 구독자 관리를 처리할 수 있습니다.",
"읽기 전용 코드 블록에 복사 버튼이 추가되고, 웹사이트의 GitHub 버튼은 실시간 스타 수를 보여 줍니다.",
"새 데몬에서는 에이전트 스킬 전달이 더 효율적이며, 이전 데몬과의 호환성도 유지됩니다.",
],
fixes: [
"Issue 일괄 편집 메뉴가 선택한 Issue들의 공통 상태, 우선순위, 담당자를 올바르게 보여 줍니다.",
"보드와 목록에서 Issue를 드래그할 때 카드가 원래 위치로 되돌아갔다가 다시 이동하는 깜박임이 줄었습니다.",
"GitHub PR 연결과 체크 업데이트가 해당 저장소를 소유한 워크스페이스로 올바르게 전달됩니다.",
"실행 중인 작업 기록 대화상자가 작업 종료나 페이지 새로 고침 없이도 계속 최신 내용을 보여 줍니다.",
"사용자 지정 런타임 삭제가 나중에 다시 나타날 수 있는 행만 지우지 않고 저장된 설정을 삭제합니다.",
],
},
{
version: "0.3.27",
date: "2026-06-22",
title: "Lark 스레드 답장과 팀 작업 흐름 개선",
changes: [],
features: [
"Lark 토픽에서 시작된 대화는 이제 원래 토픽 안에 답장되어 팀 논의를 더 쉽게 따라갈 수 있습니다.",
"스쿼드 리더가 멤버의 스킬을 명단에서 바로 확인할 수 있어 작업 위임이 더 정확해졌습니다.",
"Discord 진입점이 웹사이트 푸터, 도움말 메뉴, README, 닫을 수 있는 앱 사이드바 카드에 추가되었습니다.",
],
improvements: [
"Issue 헤더의 에이전트 활동 상태가 hover로 열려 진행 중인 작업을 더 빠르게 확인할 수 있습니다.",
"데스크톱 사이드바와 고정 탐색이 더 부드럽고 명확해져 화면의 불필요한 시각 소음이 줄었습니다.",
"채팅 답변, 할당 시 댓글 확인, 기여자 안내가 정리되어 에이전트 작업이 올바른 위치에 머물기 쉬워졌습니다.",
"원격 CLI 설정과 사용자 지정 런타임 삭제 안내가 더 명확해졌습니다.",
],
fixes: [
"부모 Issue가 백로그에 있을 때는 하위 작업이 완료되어도 원치 않는 후속 자동화가 시작되지 않습니다.",
"프로젝트 삭제는 소유자 또는 관리자만 할 수 있으며, 유효한 토큰이 있으면 private GitHub 저장소의 스킬 가져오기도 동작합니다.",
"로그인 인증 코드 입력칸에 자동으로 포커스되고, 상세 화면을 열 때 사이드바가 의도치 않게 움직이지 않습니다.",
"Codex 권한 처리와 데몬의 느린 작업 수신 진단이 더 신뢰할 수 있게 개선되었습니다.",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "스킬, 오토파일럿, 채팅 전반의 에이전트 작업 안정성 강화",
changes: [],
features: [
"개발자 기기의 로컬 스킬 라이브러리를 자동으로 찾아 에이전트 실행에서 더 쉽게 사용할 수 있습니다.",
"오토파일럿에 기본 구독자를 설정할 수 있어 새 Issue를 만들 때 필요한 팀원을 함께 포함하기 쉽습니다.",
"채팅 첨부 파일이 현재 워크스페이스에 연결되고, 메시지를 보내는 동안에도 대화를 계속하기 쉬워졌습니다.",
"실패한 에이전트 댓글을 Issue 타임라인에서 바로 다시 시도할 수 있습니다.",
],
improvements: [
"같은 모델 이름이 여러 제공자에서 제공될 때도 사용량 보고가 더 정확합니다.",
"이전 Codex 사용 기록을 보완해 사용량 이력을 더 완전하게 만들 수 있습니다.",
"여러 워크스페이스 위치에 걸친 런타임 저장 공간 사용량을 더 명확하게 보여 줍니다.",
"백그라운드 작업 안내와 릴리스 전 검사가 더 엄격해져 위험한 변경을 더 일찍 찾을 수 있습니다.",
],
fixes: [
"채팅과 댓글의 Issue 멘션 칩이 영역 안에 맞게 표시되고 주변 텍스트와 겹치지 않습니다.",
"워크스페이스 링크가 올바른 배포 호스트를 더 안정적으로 사용합니다.",
"오토파일럿 실행 폴더는 터미널 실행이 끝난 뒤 정리됩니다.",
"데스크톱 빌드는 커밋 기반 버전 이름을 올바르게 처리합니다.",
"Tencent CodeBuddy에 올바른 제공자 로고가 표시됩니다.",
"데몬의 작업 수신 응답이 더 작아져 전송이 빨라졌습니다.",
],
},
{
version: "0.3.24",
date: "2026-06-17",

View File

@@ -1,4 +1,4 @@
import { githubUrl } from "../components/shared";
import { githubUrl, discordUrl } from "../components/shared";
import type { LandingDict } from "./types";
export function createZhDict(allowSignup: boolean): LandingDict {
@@ -244,6 +244,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{ label: "\u6587\u6863", href: "/docs/zh" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "Discord", href: discordUrl },
],
},
company: {
@@ -292,6 +293,177 @@ 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",
title: "跨工作区未读小圆点、Composio 工具集底座、更顺手的编辑器",
changes: [],
features: [
"工作区切换器里,其他工作区有未读 Inbox 时会亮起小圆点。",
"新增 Composio 工具集底座,为后续第三方工具对接做好准备。",
"现在可以在多个本地检出里并行启动桌面端 dev互不打架。",
"中文文档首页新增一段中文介绍视频,可点击播放。",
],
improvements: [
"贡献者文档明确说明桌面端 dev 命令会按检出自动隔离。",
],
fixes: [
"Issue 编辑器列表里按 Tab 现在能稳定缩进所选项,光标也不会跑出列表。",
"通过 @ 提及让小队 Leader 接手时,会带上完整的小队 Briefing继承父级提及的回复也不会再次触发 Leader。",
"Issue 和评论里代码块的选区,在页面其他位置刷新时不再丢失。",
"把 Issue 直接交给某个智能体时,运行确认弹窗会立刻展开 Handoff 备注。",
"工作区切换器上的未读小圆点会和你看到的 Inbox 保持一致。",
"编辑 Issue 评论时,保存按钮会显示加载状态,直到保存完成。",
"搜索结果能够稳定加载。",
"自托管缺少 Docker Compose v2 时会立刻给出明确的安装提示。",
],
},
{
version: "0.3.30",
date: "2026-06-25",
title: "Slack 协作通道接入,编辑器更顺手,多项稳定性修复",
changes: [],
features: [
"Slack 对话接入全新的统一协作通道与飞书、Lark 一样稳定,消息收发更可靠",
"在 Issue 编辑器里按 Tab可以直接选中当前高亮的 @ 提及或建议项,挑选同事或 Issue 一键完成",
"在编辑器的浮动菜单里新增一键开关,能够快速把段落切换成任务清单",
],
improvements: [
"前端持续集成会自动跳过没有改动前端代码的 PR把构建时间留给真正需要的改动",
"命令行子命令的自动化测试覆盖更广,让日常工作流在每次发版后依然稳定",
"为每个服务商默认的智能体启动参数补齐说明文档,并下线了一次性的飞书切换开关——统一协作通道已经在生产环境完全接管",
],
fixes: [
"OpenClaw 对配置文件差异更宽容,并且支持新版 2026.6.x 的 agents 配置格式,已有的 OpenClaw 运行时不会因此掉线",
"把 Issue 移动到其他项目时,会立刻从原来的项目列表里消失;并且在 Issue 状态从看板视野外切换时,看板列上的数字也会正确同步",
"当附件由不同来源的资源服务器提供时,预览也可以正常打开",
"命令行智能体会等待守护进程就绪后再决定鉴权来源,避免悄悄回落到个人访问令牌;自托管环境配置流程也会沿用现有设置并清晰展示服务地址的变化",
"飞书消息中的网页链接现在会指向你配置的应用 URL而不是回退到通用网址",
"Codex 任务在输出过载时也能正常清理不会再卡住Kiro 任务即便关闭过程中出现错误,也能保留目标完成状态;智能体退出时会先终止整组 opencode 子进程,再关闭输出",
"在快速创建 Issue 时同时上传多个文件,所有附件都会稳定地保留下来",
"Redis 上的 Webhook 限流不会再把无关的 Webhook 合并计算,避免被一起误伤;守护进程加载多个 skill 包时,即便 skill 体积较大也能稳定完成",
"Issue 标签名不再接受控制字符,标签在各端展示都更整洁可读",
],
},
{
version: "0.3.29",
date: "2026-06-24",
title: "飞书协作通道升级,新增功能灰度发布,定时自动化更可靠",
changes: [],
features: [
"飞书对话升级到全新的统一协作通道,消息收发更稳定一致,也为后续接入更多聊天平台打下基础",
"新增功能灰度能力,覆盖应用和守护进程两侧,团队可以分阶段、小范围地开放高风险改动",
"智能体阅读很长的 Issue 讨论时,会自动把已解决的讨论折叠到关键结论,让上下文更聚焦",
"飞书用户可以用 `/new` 开启新会话,飞书的 WebSocket 连接也支持配置代理",
],
improvements: [
"定时自动化更可靠:遇到漏跑、重试或多个执行端同时处理时,也能稳定地只按预期执行一次",
"智能体运行的开场说明可以切换到更精简的版本,去掉冗余内容,必要时仍可切回完整版本",
"运行时服务商文档已更新到当前支持的服务商,新增 Qoder、CodeBuddy、Antigravity 说明,并移除过时的 Gemini CLI 信息",
"项目仓库设置里指定的分支 / 版本,现在会在本地智能体工作时正确生效,不会再拿到错误的分支",
],
fixes: [
"父 Issue 下的子 Issue 现在会按创建顺序稳定展示",
"Issue 内的附件预览现在可以正常打开",
"@ 提及时即使搜索结果重新排序,也会准确选中当前高亮的人或 Issue",
"删除已取消的聊天草稿后,切换页面再回来不会再次出现",
"自动化冷启动、Issue 顶部智能体状态和 Antigravity 服务商错误提示更准确",
],
},
{
version: "0.3.28",
date: "2026-06-23",
title: "子 Issue 支持分阶段,新增 Qoder 运行时支持",
changes: [],
features: [
"子 Issue 现在可以按阶段组织,同一阶段的工作可以并行推进,父 Issue 只会在整个阶段完成后收到更新",
"现在指派或批量更新 Issue 时,会先确认这次操作是否会启动智能体、启动的是哪一个,让你可以只改动而不触发运行;确认启动时,还能附上一段交接说明,作为智能体这次运行的开场上下文",
"Qoder 现在可以作为智能体服务商使用,并带有模型发现和服务商品牌展示",
"自定义运行时可以配置固定启动参数;保存的运行时无法注册时,也会给出更清楚的提示",
],
improvements: [
"项目描述现在会跟随智能体工作一起提供,让智能体获得更稳定的项目上下文",
"命令行现在支持处理评论解决状态、查看 Issue 用量汇总,以及管理自动任务订阅人",
"只读代码块新增复制按钮,官网页头的 GitHub 按钮也会显示实时星标数",
"新版守护进程获取智能体技能时更高效,同时继续兼容旧版本守护进程",
],
fixes: [
"批量编辑 Issue 时,菜单现在会正确显示所选 Issue 共有的状态、优先级和指派人",
"在看板和列表中拖动 Issue 时,卡片不会再先跳回原位再移动到目标位置",
"GitHub PR 关联和检查更新会路由到真正拥有该仓库的工作空间",
"运行中的任务记录弹窗现在会持续更新,不必等任务结束或刷新页面",
"删除自定义运行时时会删除保存的配置,而不是只删除之后可能重新出现的运行时行",
],
},
{
version: "0.3.27",
date: "2026-06-22",
title: "Lark 话题回复和团队协作流程优化",
changes: [],
features: [
"Lark 里的话题消息现在会回到原话题中,团队讨论更容易保持上下文",
"小队负责人现在可以在成员列表里看到成员技能,分配任务时更容易选对人",
"Discord 入口已加入官网页脚、帮助菜单、README以及可关闭的应用侧边栏卡片",
],
improvements: [
"Issue 顶部的智能体活动状态现在悬停即可展开,更方便快速查看当前进展",
"桌面侧边栏和固定导航更顺滑、更清爽,减少不必要的视觉干扰",
"聊天回复、任务分配补读和贡献者指引更克制,智能体工作更容易留在正确位置",
"远程命令行初始化和自定义运行时删除现在会给出更清楚的操作提示",
],
fixes: [
"父 Issue 仍在待办池时,子任务完成不会意外唤起后续自动处理",
"删除项目现在需要所有者或管理员权限;私有 GitHub 仓库的技能导入在配置有效令牌后可以正常完成",
"登录验证码输入框会自动聚焦,进入详情页时侧边栏也不会再意外播放动画",
"Codex 权限处理和守护进程慢任务诊断更可靠,排查问题时信息更完整",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "让技能、自动任务和聊天中的智能体工作更可靠",
changes: [],
features: [
"开发者机器上的本地技能库现在可以被自动识别,智能体运行时更容易复用团队能力",
"自动任务可以配置默认订阅人,新建 Issue 时更容易把相关队友带入确认",
"聊天附件会绑定到当前工作空间,发送消息时也不会阻塞后续对话",
"智能体评论发送失败后,可以直接在 Issue 时间线里重试",
],
improvements: [
"同名模型来自不同服务商时,使用量统计会更准确",
"历史 Codex 使用量可以补齐,用量记录更完整",
"运行时存储统计会覆盖更多工作目录,空间占用更清楚",
"后台任务指引和发版检查更严格,可以更早发现高风险改动",
],
fixes: [
"聊天和评论里的 Issue 提及标签会适配容器宽度,不再和周围文字重叠",
"工作空间链接会更稳定地使用正确的部署域名",
"自动任务运行结束后,会清理对应的运行目录",
"桌面端可以正确处理基于提交版本的版本号",
"Tencent CodeBuddy 会显示正确的服务商图标",
"守护进程领取任务的响应更小,传输更快",
],
},
{
version: "0.3.24",
date: "2026-06-17",

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { formatStarCount } from "./use-github-stars";
describe("formatStarCount", () => {
it("renders counts below 1,000 exactly", () => {
expect(formatStarCount(0)).toBe("0");
expect(formatStarCount(7)).toBe("7");
expect(formatStarCount(999)).toBe("999");
});
it("formats thousands with one decimal, GitHub-style", () => {
expect(formatStarCount(37_600)).toBe("37.6k");
expect(formatStarCount(1_234)).toBe("1.2k");
expect(formatStarCount(12_300)).toBe("12.3k");
});
it("trims a trailing .0 ('1k', not '1.0k')", () => {
expect(formatStarCount(1_000)).toBe("1k");
expect(formatStarCount(2_000)).toBe("2k");
});
it("rounds to one decimal like the repo header", () => {
expect(formatStarCount(1_949)).toBe("1.9k");
expect(formatStarCount(1_990)).toBe("2k");
});
it("formats millions with an 'm' suffix", () => {
expect(formatStarCount(1_200_000)).toBe("1.2m");
expect(formatStarCount(2_000_000)).toBe("2m");
});
});

View File

@@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
/**
* Live GitHub star count for the landing header's "GitHub" button.
*
* Fetched client-side on purpose: the badge lives in the shared
* {@link LandingHeader}, which renders on every marketing page, so a single
* client fetch covers them all without threading a server value through eight
* render sites. Each visitor calls the GitHub API from their own IP, which
* sidesteps the shared-outbound-IP rate limit that the server-side
* `github-release.ts` fetcher has to work around with a PAT.
*
* The result is memoized at module scope (plus an in-flight promise) so
* client-side navigation between landing pages reuses the first fetch instead
* of hitting the API again. A failed fetch caches `null` so we don't retry in
* a loop; the button just degrades to its plain "GitHub" label.
*/
const REPO = "multica-ai/multica";
// `undefined` = never fetched; `number` = resolved count; `null` = fetch failed.
let cachedStars: number | null | undefined;
let inFlight: Promise<number | null> | null = null;
async function loadStars(): Promise<number | null> {
if (cachedStars !== undefined) return cachedStars;
if (inFlight) return inFlight;
inFlight = fetch(`https://api.github.com/repos/${REPO}`, {
headers: { Accept: "application/vnd.github+json" },
})
.then((res) => {
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`);
return res.json() as Promise<{ stargazers_count?: unknown }>;
})
.then((data) => {
const count =
typeof data.stargazers_count === "number" ? data.stargazers_count : null;
cachedStars = count;
return count;
})
.catch(() => {
cachedStars = null;
return null;
})
.finally(() => {
inFlight = null;
});
return inFlight;
}
export function useGithubStars(): number | null {
const [stars, setStars] = useState<number | null>(cachedStars ?? null);
useEffect(() => {
let active = true;
void loadStars().then((count) => {
if (active && count != null) setStars(count);
});
return () => {
active = false;
};
}, []);
return stars;
}
/**
* Compact star count matching GitHub's own repo-header style: one decimal
* thousands/millions with the trailing ".0" trimmed ("1k", "37.6k", "1.2m").
* Counts below 1,000 render exactly. Mirrors GitHub's `toFixed(1)` rounding so
* our badge reads the same as the figure on the repo page.
*/
export function formatStarCount(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return String(n);
}

View File

@@ -11,6 +11,11 @@ if (typeof globalThis.ResizeObserver === "undefined") {
} as unknown as typeof ResizeObserver;
}
// jsdom doesn't implement elementFromPoint; input-otp uses it internally.
if (typeof document.elementFromPoint !== "function") {
document.elementFromPoint = () => null;
}
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
// Create a proper localStorage mock if methods are missing.
if (

View File

@@ -0,0 +1,91 @@
# Codex Usage Cache Backfill
This runbook describes the one-time hosted data repair for Codex usage rows
created before cached input was normalized at ingestion time.
Do not run this as an automatic database migration. The write step needs an
operator-selected cutoff, dry-run review, and an explicit execute command.
## When To Run
Run this only after the backend image containing `backfill_codex_usage_cache`
has been deployed, and only for databases that need historical Codex usage
correction.
Use the actual hosted deployment time of PR #4083 as `--cutoff`. Do not use the
PR merge time unless it is also the real production cutover time.
## Execution Model
Run the command from the released backend image as a one-time operator job, such
as a Kubernetes Job with the normal backend database secret and network access.
Override the command to execute `./backfill_codex_usage_cache`.
The command defaults to dry-run. It mutates data only when `--execute` is passed.
## Dry Run
First run:
```bash
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME>
```
Optionally limit scope while validating:
```bash
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME> --workspace-id <workspace-uuid>
```
Review the per-workspace/date output:
- `rows`
- `input_before`
- `input_after`
- `input_tokens_removed`
- `clamped_rows`
Proceed only if the totals match the expected overcount shape.
## Execute
After dry-run review:
```bash
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME> --execute
```
For large datasets, throttle writes:
```bash
./backfill_codex_usage_cache \
--cutoff <RFC3339_DEPLOY_TIME> \
--execute \
--batch-size 500 \
--sleep-between-batches 1s
```
By default, execution rebuilds affected hourly rollups by calling
`rollup_task_usage_hourly_window(...)` for the database update window. Leave
`--rebuild-rollup=true` unless an operator intentionally plans a separate rollup
rebuild.
## Verification
After execution, run the dry-run command again with the same cutoff and scope.
Eligible rows should be zero.
Then verify Usage / Runtime dashboard periods that were previously inflated.
## Safety Boundaries
The command updates only rows that match all of these conditions:
- `provider = 'codex'`
- `cache_read_tokens > 0`
- `input_tokens > 0`
- `COALESCE(updated_at, created_at) < --cutoff`
- optional `--workspace-id` match
Rows without persisted `cache_read_tokens` are intentionally ignored because the
current database cannot accurately reconstruct cached input for them.

61
docs/custom-runtimes.md Normal file
View File

@@ -0,0 +1,61 @@
# Custom runtimes
Custom runtime profiles let a workspace register an AI CLI that speaks one of
Multica's supported protocol families but is launched through a team-specific
command.
## Command and arguments
Paste the same argv-style command you would run in a terminal:
```sh
agent --model composer-2.5
```
Multica stores this as:
- `command_name`: `agent`
- `fixed_args`: `["--model", "composer-2.5"]`
The daemon starts the process directly with `exec.Command(command_name,
fixed_args...)`; it does not run a shell.
Supported input:
- plain arguments separated by whitespace
- single or double quotes for values with spaces
- backslash escaping for literal spaces or quote characters
The UI parser is argv-oriented, not a full POSIX shell. Inside double quotes,
`\` escapes the next character directly; use single quotes when you need `$` or
backticks to stay literal. Running tasks keep the launch args they started with;
profile command or argument edits apply to newly claimed tasks after the daemon
re-registers.
Unsupported input:
- pipes, redirects, `;`, `&&`, `||`
- backticks
- `$VAR` or `$(...)` expansion
Use a wrapper script when the runtime needs shell behavior.
## Command not found
Desktop-launched daemons may not inherit the same `PATH` as an interactive
terminal. If a custom runtime shows a registration error even though the command
works in your shell, pin the absolute path on that machine:
```sh
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
```
Then restart or refresh the daemon so it re-registers the profile.
## Upgrade order
Custom runtime arguments and registration-error reporting require both the
server and daemon versions that support `fixed_args` launch specs and
`failed_profiles` registration reports. In mixed deployments, upgrade the server
before rolling out newer daemons so failed custom-only profiles can be recorded
instead of being rejected as an empty runtime registration.

252
docs/feature-flags.md Normal file
View File

@@ -0,0 +1,252 @@
# Feature Flags
Multica ships a framework-level feature flag implementation:
- **Backend**: `server/pkg/featureflag` — Go package.
- **Frontend**: `@multica/core/feature-flags` — TypeScript module with React hooks.
Both sides share the same vocabulary (`Decision`, `EvalContext`, `Rule`, `PercentRollout`) and the same FNV-1a percent bucketing, so a flag evaluated on the server and on the client lands in the same bucket for the same user.
The package is designed so new features can adopt feature flags without writing any infrastructure code — drop a rule into the static config, call `Service.IsEnabled` / `useFlag`, done.
---
## Core concepts
```
[Toggle Point] --query--> [Service / Router] --read--> [Provider / Configuration]
business code static / env / chain
```
- A **Toggle Point** is the single `if` in business code. It always calls the Service, never the provider directly.
- The **Service** (`Service` in Go, `FeatureFlagService` in TS) is the router. Business code never depends on which provider is behind it.
- A **Provider** is the configuration backend. Today we ship `StaticProvider` (in-memory rules), `EnvProvider` (Go only — env-var override), and `ChainProvider` (composition). A future DB or LaunchDarkly provider plugs in without changing any caller.
- A **Decision** is the structured result: `{ enabled, variant, reason, source }`. `IsEnabled` is the boolean projection, `Variant` is the raw string. Use `Decision` for diagnostic endpoints.
Four flag categories (Martin Fowler):
| Category | Lifetime | Owner | Example |
|---|---|---|---|
| **Release** | Daysweeks | Engineering | Hide a half-finished page behind `flags_release_v2` |
| **Experiment** | Hoursweeks | Product / Data | A/B test `checkout_algo` between `control` and `experiment-v2` |
| **Ops** | Short or evergreen | SRE | Kill switch `billing_disable_invoice_pdf` |
| **Permission** | Years | Product | `plan_gate_enterprise_dashboard` |
Manage them in the same provider but treat them differently: Release flags get deleted; Ops flags need fast override paths (`FF_<KEY>` env var); Permission flags use `Allow` lists; Experiment flags use `PercentRollout`.
---
## Backend (Go)
### Wiring at startup
The server constructs a `featureflag.Service` once in `cmd/server/main.go` via the standard helper:
```go
flags, err := featureflag.NewServiceFromEnv(featureflag.WithLogger(slog.Default()))
if err != nil {
slog.Error("feature flag configuration failed to load", "error", err)
os.Exit(1)
}
```
`NewServiceFromEnv` reads two env vars — both follow the same `MULTICA_*_FILE` / `FF_*` conventions documented in `.env.example`:
| Env var | Role |
|---|---|
| `MULTICA_FEATURE_FLAGS_FILE` | Path to the YAML rule set (optional; absent = no static rules). |
| `FF_<FLAG_KEY>` | Per-flag runtime override. `FF_BILLING_NEW_INVOICE_EMAIL=false` / `25%` / `experiment-v2`. Beats the YAML, no redeploy. |
The provider chain is `EnvProvider → YAML StaticProvider`. The server can boot with zero flag config — every `IsEnabled` call falls back to the caller's default until someone authors a rule.
### Daemon-bound flags
Daemon-bound flags are evaluated by the server and delivered to local daemons
over the daemon heartbeat ack. This is for process-level daemon behavior where
operators need one rollout and kill-switch path across cloud runtimes, Desktop
embedded daemons, and user-run CLI daemons.
Only flags listed in `server/internal/featureflagdispatch/registry.go` are sent
to daemons. The registry is intentionally short:
```go
var DaemonBoundFlags = []string{
"runtime_brief_slim",
}
```
On each HTTP or WebSocket heartbeat, the server evaluates every registered key
as a daemon/process-level decision. The snapshot EvalContext exposes
`daemon_id` only; workspace/runtime/task/user scoped rollout is intentionally
not part of this channel because the daemon stores one process-global snapshot.
The heartbeat ack carries a full snapshot:
```json
{
"feature_flags": {
"version": 1,
"flags": {
"runtime_brief_slim": "on"
}
}
}
```
The daemon installs that snapshot into its process-level feature flag service.
The daemon provider order is:
1. `EnvProvider` (`FF_*`) for local emergency overrides.
2. `ServerSnapshotProvider` from the latest heartbeat ack.
3. local YAML `StaticProvider` as a fallback for old servers or self-hosted rescue.
4. the toggle point's caller-supplied default.
That means `FF_RUNTIME_BRIEF_SLIM=false` always suppresses a server snapshot
that enables `runtime_brief_slim`. New daemons talking to old servers receive no
`feature_flags` field and automatically fall back to local env/YAML behavior.
Old daemons talking to new servers ignore the unknown JSON field.
To add another daemon-bound process-level flag, add its key to the registry and
use the existing daemon feature flag service at the toggle point. Do not add
workspace percent rollout, task payload fields, or task-scoped readers for
daemon-bound flags unless a separate design explicitly introduces scoped daemon
flag evaluation.
### YAML schema
```yaml
# /etc/multica/feature-flags.yaml
billing_new_invoice_email:
default: true
checkout_algo:
default: false
variant: experiment-v2
percent:
percent: 25
by: user_id
ops_disable_recommendations:
default: false
allow: ["user-internal-1", "user-internal-2"]
allow_by: user_id
```
Every field except `default` is optional. `variant` is the on-variant — see the multi-arm note below. An empty file is a valid "no flags yet" state. Malformed YAML fails startup the same way `DATABASE_URL` parse errors do, so misconfig surfaces loudly.
### Attaching evaluation context to the request
```go
func middleware(flags *featureflag.Service, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ec := featureflag.EvalContext{
UserID: currentUserID(r),
WorkspaceID: currentWorkspaceID(r),
Attributes: map[string]string{"plan": currentPlan(r)},
}
ctx := featureflag.WithEvalContext(r.Context(), ec)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
```
### Toggle point in business code
```go
if flags.IsEnabled(ctx, "billing_new_invoice_email", false) {
return s.sendNewInvoiceEmail(ctx, invoice)
}
return s.sendLegacyInvoiceEmail(ctx, invoice)
```
For multi-arm flags:
```go
switch flags.Variant(ctx, "checkout_algo", "control") {
case "experiment-v2":
return checkoutV2(ctx, order)
case "experiment-v3":
return checkoutV3(ctx, order)
default:
return checkoutControl(ctx, order)
}
```
`Rule.Variant` is the **on-variant**: it is only returned when the rule evaluates to enabled=true (allow hit, percent hit, default-on). When the rule evaluates to disabled (deny hit, percent miss, default-off) the Service returns `"off"` so callers branching on `Variant()` cannot route control users into the experiment arm. This is exercised by `TestStaticProviderVariantOnlyWhenEnabled` and is the same on the TS side.
The Service is nil-safe and missing-key-safe: `(*Service)(nil).IsEnabled(ctx, "any", true)` returns `true`. Business code never needs to guard against a missing flag.
---
## Frontend (TypeScript / React)
### Mounting once at the root
```tsx
// apps/web/app/_providers.tsx (or the equivalent root)
import {
FeatureFlagsProvider,
FeatureFlagService,
StaticProvider,
} from "@multica/core/feature-flags";
const service = new FeatureFlagService(
new StaticProvider({
billing_v2_dashboard: { default: false, allow: ["user-internal"] },
checkout_algo: { default: true, variant: "experiment-v2",
percent: { percent: 25 } },
}),
);
export function Providers({ children }: { children: ReactNode }) {
const userId = useCurrentUserId();
return (
<FeatureFlagsProvider service={service} context={{ userId }}>
{children}
</FeatureFlagsProvider>
);
}
```
When the backend pushes a fresh rule set (via an API response or WebSocket), call `service.setProvider(new StaticProvider(remoteRules))` and the whole tree re-evaluates.
### Toggle point in a component
```tsx
import { useFlag, useVariant } from "@multica/core/feature-flags";
function BillingPage() {
const showV2 = useFlag("billing_v2_dashboard", false);
return showV2 ? <BillingV2 /> : <BillingV1 />;
}
function Checkout() {
const variant = useVariant("checkout_algo", "control");
switch (variant) {
case "experiment-v2": return <CheckoutV2 />;
case "experiment-v3": return <CheckoutV3 />;
default: return <CheckoutControl />;
}
}
```
Outside a `FeatureFlagsProvider` (Storybook, unit tests, error pages) `useFlag` / `useVariant` return the supplied default. You never have to mount the provider just to render a component in isolation.
### Security note: never rely on the frontend alone
A frontend feature flag controls what the user *sees*. It does NOT enforce access. Any API route exposing the same capability MUST evaluate the matching backend flag independently. The two flags can share a key but they live in two `Service` instances and the backend value is the source of truth.
---
## Best-practice checklist
Adopted from Martin Fowler, ConfigCat and Octopus.
- **Naming**: `{team}_{area}_{behavior}`, e.g. `billing_checkout_new_payment_flow`. No `enable_` / `disable_` prefixes (redundant).
- **One flag, one purpose**: never repurpose an old flag for a new feature. Add a new flag and delete the old one.
- **Plan the death of the flag at birth**: open a follow-up issue to remove the flag when the rollout completes. Release flags should live days, not quarters.
- **Convention**: `Off` is the legacy / safe state, `On` is the new behavior. Lets CI test "all-off (today)" and "all-on (tomorrow)".
- **Kill switch fast path**: ops-critical flags should be exposed via `EnvProvider` so SREs can flip them without a deploy.
- **Backend protection**: anything controlling access goes through the backend Service; the frontend flag is presentation only.
- **No secrets in flags**: variant values are not Secrets Manager / KMS. Use those for tokens, keys, and passwords.
See `docs/design.md` and `docs/timezone-architecture-rfc.md` for prior examples of how this pattern is used across the codebase.

View File

@@ -82,7 +82,7 @@ Multica 做的事:
Multica **不自己训模型**,也不锁定某一家厂商。它是调度器,本地 daemon 会自动探测以下 CLI 工具并接入:
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent · Kimi · Kiro CLI
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent · Kimi · Kiro CLI · Qoder CLI
每个 agent 可以配置自己的模型、API Key、环境变量、MCP 服务器。
@@ -244,7 +244,7 @@ Project 相比 Issue 是更高层的组织单元。一个 issue 可以不属于
#### 配置字段
- **基本信息**:名字、描述、头像(自动生成)
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor / Kimi / Kiro 中的哪一个
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor / Kimi / Kiro / Qoder 中的哪一个
- **Runtime**:绑定到哪个运行时(即在哪台机器上跑)
- **Instructions 说明书**agent 的系统提示词("你是一个资深工程师..."
- **Custom Env**:要注入到 CLI 进程的环境变量(如 `ANTHROPIC_API_KEY``ANTHROPIC_BASE_URL``CLAUDE_CODE_USE_BEDROCK`
@@ -291,7 +291,7 @@ Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent
`multica` CLI 在用户的机器上启动一个后台进程macOS launchd / Linux systemd / Windows 服务风格),它:
1. **自动探测** `$PATH` 上安装的 coding CLI`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`
1. **自动探测** `$PATH` 上安装的 coding CLI`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `qodercli`
2. 向 server **注册** 为一组 runtime一个 CLI = 一个 runtime
3. 每 3 秒 **轮询** 一次 server有任务就认领
4. 每 15 秒 **心跳**keepalive报告自己还活着

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

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { isBenignException } from "./benign-exceptions";
describe("isBenignException", () => {
it("drops ResizeObserver loop errors via $exception_list value", () => {
expect(
isBenignException({
$exception_list: [
{
type: "Error",
value: "ResizeObserver loop completed with undelivered notifications.",
},
],
}),
).toBe(true);
});
it("drops the older 'loop limit exceeded' phrasing", () => {
expect(
isBenignException({
$exception_list: [
{ type: "Error", value: "ResizeObserver loop limit exceeded" },
],
}),
).toBe(true);
});
it("drops when the signal is on the top-level $exception_message", () => {
expect(
isBenignException({
$exception_message: "ResizeObserver loop limit exceeded",
}),
).toBe(true);
});
it("matches case-insensitively", () => {
expect(
isBenignException({ $exception_message: "resizeobserver LOOP limit exceeded" }),
).toBe(true);
});
it("keeps real errors", () => {
expect(
isBenignException({
$exception_list: [
{
type: "TypeError",
value: "Cannot read properties of undefined (reading 'split')",
},
],
}),
).toBe(false);
});
it("does not match an unrelated mention of ResizeObserver", () => {
// Only the benign "loop" phrasing is silenced; a genuine bug in
// ResizeObserver usage must still be reported.
expect(
isBenignException({
$exception_message: "ResizeObserver is not defined",
}),
).toBe(false);
});
it("fails open on missing or malformed properties", () => {
expect(isBenignException(undefined)).toBe(false);
expect(isBenignException({})).toBe(false);
expect(isBenignException({ $exception_list: "not-an-array" })).toBe(false);
expect(isBenignException({ $exception_list: [null, 42, {}] })).toBe(false);
expect(isBenignException({ $exception_message: 123 })).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
// Known-benign browser exceptions that are pure noise in `$exception`
// telemetry. These are dropped ENTIRELY in `before_send` (not merely deduped by
// exception-dedupe.ts) — they carry no actionable signal, the browser
// self-recovers, and at scale they dominate the error stream, drowning real
// failures and burning the billed event budget.
//
// ResizeObserver "loop ..." errors are the canonical case: the spec fires them
// when observation callbacks don't settle within a single animation frame. The
// browser resumes delivery on the next frame, so nothing actually breaks. Every
// app that uses ResizeObserver (directly or via a UI library) emits them. The
// CSSWG explicitly considers them benign — see w3c/csswg-drafts#5023. Across
// Chrome versions the message is either "ResizeObserver loop limit exceeded"
// (older) or "ResizeObserver loop completed with undelivered notifications"
// (newer); both contain "ResizeObserver loop".
//
// The bar for adding a pattern here is high: it must be a benign,
// self-recovering error with no actionable signal. A real bug must never be
// silenced — when unsure, leave it to the dedupe fuse, which only caps repeats.
const BENIGN_MESSAGE_PATTERNS: RegExp[] = [/ResizeObserver loop/i];
/**
* Whether this `$exception` event is known-benign browser noise that should be
* dropped entirely. Reads the message from the (pre-redaction) event
* properties — the matched messages carry no PII, so reading them raw is safe,
* and matching before redaction avoids any chance of a scrub mangling the
* signal. Never throws: any unexpected shape returns `false` (keep the event),
* the fail-open direction `before_send` requires.
*/
export function isBenignException(
properties: Record<string, unknown> | undefined,
): boolean {
if (!properties || typeof properties !== "object") return false;
const messages: unknown[] = [properties.$exception_message];
const list = properties.$exception_list;
if (Array.isArray(list)) {
for (const entry of list) {
if (entry && typeof entry === "object" && "value" in entry) {
messages.push((entry as { value: unknown }).value);
}
}
}
for (const message of messages) {
if (typeof message !== "string") continue;
for (const pattern of BENIGN_MESSAGE_PATTERNS) {
if (pattern.test(message)) return true;
}
}
return false;
}

View File

@@ -15,6 +15,7 @@
import posthog from "posthog-js";
import { redactExceptionProperties } from "./redact-exception";
import { shouldDropException } from "./exception-dedupe";
import { isBenignException } from "./benign-exceptions";
export const EVENT_SCHEMA_VERSION = 2;
@@ -166,6 +167,11 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
capture_exceptions: true,
before_send: (event) => {
if (event && event.event === "$exception") {
// Drop known-benign browser noise (e.g. ResizeObserver loop) entirely
// — checked on the raw message before redaction. These dominate the
// stream and carry no signal, so they skip both redaction and the
// dedupe fuse. See benign-exceptions.ts.
if (isBenignException(event.properties)) return null;
redactExceptionProperties(event.properties);
if (shouldDropException(event.properties)) return null;
}

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