Compare commits

..

71 Commits

Author SHA1 Message Date
Eve
21cd617d16 feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721)
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: multica-agent <github@multica.ai>
2026-06-29 17:12:18 +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
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
377 changed files with 31121 additions and 8855 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.

View File

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

View File

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

View File

@@ -37,6 +37,27 @@ define REQUIRE_ENV
fi
endef
# Self-hosting requires Docker Compose v2 (the `docker compose` CLI plugin).
# The self-host compose files use compose-spec syntax (top-level `name:`, no
# `version:`) that the legacy v1 `docker-compose` standalone cannot parse, so we
# fail early with an actionable message instead of a cryptic CLI parse error
# (e.g. "unknown shorthand flag: 'f' in -f") when the plugin is missing or v1.
# Keep the message short and OS-agnostic: per-OS install steps belong in docs.
define REQUIRE_COMPOSE
@if ! $(COMPOSE) version >/dev/null 2>&1; then \
echo "Docker Compose v2 ('docker compose') was not found."; \
echo "Self-hosting requires the Compose v2 CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
fi; \
if ! $(COMPOSE) version --short 2>/dev/null | grep -Eq '^v?2\.'; then \
echo "'$(COMPOSE)' is not Docker Compose v2."; \
echo "Self-hosting requires the Compose v2 CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
fi
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 +75,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 +93,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 +101,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 +127,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 +149,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 +175,13 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
fi
selfhost-stop: ## Stop the self-hosted Docker Compose stack
$(REQUIRE_COMPOSE)
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
$(COMPOSE) -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
import { DocsHero } from "@/components/hero";
import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/components/editorial";
import { VideoEmbed } from "@/components/video-embed";
import { i18n, type Lang } from "@/lib/i18n";
import { homeCopy } from "@/lib/translations";
import { docsAlternates } from "@/lib/site";
@@ -62,6 +63,7 @@ export default async function Page({
NumberedCard,
NumberedSteps,
Step,
VideoEmbed,
}}
/>
</DocsLocaleProvider>

View File

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

View File

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

@@ -7,6 +7,8 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在同一个 [工作区](/workspaces) 里共同工作。你可以像给同事派活一样,[把一个任务分配给智能体](/assigning-issues) ——由它去执行、汇报进展、在评论里回复你;也可以[打开聊天窗口直接和它对话](/chat),让它帮你起草任务、回答问题、或完成一次性请求。
<VideoEmbed provider="bilibili" id="BV1cv7Y6gEg7" title="Multica 中文介绍视频" />
这一页讲清楚智能体在哪里运行,以及你有哪几种方式开始使用 Multica。
## 智能体在哪里运行

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

@@ -19,6 +19,7 @@
"squads",
"---智能体怎么运行---",
"daemon-runtimes",
"install-agent-runtime",
"tasks",
"providers",
"---与智能体协作---",

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,12 @@ 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` のような衝突しない兄弟ディレクトリへワークスペースコピーを書き込みます。
@@ -127,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,12 @@ 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` 같은 충돌 없는 형제 디렉터리에 워크스페이스 사본을 씁니다.
@@ -127,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,12 @@ 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`.
@@ -127,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,12 @@ 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 目录。

View File

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

View File

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

View File

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

View File

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

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

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

@@ -28,6 +28,7 @@ import type {
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
InboxItem,
InboxWorkspaceUnread,
IssueSubscriber,
Comment,
CommentTriggerPreview,
@@ -111,6 +112,9 @@ import type {
BeginLarkInstallResponse,
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
ComposioToolkit,
ComposioConnection,
ComposioConnectInitResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
@@ -159,6 +163,8 @@ import {
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_GROUPED_ISSUES_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_SEARCH_ISSUES_RESPONSE,
EMPTY_SEARCH_PROJECTS_RESPONSE,
EMPTY_SQUAD,
EMPTY_SQUAD_LIST,
EMPTY_SQUAD_MEMBER_STATUS_LIST,
@@ -177,6 +183,8 @@ import {
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
SearchIssuesResponseSchema,
SearchProjectsResponseSchema,
SquadSchema,
SquadListSchema,
SquadMemberStatusListResponseSchema,
@@ -201,6 +209,8 @@ import {
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
EMPTY_CANCEL_TASK_RESPONSE,
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -551,7 +561,13 @@ export class ApiClient {
if (params.limit !== undefined) search.set("limit", String(params.limit));
if (params.offset !== undefined) search.set("offset", String(params.offset));
if (params.include_closed) search.set("include_closed", "true");
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
const raw = await this.fetch<unknown>(
`/api/issues/search?${search}`,
params.signal ? { signal: params.signal } : undefined,
);
return parseWithFallback(raw, SearchIssuesResponseSchema, EMPTY_SEARCH_ISSUES_RESPONSE, {
endpoint: "GET /api/issues/search",
});
}
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
@@ -559,7 +575,13 @@ export class ApiClient {
if (params.limit !== undefined) search.set("limit", String(params.limit));
if (params.offset !== undefined) search.set("offset", String(params.offset));
if (params.include_closed) search.set("include_closed", "true");
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
const raw = await this.fetch<unknown>(
`/api/projects/search?${search}`,
params.signal ? { signal: params.signal } : undefined,
);
return parseWithFallback(raw, SearchProjectsResponseSchema, EMPTY_SEARCH_PROJECTS_RESPONSE, {
endpoint: "GET /api/projects/search",
});
}
async getIssue(id: string): Promise<Issue> {
@@ -1459,6 +1481,17 @@ export class ApiClient {
return this.fetch("/api/inbox/unread-count");
}
// Cross-workspace unread summary: one entry per workspace the user belongs
// to that has unread inbox items. Backs the workspace-switcher dot for
// OTHER workspaces. Schema-guarded so a contract drift hides the dot rather
// than crashing the sidebar.
async getInboxUnreadSummary(): Promise<InboxWorkspaceUnread[]> {
const raw = await this.fetch<unknown>("/api/inbox/unread-summary");
return parseWithFallback(raw, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, {
endpoint: "GET /api/inbox/unread-summary",
});
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
}
@@ -2240,4 +2273,34 @@ export class ApiClient {
body: JSON.stringify({ token }),
});
}
// Composio integration (MUL-3720). All routes are user-scoped (a connection
// belongs to a user, not a workspace), so none take a workspaceId.
/** Full Composio toolkit catalog, each annotated with `connectable`
* (whether the project has an enabled auth config for it). */
async listComposioToolkits(): Promise<ComposioToolkit[]> {
return this.fetch(`/api/integrations/composio/toolkits`);
}
/** The caller's active Composio connections. */
async listComposioConnections(): Promise<ComposioConnection[]> {
return this.fetch(`/api/integrations/composio/connections`);
}
/** Starts a hosted Composio connect flow for a toolkit and returns the
* redirect URL the browser should be sent to. */
async beginComposioConnect(toolkitSlug: string): Promise<ComposioConnectInitResponse> {
return this.fetch(`/api/integrations/composio/connect/init`, {
method: "POST",
body: JSON.stringify({ toolkit_slug: toolkitSlug }),
});
}
/** Disconnects a Composio connection the caller owns. */
async deleteComposioConnection(connectionId: string): Promise<void> {
await this.fetch(`/api/integrations/composio/connections/${connectionId}`, {
method: "DELETE",
});
}
}

View File

@@ -91,6 +91,24 @@ describe("ApiClient schema fallback", () => {
});
});
describe("searchIssues", () => {
it("falls back to an empty result when the response is malformed", async () => {
stubFetchJson({ issues: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.searchIssues({ q: "bug" });
expect(res).toEqual({ issues: [], total: 0 });
});
});
describe("searchProjects", () => {
it("falls back to an empty result when the response is malformed", async () => {
stubFetchJson({ projects: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.searchProjects({ q: "roadmap" });
expect(res).toEqual({ projects: [], total: 0 });
});
});
describe("listAutopilots", () => {
const baseAutopilot = {
id: "ap-1",

View File

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

View File

@@ -15,8 +15,11 @@ import type {
CreateBillingCheckoutSessionResponse,
CreateBillingPortalSessionResponse,
GroupedIssuesResponse,
InboxWorkspaceUnread,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
Squad,
TimelineEntry,
User,
@@ -275,6 +278,55 @@ export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
total: 0,
};
const SearchIssueResultSchema = IssueSchema.extend({
match_source: z.string(),
matched_snippet: z.string().optional(),
matched_description_snippet: z.string().optional(),
matched_comment_snippet: z.string().optional(),
}).loose();
export const SearchIssuesResponseSchema = z.object({
issues: z.array(SearchIssueResultSchema).default([]),
total: z.number().default(0),
}).loose();
export const EMPTY_SEARCH_ISSUES_RESPONSE: SearchIssuesResponse = {
issues: [],
total: 0,
};
const ProjectSchema = z.object({
id: z.string(),
workspace_id: z.string(),
title: z.string(),
description: z.string().nullable(),
icon: z.string().nullable(),
status: z.string(),
priority: z.string(),
lead_type: z.string().nullable(),
lead_id: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
issue_count: z.number().default(0),
done_count: z.number().default(0),
resource_count: z.number().default(0),
}).loose();
const SearchProjectResultSchema = ProjectSchema.extend({
match_source: z.string(),
matched_snippet: z.string().optional(),
}).loose();
export const SearchProjectsResponseSchema = z.object({
projects: z.array(SearchProjectResultSchema).default([]),
total: z.number().default(0),
}).loose();
export const EMPTY_SEARCH_PROJECTS_RESPONSE: SearchProjectsResponse = {
projects: [],
total: 0,
};
const IssueAssigneeGroupSchema = z.object({
id: z.string(),
assignee_type: z.string().nullable(),
@@ -863,6 +915,25 @@ export const EMPTY_USER: User = {
updated_at: "",
};
// ---------------------------------------------------------------------------
// Cross-workspace unread inbox summary (`/api/inbox/unread-summary` GET).
// One entry per workspace the user belongs to that has unread items; the
// sidebar derives the workspace-switcher dot from it. Lenient per the usual
// rules so a future field addition can't blank the dot — on malformed JSON
// parseWithFallback returns the empty list, which simply hides the dot.
// ---------------------------------------------------------------------------
export const InboxUnreadSummarySchema = z.array(
z
.object({
workspace_id: z.string(),
count: z.number(),
})
.loose(),
);
export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = [];
// ---------------------------------------------------------------------------
// Billing schemas (cloud-billing proxy surface)
//

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
import type { Decision, EvalContext, Provider } from "./types";
/**
* ChainProvider composes multiple providers and returns the first match.
*
* Order from most-specific to most-generic: per-request override, server
* push, static config. The first provider that returns a Decision wins, so
* the chain naturally implements the "ops override beats static config"
* pattern callers expect.
*
* A ChainProvider that wraps zero providers is valid and always returns
* undefined, so the Service falls back to the caller's default.
*/
export class ChainProvider implements Provider {
readonly name = "chain";
private readonly providers: ReadonlyArray<Provider>;
constructor(providers: ReadonlyArray<Provider | null | undefined>) {
// Filter nullish entries so callers can pass optional providers
// directly: `new ChainProvider([envOverride, baseStatic])`.
this.providers = providers.filter((p): p is Provider => p != null);
}
lookup(key: string, ctx: EvalContext): Decision | undefined {
for (const p of this.providers) {
const d = p.lookup(key, ctx);
if (d !== undefined) return d;
}
return undefined;
}
}

View File

@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { FeatureFlagsProvider, useFlag, useVariant } from "./context";
import { FeatureFlagService } from "./service";
import { StaticProvider } from "./static-provider";
function FlagBadge({ flagKey, defaultValue }: { flagKey: string; defaultValue: boolean }) {
const enabled = useFlag(flagKey, defaultValue);
return <span data-testid="flag">{enabled ? "ON" : "OFF"}</span>;
}
function VariantBadge({ flagKey, defaultValue }: { flagKey: string; defaultValue: string }) {
const variant = useVariant(flagKey, defaultValue);
return <span data-testid="variant">{variant}</span>;
}
describe("FeatureFlagsProvider + hooks", () => {
it("useFlag returns provider value inside the tree", () => {
const service = new FeatureFlagService(
new StaticProvider({ demo: { default: true } }),
);
render(
<FeatureFlagsProvider service={service}>
<FlagBadge flagKey="demo" defaultValue={false} />
</FeatureFlagsProvider>,
);
expect(screen.getByTestId("flag").textContent).toBe("ON");
});
it("useFlag falls back to default outside any provider (tests / stories)", () => {
render(<FlagBadge flagKey="anything" defaultValue={true} />);
expect(screen.getByTestId("flag").textContent).toBe("ON");
});
it("useFlag respects the EvalContext attached to the provider", () => {
const service = new FeatureFlagService(
new StaticProvider({
internal: { default: false, allow: ["user-internal"] },
}),
);
render(
<FeatureFlagsProvider service={service} context={{ userId: "user-internal" }}>
<FlagBadge flagKey="internal" defaultValue={false} />
</FeatureFlagsProvider>,
);
expect(screen.getByTestId("flag").textContent).toBe("ON");
});
it("useVariant returns the variant identifier", () => {
const service = new FeatureFlagService(
new StaticProvider({
algo: { default: true, variant: "experiment-v2" },
}),
);
render(
<FeatureFlagsProvider service={service}>
<VariantBadge flagKey="algo" defaultValue="control" />
</FeatureFlagsProvider>,
);
expect(screen.getByTestId("variant").textContent).toBe("experiment-v2");
});
it("useVariant falls back to default outside any provider", () => {
render(<VariantBadge flagKey="algo" defaultValue="control" />);
expect(screen.getByTestId("variant").textContent).toBe("control");
});
});

View File

@@ -0,0 +1,108 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { EvalContext } from "./types";
import { FeatureFlagService } from "./service";
/**
* React glue for the FeatureFlagService.
*
* Two pieces are exported:
*
* - {@link FeatureFlagsProvider}: wraps a part of the tree with a Service
* and an EvalContext. The Service is usually constructed once at the
* application root; the EvalContext changes as the user context changes
* (e.g. after login).
* - {@link useFlag} / {@link useVariant}: the recommended Toggle Points in
* UI code. They never throw; if the provider tree is missing they fall
* back to the supplied default, which keeps Storybook stories and unit
* tests from needing to mount the provider just to render a button.
*
* Note: we deliberately do NOT expose the underlying FeatureFlagService
* through hooks. Components that need raw access can read it via the
* exported context object, but at the cost of giving up the always-on
* safety guarantee.
*/
interface FeatureFlagContextValue {
service: FeatureFlagService;
ctx: EvalContext;
}
const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
export interface FeatureFlagsProviderProps {
service: FeatureFlagService;
/**
* Targeting context for every flag evaluation inside this subtree.
* Pass an empty object when the user is anonymous — percent rollouts
* and allow/deny lists then evaluate against the empty identifier,
* which is the desired behavior for anonymous traffic.
*/
context?: EvalContext;
children: ReactNode;
}
/**
* Mount a FeatureFlagService and EvalContext into the tree. Replacing the
* `service` prop on a re-render is allowed but rare; prefer mutating the
* provider on the existing Service via `setProvider`, which avoids forcing
* every consumer to re-evaluate.
*/
export function FeatureFlagsProvider({
service,
context: ctx = {},
children,
}: FeatureFlagsProviderProps) {
const value = useMemo<FeatureFlagContextValue>(
() => ({ service, ctx }),
[service, ctx],
);
return (
<FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
);
}
/**
* useFlag returns the boolean state of a feature flag.
*
* Outside a {@link FeatureFlagsProvider} the hook returns `defaultValue`,
* never throws. This keeps tests and stories independent of the provider.
*
* @example
* const showNewBilling = useFlag("billing_v2_dashboard", false);
* return showNewBilling ? <BillingV2 /> : <BillingV1 />;
*/
export function useFlag(key: string, defaultValue: boolean): boolean {
const value = useContext(FeatureFlagContext);
if (!value) return defaultValue;
return value.service.isEnabled(key, value.ctx, defaultValue);
}
/**
* useVariant returns the raw variant identifier for a multi-arm flag, with
* the same out-of-provider safety as {@link useFlag}.
*
* @example
* const variant = useVariant("checkout_algo", "control");
* switch (variant) {
* case "experiment-v2": return <CheckoutV2 />;
* case "experiment-v3": return <CheckoutV3 />;
* default: return <CheckoutControl />;
* }
*/
export function useVariant(key: string, defaultValue: string): string {
const value = useContext(FeatureFlagContext);
if (!value) return defaultValue;
return value.service.variant(key, value.ctx, defaultValue);
}
/**
* Escape hatch for diagnostic overlays that need direct Service access.
* Returns `null` outside a provider so callers must guard explicitly —
* this is intentional: random component code should use {@link useFlag},
* not the raw Service.
*/
export function useFeatureFlagService(): FeatureFlagService | null {
return useContext(FeatureFlagContext)?.service ?? null;
}

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { bucketFor, inPercent } from "./hash";
describe("feature-flags hash", () => {
it("bucketFor returns a value in [0, 100)", () => {
for (const id of ["a", "b", "user-1", "user-2", "", "🦄"]) {
const b = bucketFor("flag", id);
expect(b).toBeGreaterThanOrEqual(0);
expect(b).toBeLessThan(100);
}
});
it("bucketFor is deterministic for the same (key, id)", () => {
const first = bucketFor("billing_new_invoice", "user-42");
for (let i = 0; i < 100; i++) {
expect(bucketFor("billing_new_invoice", "user-42")).toBe(first);
}
});
it("separator prevents key/id boundary collisions", () => {
// ("ab","c") and ("a","bc") must not hash to the same bucket.
expect(bucketFor("ab", "c")).not.toBe(bucketFor("a", "bc"));
});
// Pinned (key, identifier) -> bucket values that MUST agree with the
// Go-side server/pkg/featureflag/hash_test.go::TestPercentBucketCrossLanguageGolden.
// The shared golden table is the single source of truth for "same user,
// same bucket" across backend and frontend; if either side drifts, both
// tests fail and one must be brought back in sync.
//
// The non-ASCII cases (CJK, accented, emoji) exist on purpose: Go hashes
// the UTF-8 byte representation of a string. The TS side must do the
// same. A regression that swaps UTF-8 encoding for charCodeAt would
// only be caught by these inputs.
it("cross-language golden: bucket values match the Go side exactly", () => {
const cases: ReadonlyArray<[string, string, number]> = [
// ASCII baseline.
["billing_new_invoice", "user-42", 97],
["feature_a", "user-1", 50],
["checkout_algo", "u-7f8a", 11],
["ws_rollout", "workspace-1", 62],
["empty_id_flag", "", 83],
// Non-ASCII: enforces UTF-8 parity (TextEncoder on the TS side).
["flag", "é", 53],
["flag", "🦄", 82],
["实验", "user-1", 90],
["flag", "用户-1", 95],
["checkout_算法", "user-100", 79],
];
for (const [key, id, want] of cases) {
expect(bucketFor(key, id)).toBe(want);
}
});
it("inPercent clamps boundary values", () => {
expect(inPercent("any", "any", 0)).toBe(false);
expect(inPercent("any", "any", -10)).toBe(false);
expect(inPercent("any", "any", 100)).toBe(true);
expect(inPercent("any", "any", 999)).toBe(true);
});
it("inPercent splits a 50% rollout roughly in half across 1000 users", () => {
// 50% over 1000 distinct users should land near 500; we allow a
// generous +/- 100 window so the test isn't flaky.
let enabled = 0;
for (let i = 0; i < 1000; i++) {
if (inPercent("split", `user-${i.toString(36)}`, 50)) enabled++;
}
expect(enabled).toBeGreaterThan(400);
expect(enabled).toBeLessThan(600);
});
});

View File

@@ -0,0 +1,76 @@
/**
* FNV-1a 32-bit hash used for deterministic percent-rollout bucketing.
*
* The same (key, identifier) pair MUST always produce the same bucket;
* otherwise users would flip in and out of experiments across requests. The
* algorithm matches the Go-side server/pkg/featureflag/hash.go byte-for-byte
* so a flag evaluated on the frontend and on the backend lands in the same
* bucket for the same user. Cross-language equality is exercised by golden
* tests on both sides; see hash.test.ts and hash_test.go.
*
* The hash operates on the UTF-8 encoding of each input. Go's `[]byte(s)`
* conversion is also UTF-8, so the two implementations agree even when
* flag keys or identifiers contain non-ASCII characters (Chinese flag
* names, user IDs that include accented characters, emoji, ...). Using
* `charCodeAt` directly would have hashed UTF-16 code units instead and
* silently diverged from Go for any non-ASCII input.
*
* FNV-1a is used because it is cheap, dependency-free, and well-distributed
* enough for sub-100 bucketing. It is NOT cryptographic; do not use it for
* anything beyond bucketing.
*/
// One shared TextEncoder per module. TextEncoder is part of the WHATWG
// Encoding spec and ships in every evergreen browser, in Node 11+, and in
// React Native (Hermes) >= 0.74. We deliberately do not lazy-init it so
// failures show up at import time, not the first time a flag is read.
const utf8 = new TextEncoder();
function fnv1a(parts: ReadonlyArray<string>): number {
// 32-bit FNV-1a: offset basis 0x811c9dc5, prime 0x01000193.
let hash = 0x811c9dc5;
for (let p = 0; p < parts.length; p++) {
if (p > 0) {
// Zero-byte separator BETWEEN parts (not after the last one). This
// matches what the Go side writes via h.Write([]byte{0}) between
// key and identifier and is what prevents ("ab", "c") and
// ("a", "bc") from colliding. A trailing separator would diverge
// from Go and silently break cross-tier bucket parity.
hash ^= 0;
hash = Math.imul(hash, 0x01000193);
}
// Encode the part as UTF-8 to match Go's `[]byte(string)`. Using
// charCodeAt would hash UTF-16 code units instead and diverge from
// Go for any non-ASCII input (Chinese keys, accented user IDs,
// emoji, ...). See the package doc above.
const bytes = utf8.encode(parts[p]!);
for (let i = 0; i < bytes.length; i++) {
hash ^= bytes[i]!;
// Multiply by FNV prime mod 2^32. Math.imul keeps the result in a
// 32-bit integer without slipping into float territory.
hash = Math.imul(hash, 0x01000193);
}
}
// Force unsigned 32-bit before the modulo to match Go's uint32 arithmetic.
return hash >>> 0;
}
/**
* bucketFor returns a deterministic bucket in [0, 100) for the supplied
* (key, identifier) pair. Identical to the Go bucketFor in
* server/pkg/featureflag/hash.go.
*/
export function bucketFor(key: string, identifier: string): number {
return fnv1a([key, identifier]) % 100;
}
/**
* inPercent reports whether (key, identifier) falls within the first
* `percent` buckets. Values outside [0, 100] are clamped: <=0 disables for
* everyone, >=100 enables for everyone.
*/
export function inPercent(key: string, identifier: string, percent: number): boolean {
if (percent <= 0) return false;
if (percent >= 100) return true;
return bucketFor(key, identifier) < percent;
}

View File

@@ -0,0 +1,30 @@
/**
* Public surface for @multica/core/feature-flags.
*
* Keep this list minimal — every new export becomes a contract we have to
* preserve across the monorepo. Add to it only when a real caller appears.
*/
export type {
Decision,
EvalContext,
PercentRollout,
Provider,
Reason,
Rule,
} from "./types";
export { FeatureFlagService } from "./service";
export { StaticProvider } from "./static-provider";
export { ChainProvider } from "./chain-provider";
export {
FeatureFlagsProvider,
useFeatureFlagService,
useFlag,
useVariant,
} from "./context";
// Hash helpers are exported for tests and for callers that want to share
// the bucketing logic without going through a Provider (rare; usually a
// red flag that the caller should be using the Service instead).
export { bucketFor, inPercent } from "./hash";

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { ChainProvider } from "./chain-provider";
import { StaticProvider } from "./static-provider";
import { FeatureFlagService } from "./service";
describe("FeatureFlagService", () => {
it("returns the default when no provider is configured", () => {
const s = new FeatureFlagService(null);
expect(s.isEnabled("any", {}, true)).toBe(true);
expect(s.isEnabled("any", {}, false)).toBe(false);
expect(s.variant("any", {}, "control")).toBe("control");
expect(s.decision("any", {}, false).reason).toBe("default");
});
it("returns the default when the provider does not know the key", () => {
const s = new FeatureFlagService(new StaticProvider({}));
expect(s.isEnabled("missing", {}, true)).toBe(true);
expect(s.decision("missing", {}, true).reason).toBe("default");
});
it("uses the provider decision when found", () => {
const sp = new StaticProvider({ billing: { default: true } });
const s = new FeatureFlagService(sp);
const d = s.decision("billing", {}, false);
expect(d.enabled).toBe(true);
expect(d.reason).toBe("static");
expect(d.source).toBe("static");
});
it("echoes the requested key in the decision", () => {
const sp = new StaticProvider({ a: { default: true } });
const s = new FeatureFlagService(sp);
expect(s.decision("a", {}, false).key).toBe("a");
});
it("setProvider swaps the underlying provider", () => {
const s = new FeatureFlagService(null);
expect(s.isEnabled("k", {}, false)).toBe(false);
s.setProvider(new StaticProvider({ k: { default: true } }));
expect(s.isEnabled("k", {}, false)).toBe(true);
});
});
describe("ChainProvider", () => {
it("first match wins", () => {
const top = new StaticProvider({ shared: { default: true } });
const bottom = new StaticProvider({ shared: { default: false } });
const chain = new ChainProvider([top, bottom]);
expect(chain.lookup("shared", {})?.enabled).toBe(true);
});
it("falls through to the next provider", () => {
const top = new StaticProvider({});
const bottom = new StaticProvider({ only_in_bottom: { default: true } });
const chain = new ChainProvider([top, bottom]);
expect(chain.lookup("only_in_bottom", {})?.enabled).toBe(true);
});
it("returns undefined when no provider matches", () => {
const chain = new ChainProvider([new StaticProvider({})]);
expect(chain.lookup("nope", {})).toBeUndefined();
});
it("skips null and undefined entries", () => {
const sp = new StaticProvider({ real: { default: true } });
const chain = new ChainProvider([null, sp, undefined]);
expect(chain.lookup("real", {})?.enabled).toBe(true);
});
});

View File

@@ -0,0 +1,84 @@
import type { Decision, EvalContext, Provider } from "./types";
/**
* FeatureFlagService is the framework-level Toggle Router. UI code asks the
* Service for decisions; the Service consults its configured {@link Provider}.
*
* The class is intentionally side-effect free. Mounting it inside a React
* tree is handled by `./context.tsx`; the Service itself works outside of
* React (unit tests, web workers, Node CLI tools, ...).
*
* Always-on safety: every public entry point returns the caller's default
* when no provider matches. Business code never has to guard against a
* missing flag.
*/
export class FeatureFlagService {
private provider: Provider | null;
constructor(provider: Provider | null = null) {
this.provider = provider;
}
/**
* Swap the underlying provider at runtime. Useful when fresh config
* arrives from the backend; the React provider tree re-renders
* automatically because the consumer hooks subscribe to the wrapper.
*/
setProvider(provider: Provider | null): void {
this.provider = provider;
}
/**
* Returns true when the named flag evaluates to an "on" state. When the
* flag is unknown the caller's default is returned.
*
* @example
* if (flags.isEnabled("billing_new_invoice_email", { userId }, false)) {
* return <NewInvoiceEmail />;
* }
* return <LegacyInvoiceEmail />;
*/
isEnabled(key: string, ctx: EvalContext, defaultValue: boolean): boolean {
return this.decision(key, ctx, defaultValue).enabled;
}
/**
* Returns the raw variant for a multi-arm flag, falling back to
* `defaultValue` when nothing matches.
*/
variant(key: string, ctx: EvalContext, defaultValue: string): string {
if (!this.provider) {
return defaultValue;
}
const d = this.provider.lookup(key, ctx);
if (!d) return defaultValue;
return d.variant;
}
/**
* Full structured decision. Used by diagnostic overlays and tests.
*/
decision(key: string, ctx: EvalContext, defaultValue: boolean): Decision {
if (!this.provider) {
return defaultDecision(key, defaultValue);
}
const d = this.provider.lookup(key, ctx);
if (!d) return defaultDecision(key, defaultValue);
return { ...d, key };
}
/** Returns the wrapped provider (read-only) for diagnostics. */
getProvider(): Provider | null {
return this.provider;
}
}
function defaultDecision(key: string, value: boolean): Decision {
return {
key,
enabled: value,
variant: value ? "on" : "off",
reason: "default",
source: "default",
};
}

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { StaticProvider } from "./static-provider";
describe("StaticProvider", () => {
it("returns undefined for unknown keys so callers fall through", () => {
const sp = new StaticProvider();
expect(sp.lookup("missing", {})).toBeUndefined();
});
it("returns the rule default for known keys", () => {
const sp = new StaticProvider({ on: { default: true }, off: { default: false } });
expect(sp.lookup("on", {})?.enabled).toBe(true);
expect(sp.lookup("off", {})?.enabled).toBe(false);
});
it("allow forces ON for matching users", () => {
const sp = new StaticProvider({
internal_dashboard: { default: false, allow: ["user-internal"] },
});
expect(sp.lookup("internal_dashboard", { userId: "user-internal" })?.enabled).toBe(true);
expect(sp.lookup("internal_dashboard", { userId: "user-random" })?.enabled).toBe(false);
});
it("deny wins over allow for the same user", () => {
const sp = new StaticProvider({
conflict: { default: true, allow: ["same"], deny: ["same"] },
});
expect(sp.lookup("conflict", { userId: "same" })?.enabled).toBe(false);
});
it("percent rollout is deterministic for a fixed user", () => {
const sp = new StaticProvider({ split: { percent: { percent: 50 } } });
const first = sp.lookup("split", { userId: "stable" })?.enabled;
for (let i = 0; i < 100; i++) {
expect(sp.lookup("split", { userId: "stable" })?.enabled).toBe(first);
}
});
it("percent rollout with by=workspace_id buckets by workspace", () => {
const sp = new StaticProvider({
ws_rollout: { percent: { percent: 100, by: "workspace_id" } },
});
const decision = sp.lookup("ws_rollout", { workspaceId: "w-1" });
expect(decision?.enabled).toBe(true);
expect(decision?.reason).toBe("percent");
});
it("variant overrides the boolean variant string", () => {
const sp = new StaticProvider({
checkout: { default: true, variant: "experiment-v2" },
});
const d = sp.lookup("checkout", { userId: "anyone" });
expect(d?.variant).toBe("experiment-v2");
expect(d?.enabled).toBe(true);
});
// Regression test for the MUL-3615 review: when a rule sets `variant`
// but the rule itself evaluates to enabled=false (deny match, percent
// miss, default-off), the decision MUST report variant="off", never
// the on-variant. Otherwise a switch on `useVariant()` would route
// non-rolled-in users into the experiment arm.
it("variant: returns 'off' when the rule evaluates to disabled", () => {
const sp = new StaticProvider({
exp: {
default: false,
variant: "experiment-v2",
deny: ["banned-user"],
percent: { percent: 0 },
},
});
for (const userId of ["banned-user", "random-user", ""]) {
const d = sp.lookup("exp", { userId });
expect(d?.enabled).toBe(false);
expect(d?.variant).toBe("off");
}
});
it("variant: returns the on-variant when the rule evaluates to enabled", () => {
const sp = new StaticProvider({
exp: { default: false, variant: "experiment-v2", allow: ["rolled-in"] },
});
const d = sp.lookup("exp", { userId: "rolled-in" });
expect(d?.enabled).toBe(true);
expect(d?.variant).toBe("experiment-v2");
});
it("loadRules replaces, not merges, the rule map", () => {
const sp = new StaticProvider({ old: { default: true } });
sp.loadRules({ fresh: { default: true } });
expect(sp.lookup("old", {})).toBeUndefined();
expect(sp.lookup("fresh", {})?.enabled).toBe(true);
});
it("custom attribute lookup against attributes map", () => {
const sp = new StaticProvider({
plan_gate: { default: false, allow: ["enterprise"], allowBy: "plan" },
});
expect(
sp.lookup("plan_gate", { attributes: { plan: "enterprise" } })?.enabled,
).toBe(true);
expect(sp.lookup("plan_gate", { attributes: { plan: "free" } })?.enabled).toBe(false);
});
it("keys returns a sorted snapshot", () => {
const sp = new StaticProvider({ zeta: {}, alpha: {}, mu: {} });
expect(sp.keys()).toEqual(["alpha", "mu", "zeta"]);
});
});

View File

@@ -0,0 +1,117 @@
import type { Decision, EvalContext, Provider, Rule } from "./types";
import { inPercent } from "./hash";
/**
* StaticProvider is an in-memory Provider populated either programmatically
* or from a JSON config shipped with the application bundle.
*
* This is the recommended baseline provider for the frontend: configuration
* lives in source control, moves through CD alongside the build, and
* changes require a deploy. For dynamic flags fetched from the backend,
* wrap a {@link StaticProvider} behind a chain provider that also reads
* from API state — the StaticProvider then acts as a safety net for the
* very first paint before the API response is available.
*/
export class StaticProvider implements Provider {
readonly name = "static";
private rules: Map<string, Rule>;
constructor(rules: Readonly<Record<string, Rule>> = {}) {
this.rules = new Map(Object.entries(rules));
}
/** Replace or install the rule for `key`. */
set(key: string, rule: Rule): void {
this.rules.set(key, rule);
}
/**
* Replace every rule atomically. Use when reloading flag config from a
* fetch response so consumers never observe a mixed state.
*/
loadRules(rules: Readonly<Record<string, Rule>>): void {
this.rules = new Map(Object.entries(rules));
}
/** Sorted list of known flag keys. Useful for dev overlays. */
keys(): string[] {
return Array.from(this.rules.keys()).sort();
}
lookup(key: string, ctx: EvalContext): Decision | undefined {
const rule = this.rules.get(key);
if (!rule) return undefined;
return evaluateRule(key, rule, ctx);
}
}
function evaluateRule(key: string, rule: Rule, ctx: EvalContext): Decision {
// Deny wins over everything else; a kill switch must remain reachable
// even when other targeting matches.
const denyBy = rule.denyBy ?? "user_id";
if (rule.deny && rule.deny.length > 0) {
const v = lookupAttr(ctx, denyBy);
if (v && rule.deny.includes(v)) {
return decisionFromRule(key, rule, false, "static");
}
}
const allowBy = rule.allowBy ?? "user_id";
if (rule.allow && rule.allow.length > 0) {
const v = lookupAttr(ctx, allowBy);
if (v && rule.allow.includes(v)) {
return decisionFromRule(key, rule, true, "static");
}
}
if (rule.percent) {
const by = rule.percent.by ?? "user_id";
const ident = lookupAttr(ctx, by) ?? "";
const enabled = inPercent(key, ident, rule.percent.percent);
return decisionFromRule(key, rule, enabled, "percent");
}
return decisionFromRule(key, rule, rule.default ?? false, "static");
}
function decisionFromRule(
key: string,
rule: Rule,
enabled: boolean,
reason: Decision["reason"],
): Decision {
// Variant policy: rule.variant is the ON-variant. When the rule
// evaluates to false we return the canonical "off" so a caller
// branching on the variant cannot accidentally enter the experiment
// arm for a user that did not roll in.
let variant = boolToVariant(enabled);
if (enabled && rule.variant && rule.variant.length > 0) {
variant = rule.variant;
}
return {
key,
enabled,
variant,
reason,
source: "static",
};
}
function boolToVariant(b: boolean): string {
return b ? "on" : "off";
}
/**
* Resolve an attribute name against the EvalContext. The well-known names
* "user_id" and "workspace_id" map to the dedicated fields so rules can use
* them by name without callers also populating `attributes`.
*/
function lookupAttr(ctx: EvalContext, name: string): string | undefined {
if (name === "user_id") return nonEmpty(ctx.userId);
if (name === "workspace_id") return nonEmpty(ctx.workspaceId);
return nonEmpty(ctx.attributes?.[name]);
}
function nonEmpty(v: string | undefined): string | undefined {
return v && v.length > 0 ? v : undefined;
}

View File

@@ -0,0 +1,114 @@
/**
* Public types for the @multica/core/feature-flags module.
*
* The shape mirrors the Go-side server/pkg/featureflag package on purpose so
* a Decision returned by the backend can be marshalled directly into the
* frontend Service without translation. Keep them in sync when extending
* either side.
*/
/**
* Reason explains why a Decision returned the value it did. Exposed in
* diagnostics endpoints and in development overlays so engineers can tell
* "this flag is on because the user is in the allowlist" apart from "this
* flag is on because the default kicked in".
*/
export type Reason =
| "static"
| "percent"
| "override"
| "default"
| "error";
/**
* Structured outcome of a single flag evaluation. Most callers only need
* the {@link FeatureFlagService.isEnabled} convenience, but tests and
* dev tools want the full record.
*/
export interface Decision {
/** The flag identifier that was evaluated. */
key: string;
/** Boolean projection. True for any variant except "off" / "" / "false" / "0". */
enabled: boolean;
/** Raw variant value. Boolean flags use "on" / "off"; variant flags use arbitrary identifiers. */
variant: string;
/** Why this decision was made. */
reason: Reason;
/** Name of the provider that produced the decision, or "default" when nothing matched. */
source: string;
}
/**
* Per-evaluation context for dynamic targeting (allow/deny lists, percent
* rollouts). All fields are optional; a missing field never crashes the
* evaluation, it simply skips the rules that depend on it.
*/
export interface EvalContext {
userId?: string;
workspaceId?: string;
/** Free-form attributes (plan, country, client, ...). Keys are case-sensitive. */
attributes?: Readonly<Record<string, string>>;
}
/**
* Percent rollout descriptor. The bucket for (key, identifier) is computed
* with FNV-1a so the same identifier always falls into the same bucket
* across processes and tabs.
*/
export interface PercentRollout {
/** Rollout size in [0, 100]. Out-of-range values are clamped. */
percent: number;
/**
* Attribute name used as the bucketing identifier. Defaults to "user_id".
* Use "workspace_id" for workspace-scoped rollouts.
*/
by?: string;
}
/**
* Rule describes how the {@link StaticProvider} evaluates a single flag.
*
* Evaluation order (first match wins):
* 1. Deny: if the EvalContext attribute matches an entry in deny, return OFF.
* 2. Allow: if it matches an entry in allow, return ON.
* 3. Percent: if the bucket falls inside percent.percent, return ON; else OFF.
* 4. Default: return defaultValue.
*/
export interface Rule {
/** Value returned when no targeting rule matches. Defaults to false. */
default?: boolean;
/**
* Variant identifier returned WHEN the rule evaluates to enabled=true.
* Use for multi-arm experiments (e.g. "experiment-v2"). When the rule
* evaluates to enabled=false the Decision's variant is always "off",
* so callers branching on `Variant()` cannot accidentally enter the
* experiment arm for users that did not roll in.
*/
variant?: string;
/** Identifier values that force the flag ON. */
allow?: ReadonlyArray<string>;
/** EvalContext attribute used for allow lookups. Defaults to "user_id". */
allowBy?: string;
/** Identifier values that force the flag OFF. Deny wins over allow. */
deny?: ReadonlyArray<string>;
/** EvalContext attribute used for deny lookups. Defaults to "user_id". */
denyBy?: string;
/** Deterministic percent rollout. */
percent?: PercentRollout;
}
/**
* Provider is the configuration backend for the Service. Implementations
* MUST be safe for concurrent use; the Service reads providers from many
* components without additional synchronization.
*
* Returning `undefined` (instead of a Decision) tells the Service to fall
* through to the next provider in a ChainProvider, or to the caller's
* default if there is no next provider.
*/
export interface Provider {
/** Stable, human-readable identifier surfaced in Decision.source. */
readonly name: string;
/** Evaluate the flag, or return undefined if this provider does not know it. */
lookup(key: string, ctx: EvalContext): Decision | undefined;
}

View File

@@ -100,3 +100,116 @@ describe("useFileUpload — markdownLink picks the durable URL with three-layer
expect(api.uploadFile as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
});
});
// MUL-3339 — `uploading` is an in-flight counter, not a single boolean.
// The single-boolean shape silently regressed the quick-create multi-image
// attach flow: callers fire N concurrent uploads (drag-drop, multi-image
// paste), the first upload's `finally` would flip `uploading` back to false
// while N-1 are still in flight, and the submit gate (which only reads
// `uploading`) would unblock — `stripBlobUrls` then erased the still-pending
// images from the markdown and their attachment ids never reached the
// server. The fix tracks an in-flight counter and exposes
// `uploading = count > 0`, so callers see "uploading" as long as ANY upload
// is in flight.
describe("useFileUpload — concurrent uploads (MUL-3339 regression)", () => {
it("keeps uploading=true until ALL concurrent uploads resolve", async () => {
// Hand-rolled deferreds so the test controls resolve order.
const att1 = makeAttachment({ id: "att-1" });
const att2 = makeAttachment({ id: "att-2" });
let resolve1: (v: Attachment) => void = () => {};
let resolve2: (v: Attachment) => void = () => {};
const p1 = new Promise<Attachment>((r) => {
resolve1 = r;
});
const p2 = new Promise<Attachment>((r) => {
resolve2 = r;
});
const uploadFile = vi
.fn<(file: File) => Promise<Attachment>>()
.mockReturnValueOnce(p1)
.mockReturnValueOnce(p2);
const api = { uploadFile } as unknown as ApiClient;
const { result } = renderHook(() => useFileUpload(api));
expect(result.current.uploading).toBe(false);
// Fire both uploads concurrently — same shape as the quick-create
// drag-drop path (`files.forEach((f) => editorRef.current?.uploadFile(f))`).
let pending1: Promise<UploadResult | null> = Promise.resolve(null);
let pending2: Promise<UploadResult | null> = Promise.resolve(null);
await act(async () => {
pending1 = result.current.upload(
new File(["1"], "a.png", { type: "image/png" }),
);
pending2 = result.current.upload(
new File(["2"], "b.png", { type: "image/png" }),
);
});
expect(result.current.uploading).toBe(true);
// Resolve the FIRST upload only. With the old single-boolean shape this
// would flip `uploading` back to false — that's the production bug.
// With the in-flight counter, `uploading` stays true because upload 2
// is still pending.
await act(async () => {
resolve1(att1);
await pending1;
});
expect(result.current.uploading).toBe(true);
// Now resolve the second upload — only at this point should the gate open.
await act(async () => {
resolve2(att2);
await pending2;
});
expect(result.current.uploading).toBe(false);
});
it("decrements correctly when one of the concurrent uploads throws", async () => {
// The `finally` block runs on rejection too — the counter must still
// decrement so a failed upload never leaves the flag stuck "uploading".
const att = makeAttachment();
let resolveOk: (v: Attachment) => void = () => {};
let rejectBad: (e: Error) => void = () => {};
const ok = new Promise<Attachment>((r) => {
resolveOk = r;
});
const bad = new Promise<Attachment>((_, j) => {
rejectBad = j;
});
const uploadFile = vi
.fn<(file: File) => Promise<Attachment>>()
.mockReturnValueOnce(ok)
.mockReturnValueOnce(bad);
const api = { uploadFile } as unknown as ApiClient;
const { result } = renderHook(() => useFileUpload(api));
let okPending: Promise<UploadResult | null> = Promise.resolve(null);
let badPending: Promise<UploadResult | null> = Promise.resolve(null);
await act(async () => {
okPending = result.current.upload(
new File(["a"], "a.png", { type: "image/png" }),
);
// uploadWithToast swallows errors via onError; we test the raw `upload`
// so the caller sees the rejection. Wrap in a catch so vitest doesn't
// surface an unhandled rejection from the act() boundary.
badPending = result.current.upload(
new File(["b"], "b.png", { type: "image/png" }),
).catch(() => null);
});
expect(result.current.uploading).toBe(true);
await act(async () => {
rejectBad(new Error("boom"));
await badPending;
});
// One still in flight — must remain uploading.
expect(result.current.uploading).toBe(true);
await act(async () => {
resolveOk(att);
await okPending;
});
expect(result.current.uploading).toBe(false);
});
});

View File

@@ -84,7 +84,20 @@ export function useFileUpload(
api: ApiClient,
onError?: (error: Error) => void,
) {
const [uploading, setUploading] = useState(false);
// In-flight counter, NOT a single boolean. Callers fire multiple uploads
// concurrently (drag-drop of N files, paste with multiple images) and the
// boolean shape would flip false as soon as the FIRST upload's finally ran
// — even though N-1 are still mid-request. Surfaces consuming `uploading`
// (the quick-create submit gate, the editor's "Uploading…" button label)
// would then unblock submit while uploads are still in flight, causing
// `stripBlobUrls` to erase the still-pending images from the markdown and
// their attachment ids never to be bound (MUL-3339).
//
// The exposed `uploading: boolean` keeps the existing call-site contract
// (`{ uploading } = useFileUpload(api)` everywhere); only the internal
// tracking shape changes.
const [inFlight, setInFlight] = useState(0);
const uploading = inFlight > 0;
const upload = useCallback(
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
@@ -92,7 +105,7 @@ export function useFileUpload(
throw new Error("File exceeds 100 MB limit");
}
setUploading(true);
setInFlight((n) => n + 1);
try {
const att: Attachment = await api.uploadFile(file, {
issueId: ctx?.issueId,
@@ -101,7 +114,7 @@ export function useFileUpload(
});
return { ...att, link: att.url, markdownLink: pickMarkdownLink(att) };
} finally {
setUploading(false);
setInFlight((n) => n - 1);
}
},
[api],

View File

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

View File

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

View File

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

View File

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

View File

@@ -431,6 +431,117 @@ describe("useUpdateIssue — optimistic move keeps every bucketed board in sync"
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
expect(invalidatedKeys).not.toContainEqual(issueKeys.myAll(WS_ID));
});
it("invalidates myAll on settle when project_id changes (drops the issue from the old project's list)", async () => {
// A project move makes the issue leave the old project's filtered list. The
// surgical patch is filter-blind (it never removes a card that no longer
// matches the list filter), so onSettled must refetch myAll to drop it —
// unlike a status-only move, which deliberately does not (MUL-3669 / #4548).
updateIssue.mockResolvedValue(makeIssue(1, { project_id: "project-9" }));
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
const { result } = renderHook(() => useUpdateIssue(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync({ id: "issue-1", project_id: "project-9" });
});
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
expect(invalidatedKeys).toContainEqual(issueKeys.myAll(WS_ID));
});
});
describe("useUpdateIssue — detaching a sub-issue prunes the old parent's children cache", () => {
const PARENT_ID = "parent-1";
const childKey = issueKeys.children(WS_ID, PARENT_ID);
let qc: QueryClient;
let updateIssue: ReturnType<typeof vi.fn<(id: string, data: unknown) => Promise<Issue>>>;
function childIds(): string[] {
return (qc.getQueryData<Issue[]>(childKey) ?? []).map((c) => c.id);
}
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
updateIssue = vi.fn();
setApiInstance({ updateIssue } as unknown as ApiClient);
// Seed the detail cache so onMutate resolves the old parent from the
// freshest source, plus the parent's children list rendered by the
// sub-issues section.
const child = makeIssue(1, { parent_issue_id: PARENT_ID, stage: 2 });
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, child.id), child);
qc.setQueryData<Issue[]>(childKey, [
child,
makeIssue(2, { parent_issue_id: PARENT_ID }),
]);
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("optimistically removes the issue from the old parent's children array", async () => {
let resolve!: (issue: Issue) => void;
updateIssue.mockReturnValue(
new Promise<Issue>((r) => {
resolve = r;
}),
);
const { result } = renderHook(() => useUpdateIssue(), {
wrapper: createWrapper(qc),
});
act(() => {
result.current.mutate({ id: "issue-1", parent_issue_id: null, stage: null });
});
// Pruned immediately so the parent's sub-issues list drops it now, not
// after the settle refetch; the sibling is untouched.
expect(childIds()).toEqual(["issue-2"]);
await act(async () => {
resolve(makeIssue(1, { parent_issue_id: null, stage: null }));
});
expect(childIds()).not.toContain("issue-1");
});
it("restores the old parent's children when the request fails", async () => {
updateIssue.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useUpdateIssue(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current
.mutateAsync({ id: "issue-1", parent_issue_id: null, stage: null })
.catch(() => {});
});
expect(childIds()).toEqual(["issue-1", "issue-2"]);
});
it("keeps the issue under its parent for a non-reparenting update", async () => {
updateIssue.mockResolvedValue(
makeIssue(1, { parent_issue_id: PARENT_ID, status: "done" }),
);
const { result } = renderHook(() => useUpdateIssue(), {
wrapper: createWrapper(qc),
});
act(() => {
result.current.mutate({ id: "issue-1", status: "done" });
});
// A status-only change patches in place — never prunes the relationship.
expect(childIds()).toEqual(["issue-1", "issue-2"]);
});
});
describe("useBatchUpdateIssues — optimistic patch covers filtered boards too", () => {
@@ -541,6 +652,28 @@ describe("useBatchUpdateIssues — optimistic patch covers filtered boards too",
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
});
it("invalidates myAll on settle when project_id changes (drops moved issues from the old project's list)", async () => {
// Mirrors useUpdateIssue: a batch that moves issues between projects must
// refetch myAll so they leave the old project's filtered list, even though a
// status-only batch deliberately does not (MUL-3669 / #4548).
batchUpdateIssues.mockResolvedValue({ updated: 1 });
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
const { result } = renderHook(() => useBatchUpdateIssues(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync({
ids: ["issue-1"],
updates: { project_id: "project-9" },
});
});
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
expect(invalidatedKeys).toContainEqual(issueKeys.myAll(WS_ID));
});
});
describe("useResolveComment", () => {

View File

@@ -278,10 +278,21 @@ export function useUpdateIssue() {
old ? { ...old, ...patch } : old,
);
if (parentId) {
// When the write re-parents this issue away from `parentId` (detach
// to standalone, or move under a different parent), prune it from the
// old parent's children cache. The parent's sub-issues list renders
// that array directly, so a bare patch to parent_issue_id: null would
// leave an orphaned row in the list until the settle refetch lands.
// onError restores prevChildren, so the prune rolls back on failure.
const detachedFromParent =
Object.prototype.hasOwnProperty.call(patch, "parent_issue_id") &&
patch.parent_issue_id !== parentId;
qc.setQueryData<Issue[]>(
issueKeys.children(wsId, parentId),
(old) =>
old?.map((c) => (c.id === id ? { ...c, ...patch } : c)),
detachedFromParent
? old?.filter((c) => c.id !== id)
: old?.map((c) => (c.id === id ? { ...c, ...patch } : c)),
);
}
return { prevLists, prevDetail, prevChildren, parentId, id };
@@ -333,6 +344,15 @@ export function useUpdateIssue() {
) {
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
}
// Local safety net for a project move. The WS echo now carries
// project_changed, but a moved issue must also drop out of the OLD
// project's filtered list here in case the echo is delayed or dropped. The
// surgical onMutate patch is filter-blind — it never removes a card that no
// longer matches the list's project filter — so reconcile by refetching
// myAll whenever project_id was part of this update (MUL-3669 / #4548).
if (Object.prototype.hasOwnProperty.call(vars, "project_id")) {
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}
// Refresh the issue's attachments cache when the description editor
// bound new uploads — the description editor reads `issueAttachments`
// to resolve text-preview Eye gates, and unlike other mutations this
@@ -523,6 +543,11 @@ export function useBatchUpdateIssues() {
) {
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
}
// Local safety net mirroring useUpdateIssue: drop moved issues from the old
// project's filtered list even if the WS echo is delayed (MUL-3669 / #4548).
if (Object.prototype.hasOwnProperty.call(_vars.updates, "project_id")) {
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({

View File

@@ -317,11 +317,128 @@ describe("onIssueUpdated — position move is surgical, not a list refetch", ()
it("invalidates myAll when the project changes (Project board membership)", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
// issueA.project_id is null; moving it into a project shifts Project-board membership.
// issueA.project_id is null; moving it into a project shifts Project-board
// membership. No server flag here — this exercises the legacy cache-diff
// fallback that keeps a new frontend working against an older backend.
onIssueUpdated(qc, WS_ID, { ...issueA, project_id: "project-9" });
expectInvalidated(qc, issueKeys.myAll(WS_ID));
});
it("invalidates myAll on a server project_changed flag even when the cached project_id already matches (local optimistic move)", () => {
// Reproduces the post-optimistic-move state behind MUL-3669: onMutate has
// already written the NEW project into detail + list, so a cache diff would
// compute projectChanged=false and skip the refetch. The authoritative
// server flag must still drive it.
const moved: Issue = { ...issueA, project_id: "project-9" };
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, moved.id), moved);
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(moved));
onIssueUpdated(qc, WS_ID, moved, { projectChanged: true });
expectInvalidated(qc, issueKeys.myAll(WS_ID));
});
it("does NOT invalidate myAll when the server flag says project_changed=false (flag overrides the legacy diff)", () => {
// No detail/list cache for the issue, so the legacy diff would resolve
// oldProjectId=null and fire on the non-null incoming project_id. An explicit
// false flag from the server is authoritative and must suppress that.
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
onIssueUpdated(
qc,
WS_ID,
{ ...issueA, project_id: "project-9" },
{ projectChanged: false },
);
expect(qc.getQueryState(issueKeys.myAll(WS_ID))?.isInvalidated).toBe(false);
});
});
// A board column header shows `byStatus[status].total`. On a status change the
// surgical patch shifts both bucket totals — but only if it can find the card in
// a loaded page. A paginated column loads just its first page, so an off-screen
// issue (very common when an agent flips the status of something the viewer
// never scrolled to) is absent: patchIssueInBuckets no-ops and the count would
// silently drift, with no refetch to recover it. The status-changed no-op has to
// fall back to a single-list refetch.
describe("onIssueUpdated — off-screen status change reconciles column counts", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("refetches the workspace list when a status-changed issue is not in the loaded page", () => {
// First page only: the totals say these columns have items, but the issues
// arrays are the loaded window — the moved issue lives beyond it.
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: {
in_review: { issues: [], total: 1 },
done: { issues: [], total: 60 },
},
});
onIssueUpdated(
qc,
WS_ID,
{ id: "off-screen", status: "done" },
{ statusChanged: true },
);
expectInvalidated(qc, issueKeys.list(WS_ID));
});
it("refetches the filtered myAll list under the same condition", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), {
byStatus: { done: { issues: [], total: 60 } },
});
onIssueUpdated(
qc,
WS_ID,
{ id: "off-screen", status: "done" },
{ statusChanged: true },
);
expectInvalidated(qc, issueKeys.myAll(WS_ID));
});
it("does NOT refetch when the status-changed issue is loaded (surgical patch suffices)", () => {
const loaded: Issue = { ...baseIssue, id: "loaded", status: "in_review" };
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: {
in_review: { issues: [loaded], total: 1 },
done: { issues: [], total: 60 },
},
});
onIssueUpdated(
qc,
WS_ID,
{ ...loaded, status: "done" },
{ statusChanged: true },
);
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
expect(list?.byStatus.in_review?.total).toBe(0);
expect(list?.byStatus.done?.total).toBe(61);
// Reconciled in place — the no-flicker fast path from #4415 must hold.
expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false);
});
it("does NOT refetch an absent issue when the status did not change", () => {
// A title/label edit of an off-screen issue cannot affect any count, so it
// must not trigger a fallback refetch.
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: { done: { issues: [], total: 60 } },
});
onIssueUpdated(qc, WS_ID, { id: "off-screen", title: "renamed" });
expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false);
});
});
describe("onIssueDeleted", () => {

View File

@@ -1,4 +1,4 @@
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import { projectKeys } from "../projects/queries";
@@ -40,10 +40,16 @@ export function onIssueUpdated(
qc: QueryClient,
wsId: string,
issue: Partial<Issue> & { id: string },
// assigneeChanged comes from the server's issue:updated flags. It gates the
// filtered-list (myAll) invalidate so a non-membership change keeps those
// lists in place instead of refetching.
meta: { assigneeChanged?: boolean } = {},
// assigneeChanged / statusChanged / projectChanged come from the server's
// issue:updated flags. assigneeChanged + projectChanged gate the filtered-list
// (myAll) invalidate so a non-membership change keeps those lists in place
// instead of refetching. statusChanged gates the off-screen count reconcile
// below.
meta: {
assigneeChanged?: boolean;
statusChanged?: boolean;
projectChanged?: boolean;
} = {},
) {
// Look up the OLD parent before mutating list state, so we can keep
// the parent's children cache in sync (powers the sub-issues list
@@ -60,17 +66,41 @@ export function onIssueUpdated(
const parentChanged =
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
// Project-board membership keys on project_id. There is no project_changed
// flag on the wire, so diff the incoming project_id against the cached one.
// Project board membership keys on project_id. Prefer the server's
// project_changed flag (authoritative, set on the wire). Fall back to diffing
// the incoming project_id against the cached one only when the flag is absent
// (older backend): the diff is unreliable once a local optimistic move has
// overwritten the cached project_id, but it still covers remote/agent moves
// and keeps a new frontend on an old backend from regressing (MUL-3669 /
// #4548). The local move itself is also covered by the onSettled safety net in
// useUpdateIssue, which never depends on this flag.
const oldProjectId =
detailData?.project_id ??
(firstListData ? findIssueLocation(firstListData, issue.id)?.issue.project_id : null) ??
null;
const projectChanged =
issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId;
meta.projectChanged ??
(issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId);
// A status change shifts two bucket totals (the column header counts).
// patchIssueInBuckets does that surgically, but only when it can find the card
// in a loaded page; a paginated column holds just its first page, so an issue
// outside that window — common when an agent flips the status of something the
// viewer never scrolled to — makes the patch a no-op (it returns the same
// reference) and the totals silently drift. A status change otherwise never
// refetches the list (that refetch was the drag flicker removed by the
// optimistic-update work), so recover the one case the patch cannot: on a
// status-changed no-op, refetch just that single list to reconcile its counts.
const patchOrRefetchCounts = (key: QueryKey, data: ListIssuesCache) => {
const next = patchIssueInBuckets(data, issue.id, issue);
qc.setQueryData<ListIssuesCache>(key, next);
if (next === data && meta.statusChanged) {
qc.invalidateQueries({ queryKey: key });
}
};
for (const [key, data] of listQueries) {
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issue.id, issue));
if (data) patchOrRefetchCounts(key, data);
}
// The workspace board (issueKeys.list) is NOT filtered: an issue is always a
// member, so patchIssueInBuckets above is a complete surgical reconcile —
@@ -85,9 +115,7 @@ export function onIssueUpdated(
// refetch, no flicker — exactly like the workspace board above.
const myListQueries = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.myAll(wsId) });
for (const [key, data] of myListQueries) {
if (data?.byStatus) {
qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issue.id, issue));
}
if (data?.byStatus) patchOrRefetchCounts(key, data);
}
// Only refetch the filtered lists when the change can actually move an issue
// in/out of one. My-Issues / actor-panel membership keys on the assignee (the

View File

@@ -85,6 +85,8 @@
"./github/queries": "./github/queries.ts",
"./lark": "./lark/index.ts",
"./lark/queries": "./lark/queries.ts",
"./composio": "./composio/index.ts",
"./composio/queries": "./composio/queries.ts",
"./feedback": "./feedback/index.ts",
"./feedback/mutations": "./feedback/mutations.ts",
"./realtime": "./realtime/index.ts",
@@ -99,6 +101,7 @@
"./logger": "./logger.ts",
"./utils": "./utils.ts",
"./constants/*": "./constants/*.ts",
"./feature-flags": "./feature-flags/index.ts",
"./platform": "./platform/index.ts",
"./analytics": "./analytics/index.ts",
"./i18n": "./i18n/index.ts",

View File

@@ -103,8 +103,8 @@ describe("useRealtimeSync — ws instance change", () => {
// Should have called invalidateQueries for all workspace-scoped keys
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
// = 22 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(22);
// + 1 cross-workspace inbox unread summary = 23 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(23);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {

View File

@@ -30,7 +30,7 @@ import {
onIssueLabelsChanged,
onIssueMetadataChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxSummaryInvalidate } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import {
notificationPreferenceOptions,
@@ -230,6 +230,9 @@ export async function handleInboxNew(
): Promise<void> {
const sourceWsId = item.workspace_id;
if (sourceWsId) onInboxNew(qc, sourceWsId, item);
// A new item in ANY workspace can light the workspace-switcher dot, so
// refresh the cross-workspace summary regardless of the active workspace.
onInboxSummaryInvalidate(qc);
// Fire a native OS notification only when the app isn't focused. When
// the user is already looking at Multica, the inbox sidebar's unread
// styling is enough — no need to interrupt with a banner. `desktopAPI`
@@ -320,6 +323,9 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: chatKeys.all(wsId) });
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
}
// Cross-workspace, so outside the wsId guard: a reconnect may have missed
// inbox events from any workspace, so re-pull the switcher-dot summary.
onInboxSummaryInvalidate(qc);
// Per-issue caches are keyed without wsId, so the issueKeys.all(wsId)
// prefix above does not reach them. They rely entirely on WS events for
// freshness (staleTime: Infinity), so events missed while disconnected
@@ -394,6 +400,12 @@ export function useRealtimeSync(
inbox: () => {
const wsId = getCurrentWsId();
if (wsId) onInboxInvalidate(qc, wsId);
// inbox:read / inbox:archived / batch events arrive here. They can
// originate from a workspace other than the active one (personal
// events fan out to all the user's connections), so always refresh
// the cross-workspace summary — its dot must clear when another
// workspace's items are read/archived.
onInboxSummaryInvalidate(qc);
},
agent: () => {
const wsId = getCurrentWsId();
@@ -588,6 +600,8 @@ export function useRealtimeSync(
if (wsId) {
onIssueUpdated(qc, wsId, issue, {
assigneeChanged: payload.assignee_changed,
statusChanged: payload.status_changed,
projectChanged: payload.project_changed,
});
if (issue.status) {
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);

View File

@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { checkQuickCreateCliVersion } from "./cli-version";
import {
checkQuickCreateCliVersion,
handoffSupported,
MIN_HANDOFF_CLI_VERSION,
} from "./cli-version";
describe("checkQuickCreateCliVersion", () => {
it("returns ok for a tagged release at or above the minimum", () => {
@@ -24,3 +28,30 @@ describe("checkQuickCreateCliVersion", () => {
expect(checkQuickCreateCliVersion("0.1.0-1-gabc1234").state).toBe("ok");
});
});
// Mirrors server/pkg/agent/handoff_version_test.go so the frontend soft-gate
// signal and the server's authoritative one agree by construction.
describe("handoffSupported", () => {
it("supports a tagged release at or above the minimum", () => {
expect(handoffSupported(MIN_HANDOFF_CLI_VERSION)).toBe(true);
expect(handoffSupported("0.4.0")).toBe(true);
expect(handoffSupported("v0.3.28")).toBe(true);
});
it("does not support a tagged release below the minimum", () => {
expect(handoffSupported("0.3.26")).toBe(false);
expect(handoffSupported("0.2.21")).toBe(false);
});
it("fails closed on empty or unparsable input", () => {
expect(handoffSupported("")).toBe(false);
expect(handoffSupported(undefined)).toBe(false);
expect(handoffSupported(null)).toBe(false);
expect(handoffSupported("garbage")).toBe(false);
});
it("treats git-describe dev builds as supported regardless of base tag", () => {
expect(handoffSupported("v0.3.0-5-gabc1234")).toBe(true);
expect(handoffSupported("v0.1.0-235-gdaf0e935-dirty")).toBe(true);
});
});

View File

@@ -72,3 +72,33 @@ export function readRuntimeCliVersion(metadata: Record<string, unknown> | undefi
const v = metadata?.cli_version;
return typeof v === "string" ? v : "";
}
/**
* Frontend mirror of the server's `MinHandoffCLIVersion` soft gate
* (`server/pkg/agent/version.go`). The assignment handoff note is only rendered
* into the run's opening prompt by daemons at or above this multica CLI version
* (MUL-3375); older daemons silently drop it. Unlike the quick-create gate this
* never blocks the assignment — the UI just grays out the note box and warns.
*
* Keep in lockstep with the server constant; the two are enforced independently
* (the server is authoritative) but must agree so the warning matches reality.
*/
export const MIN_HANDOFF_CLI_VERSION = "0.3.28";
/**
* Whether a daemon-reported CLI version is new enough to render a handoff note.
* Mirrors server `agent.HandoffSupported`: missing / unparsable / below-minimum
* all degrade to `false`, and dev-built daemons (git-describe shape) always
* pass — the version string is the shared signal, so frontend and server agree
* by construction. Pure and synchronous, so the note box can settle from the
* already-warm runtime cache instead of waiting on the trigger-preview
* round-trip, exactly like the quick-create version gate.
*/
export function handoffSupported(detected: string | undefined | null): boolean {
const current = (detected ?? "").trim();
if (!current) return false;
if (DEV_DESCRIBE_RE.test(current)) return true;
const parsed = parseSemver(current);
if (!parsed) return false;
return !lessThan(parsed, parseSemver(MIN_HANDOFF_CLI_VERSION)!);
}

View File

@@ -63,7 +63,6 @@ export const RUNTIME_PROFILE_PROTOCOL_FAMILIES = [
"opencode",
"openclaw",
"hermes",
"gemini",
"pi",
"cursor",
"kimi",

View File

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

View File

@@ -95,11 +95,17 @@ export interface IssueCreatedPayload {
export interface IssueUpdatedPayload {
issue: Issue;
// The server stamps issue:updated with which fields actually changed
// (server/internal/handler/issue.go publish). Only assignee_changed is read
// today: it lets the realtime layer keep filtered myList caches in place on a
// non-membership change instead of refetching. Other change flags are present
// on the wire too and can be surfaced here when needed.
// (server/internal/handler/issue.go publish). assignee_changed lets the
// realtime layer keep filtered myList caches in place on a non-membership
// change instead of refetching; status_changed lets it reconcile board column
// counts when a status change lands on an off-screen (unloaded) issue;
// project_changed lets it drop a moved issue from the old project's filtered
// list (the client-side cache diff is unreliable after an optimistic local
// move — MUL-3669 / #4548). Other change flags are present on the wire too and
// can be surfaced here when needed.
assignee_changed?: boolean;
status_changed?: boolean;
project_changed?: boolean;
}
export interface IssueDeletedPayload {

View File

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

View File

@@ -61,7 +61,7 @@ export type {
} from "./agent";
export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread } from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, Reaction } from "./comment";
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
@@ -119,6 +119,11 @@ export type {
LarkInstallStatusResponse,
RedeemLarkBindingTokenResponse,
} from "./lark";
export type {
ComposioToolkit,
ComposioConnection,
ComposioConnectInitResponse,
} from "./composio";
export type {
Autopilot,
AutopilotStatus,

View File

@@ -52,13 +52,14 @@ export interface ListProjectsResponse {
// validateAndNormalizeResourceRef on the server and a renderer in the UI.
//
// Known types (UI must default-case unknown server-side additions):
// - github_repo: cloud-side git checkout, ref = { url, default_branch_hint? }
// - github_repo: cloud-side git checkout, ref = { url, ref?, default_branch_hint? }
// - local_directory: in-place agent execution on a specific daemon,
// ref = { local_path, daemon_id, label? }
export type ProjectResourceType = "github_repo" | "local_directory";
export interface GithubRepoResourceRef {
url: string;
ref?: string;
default_branch_hint?: string;
}

View File

@@ -12,6 +12,7 @@ import {
SelectValue,
} from "@multica/ui/components/ui/select";
import { useWorkspaceId } from "@multica/core/hooks";
import type { Agent } from "@multica/core/types";
import { agentListOptions } from "@multica/core/workspace/queries";
import { projectListOptions } from "@multica/core/projects/queries";
import {
@@ -52,6 +53,7 @@ import {
aggregateWeeklyTasks,
aggregateWeeklyTime,
computeDailyTotals,
filterKnownAgentRows,
formatDuration,
mergeAgentDashboardRows,
type AgentDashboardRow,
@@ -98,6 +100,7 @@ const EMPTY_DAILY: import("@multica/core/types").DashboardUsageDaily[] = [];
const EMPTY_BY_AGENT: import("@multica/core/types").DashboardUsageByAgent[] = [];
const EMPTY_RUNTIME: import("@multica/core/types").DashboardAgentRunTime[] = [];
const EMPTY_RUNTIME_DAILY: import("@multica/core/types").DashboardRunTimeDaily[] = [];
const EMPTY_AGENTS: Agent[] = [];
function fmtMoney(n: number): string {
if (n >= 100) return `$${n.toFixed(0)}`;
@@ -169,7 +172,8 @@ export function DashboardPage() {
useCustomPricingStore((s) => s.pricings);
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const agentsQuery = useQuery(agentListOptions(wsId));
const agents = agentsQuery.data ?? EMPTY_AGENTS;
// Validate the picked project against the current workspace's list. A
// stale UUID — left over from a project that's been deleted, or from the
@@ -310,6 +314,20 @@ export function DashboardPage() {
[agentTokenRows, runTimeRows],
);
// Hide rollup rows for agents that were hard-deleted from the workspace —
// they'd otherwise show up as a bare UUID on the leaderboard (MUL-3771).
// Archived agents stay (the agent list is fetched with archived included);
// only truly-removed agents drop out. Skip filtering until the agent list
// has loaded so a slow agents fetch doesn't transiently blank the list.
const knownAgentIds = useMemo(
() => (agentsQuery.isSuccess ? new Set(agents.map((a) => a.id)) : null),
[agentsQuery.isSuccess, agents],
);
const visibleAgentRows = useMemo(
() => filterKnownAgentRows(agentRows, knownAgentIds),
[agentRows, knownAgentIds],
);
return (
<div className="flex h-full flex-col">
{/* h-auto + min-h-12 + flex-wrap: the toolbar (project filter,
@@ -411,7 +429,7 @@ export function DashboardPage() {
{/* Per-agent leaderboard — user picks the ranking metric;
the progress bar and column emphasis follow the metric. */}
<Leaderboard
rows={agentRows}
rows={visibleAgentRows}
agents={agents}
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
/>

View File

@@ -5,6 +5,7 @@ import {
aggregateWeeklyTasks,
aggregateWeeklyTime,
computeDailyTotals,
filterKnownAgentRows,
formatDuration,
mergeAgentDashboardRows,
} from "./utils";
@@ -201,6 +202,29 @@ describe("mergeAgentDashboardRows", () => {
});
});
describe("filterKnownAgentRows", () => {
const rows = [
{ agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 },
{ agentId: "deleted", tokens: 50, cost: 0.5, seconds: 5, taskCount: 1 },
];
it("drops rows whose agent is no longer in the workspace", () => {
// "deleted" is absent from the known set — it's a hard-deleted agent whose
// legacy rollup row would otherwise render as a bare UUID.
const out = filterKnownAgentRows(rows, new Set(["live"]));
expect(out.map((r) => r.agentId)).toEqual(["live"]);
});
it("keeps every row while the agent list is still loading (null set)", () => {
const out = filterKnownAgentRows(rows, null);
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted"]);
});
it("drops every row when the known set is empty", () => {
expect(filterKnownAgentRows(rows, new Set())).toEqual([]);
});
});
describe("formatDuration", () => {
it("formats seconds-only durations", () => {
expect(formatDuration(45, "<1m")).toBe("45s");

View File

@@ -227,6 +227,23 @@ export function mergeAgentDashboardRows(
});
}
// Drop usage rows whose agent no longer exists in the workspace. The agent
// list is fetched with `include_archived: true`, so archived agents keep
// their names and stay on the leaderboard; only hard-deleted agents fall out
// of `knownAgentIds`. Those are legacy rollup rows that would otherwise
// render as a bare UUID (MUL-3771).
//
// `knownAgentIds` is empty while the agent list is still loading; callers
// pass `null` in that case so the rows pass through untouched instead of the
// whole leaderboard blanking on a slow fetch.
export function filterKnownAgentRows(
rows: AgentDashboardRow[],
knownAgentIds: ReadonlySet<string> | null,
): AgentDashboardRow[] {
if (!knownAgentIds) return rows;
return rows.filter((r) => knownAgentIds.has(r.agentId));
}
// ---------------------------------------------------------------------------
// Weekly fold for run-time + tasks. Mirrors `aggregateByWeek` in
// `runtimes/utils.ts` which already covers cost / tokens — same calendar

View File

@@ -621,6 +621,19 @@ function EditorBubbleMenu({
<Separator orientation="vertical" className="mx-0.5 h-5" />
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} activeLevel={fmt.heading1 ? 1 : fmt.heading2 ? 2 : fmt.heading3 ? 3 : undefined} />
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} isBullet={fmt.bulletList} isOrdered={fmt.orderedList} isTask={fmt.taskList} />
{/* Dedicated one-click toggle for checkbox task lists — turns the
current line(s) into a `- [ ]` task item or back to a paragraph.
The same toggle also lives in the List dropdown, but a direct
button keeps the common "make this a checklist" action one tap
away instead of two. */}
<Tooltip>
<TooltipTrigger render={
<Toggle size="sm" pressed={fmt.taskList} onPressedChange={() => editor.chain().focus().toggleTaskList().run()} onMouseDown={(e) => e.preventDefault()} />
}>
<ListTodo className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>{t(($) => $.bubble_menu.task_list)}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger render={
<Toggle size="sm" pressed={fmt.blockquote} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />

View File

@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { PatchedListItem } from "./list-item";
import { TaskList } from "@tiptap/extension-list";
import { PatchedListItem, PatchedTaskItem } from "./list-item";
interface JsonNode {
type: string;
@@ -14,7 +15,12 @@ function makeEditor(content: JsonNode) {
document.body.appendChild(element);
return new Editor({
element,
extensions: [StarterKit.configure({ listItem: false }), PatchedListItem],
extensions: [
StarterKit.configure({ listItem: false }),
PatchedListItem,
TaskList,
PatchedTaskItem,
],
content,
});
}
@@ -37,26 +43,100 @@ function listItemTextPos(editor: Editor, index: number): number {
return pos;
}
/** Mimic the editor's Enter keymap: invoke the bound Enter shortcut directly. */
function pressEnter(editor: Editor): boolean {
const listItemExt = editor.extensionManager.extensions.find(
(e) => e.name === "listItem",
);
if (!listItemExt) throw new Error("listItem extension not registered");
/**
* Mimic an editor keymap by invoking a bound shortcut directly. We can't drive
* real key events reliably in jsdom, so we resolve the keymap an extension
* registers and call the entry for `key`. The shared list keymap closes over
* `editor` (not `this`), so the rebind only needs a faithful `this`.
*/
function pressShortcut(editor: Editor, extName: string, key: string): boolean {
const ext = editor.extensionManager.extensions.find((e) => e.name === extName);
if (!ext) throw new Error(`${extName} extension not registered`);
const shortcuts = (
listItemExt.config.addKeyboardShortcuts as
ext.config.addKeyboardShortcuts as
| (() => Record<string, () => boolean>)
| undefined
)?.bind({
editor,
name: "listItem",
options: listItemExt.options,
type: editor.schema.nodes.listItem,
storage: listItemExt.storage,
name: extName,
options: ext.options,
type: editor.schema.nodes[extName],
storage: ext.storage,
} as never)();
const enter = shortcuts?.Enter;
if (!enter) throw new Error("Enter shortcut not bound");
return enter();
const fn = shortcuts?.[key];
if (!fn) throw new Error(`${key} shortcut not bound on ${extName}`);
return fn();
}
/** Mimic the editor's Enter keymap: invoke the bound Enter shortcut directly. */
function pressEnter(editor: Editor): boolean {
return pressShortcut(editor, "listItem", "Enter");
}
/** Indented bullet outline of the doc — nesting depth, item text only. */
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
const ITEM_TYPES = ["listItem", "taskItem"];
function outline(json: JsonNode): string {
const lines: string[] = [];
function rec(node: JsonNode, depth: number) {
for (const child of node.content ?? []) {
if (LIST_TYPES.includes(child.type)) {
rec(child, depth + 1);
} else if (ITEM_TYPES.includes(child.type)) {
const text = child.content?.[0]?.content?.[0]?.text ?? "";
lines.push(" ".repeat(Math.max(0, depth - 1)) + "- " + text);
for (const gc of child.content ?? []) {
if (LIST_TYPES.includes(gc.type)) rec(gc, depth + 1);
}
} else {
rec(child, depth);
}
}
}
rec(json, 0);
return lines.join("\n");
}
/** Inside-paragraph position of the index-th item of `typeName` (doc order). */
function itemPos(editor: Editor, typeName: string, index: number): number {
const positions: number[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name === typeName) positions.push(pos + 2);
return true;
});
const pos = positions[index];
if (pos === undefined) throw new Error(`no ${typeName} at index ${index}`);
return pos;
}
/** Select from the start of item `fromIdx`'s text to the end of item `toIdx`'s. */
function selectItemRange(
editor: Editor,
typeName: string,
fromIdx: number,
toIdx: number,
itemLen = 3,
) {
editor.commands.setTextSelection({
from: itemPos(editor, typeName, fromIdx),
to: itemPos(editor, typeName, toIdx) + itemLen,
});
}
/** A flat three-item list ("aaa","bbb","ccc") of the given node types. */
function flatList(listType: string, itemType: string): JsonNode {
return {
type: "doc",
content: [
{
type: listType,
content: ["aaa", "bbb", "ccc"].map((t) => ({
type: itemType,
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
})),
},
],
};
}
describe("PatchedListItem Enter behaviour", () => {
@@ -179,3 +259,171 @@ describe("PatchedListItem Enter behaviour", () => {
expect(outerText).toBe("outer");
});
});
describe("PatchedListItem Tab indent (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
const pressTab = (e: Editor) => pressShortcut(e, "listItem", "Tab");
it("leaves the doc unchanged but swallows Tab in the first item (stay put, do not escape focus)", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
editor.commands.setTextSelection(itemPos(editor, "listItem", 0));
// Nothing to nest under, so the structural indent is a no-op — but the caret
// is in a list, so Tab is swallowed (true) instead of leaking to the browser
// and moving focus to other controls. The doc must be untouched.
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
it("indents a single non-first item under its predecessor", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
editor.commands.setTextSelection(itemPos(editor, "listItem", 1));
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n- ccc",
);
});
it("indents a whole-list selection (items 1..3): first stays, 2 and 3 nest", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
expect(pressTab(editor)).toBe(true);
// The reported bug: this used to be a no-op because range.startIndex === 0.
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
it("indents a mid-list selection (items 2..3) under the first", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 1, 2);
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
it("returns false cleanly when the selection is not in a list (C2: no range)", () => {
editor = makeEditor({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "plain" }] },
],
});
editor.commands.setTextSelection(3);
expect(pressTab(editor)).toBe(false);
});
it("indents in a single, undoable transaction (C3)", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
const view = editor.view;
const original = view.dispatch.bind(view);
let dispatches = 0;
view.dispatch = (tr) => {
dispatches += 1;
original(tr);
};
try {
expect(pressTab(editor)).toBe(true);
} finally {
view.dispatch = original;
}
// One dispatch -> one transaction -> one undo step.
expect(dispatches).toBe(1);
editor.commands.undo();
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
});
describe("Tab indent across list types (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
it("indents an ordered-list whole selection (not only unordered)", () => {
editor = makeEditor(flatList("orderedList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
expect(pressShortcut(editor, "listItem", "Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
expect((editor.getJSON() as JsonNode).content?.[0]?.type).toBe(
"orderedList",
);
});
it("indents a task-list whole selection via the taskItem keymap", () => {
editor = makeEditor(flatList("taskList", "taskItem"));
selectItemRange(editor, "taskItem", 0, 2);
expect(pressShortcut(editor, "taskItem", "Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
});
describe("Shift-Tab dedent regression (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
it("lifts a multi-item nested selection back to the top level (unchanged)", () => {
editor = makeEditor({
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "aaa" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "bbb" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "ccc" }],
},
],
},
],
},
],
},
],
},
],
});
// Select the two nested items (bbb, ccc) and dedent.
selectItemRange(editor, "listItem", 1, 2);
expect(pressShortcut(editor, "listItem", "Shift-Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
});

View File

@@ -1,5 +1,65 @@
import { type Editor, InputRule } from "@tiptap/core";
import { ListItem, TaskItem } from "@tiptap/extension-list";
import { sinkListItem as pmSinkListItem } from "@tiptap/pm/schema-list";
import { type Command, TextSelection } from "@tiptap/pm/state";
import type { NodeType } from "@tiptap/pm/model";
/**
* Tab indent that also works for a multi-item selection whose first item is the
* first child of its (sub)list.
*
* Stock `sinkListItem` (prosemirror-schema-list) bails — returns false without
* dispatching — whenever `range.startIndex === 0`, because the first item has no
* preceding sibling to nest under. That is correct for a collapsed cursor in the
* first item, but it also kills the natural "select the whole list from the top
* and press Tab" gesture: the command sees the first item at index 0 and does
* nothing (MUL-3697).
*
* The structurally-correct behaviour in a nested-list model (matching Notion /
* GitHub) is: keep the first selected item as an anchor and sink the rest under
* it. We get that by re-running the *stock* command on a selection narrowed to
* start inside the SECOND selected item (so its `startIndex` becomes 1) while
* keeping the original `$to`. The narrowed selection is computed on a derived
* state and never dispatched on its own, so the whole operation is a single
* dispatch / single undo step.
*
* Shift-Tab / `liftListItem` has no equivalent limitation (it handles ranges and
* the first-item case correctly), so only Tab needs this wrapper.
*/
function sinkListItemRange(itemType: NodeType): Command {
return (state, dispatch) => {
// Normal path — cursor or range not starting at the first item. This also
// covers the genuine no-op for a collapsed cursor in the first item: stock
// returns false and the fallback guards below also return false.
if (pmSinkListItem(itemType)(state, dispatch)) return true;
const { $from, $to } = state.selection;
const range = $from.blockRange(
$to,
(node) => node.childCount > 0 && node.firstChild?.type === itemType,
);
// Clean false (no dispatch) when the fallback does not apply: no list range,
// the range does not start at the first item, fewer than two items are
// selected, or the item type does not match (C2).
if (!range) return false;
if (range.startIndex !== 0) return false;
if (range.endIndex - range.startIndex < 2) return false;
if (range.parent.child(range.startIndex).type !== itemType) return false;
// Move $from into the second selected item, keep $to in the last selected
// item (C1 — do not collapse onto the second item). +2 steps over the
// <listItem> + <paragraph> open tokens into inline content; `between` snaps
// to a valid text position if the item does not start with a paragraph.
const secondItemStart =
range.start + range.parent.child(range.startIndex).nodeSize + 2;
const narrowed = state.apply(
state.tr.setSelection(
TextSelection.between(state.doc.resolve(secondItemStart), $to),
),
);
return pmSinkListItem(itemType)(narrowed, dispatch);
};
}
/**
* Shared list keymap with proper "double-Enter exits list" behaviour.
@@ -19,7 +79,16 @@ import { ListItem, TaskItem } from "@tiptap/extension-list";
* empty items are unaffected because `splitListItem` handles them correctly
* and returns true.
*
* Tab / Shift-Tab indent / dedent the item.
* Tab indents the item(s) — see `sinkListItemRange` for the multi-item
* first-item handling. Shift-Tab dedents via the stock command.
*
* Whenever the caret is inside a list item, Tab is the list's indent control
* and must be swallowed even when the structural indent is a no-op (first child
* with nothing to nest under, or already at max depth). Otherwise the unhandled
* Tab falls through to the browser and moves focus out of the editor to the
* next control. So the return value tracks "is the caret in this list?"
* (`editor.isActive(name)`), NOT "did the indent move anything?": indent
* best-effort, then swallow while in a list, fall through (focus nav) when not.
*/
function listItemKeymap(editor: Editor, name: string) {
return {
@@ -28,7 +97,14 @@ function listItemKeymap(editor: Editor, name: string) {
() => commands.splitListItem(name),
() => commands.liftListItem(name),
]),
Tab: () => editor.commands.sinkListItem(name),
Tab: () => {
const itemType = editor.schema.nodes[name];
if (!itemType) return false;
sinkListItemRange(itemType)(editor.state, (tr) =>
editor.view.dispatch(tr),
);
return editor.isActive(name);
},
"Shift-Tab": () => editor.commands.liftListItem(name),
};
}

View File

@@ -209,6 +209,63 @@ describe("createMentionSuggestion", () => {
).toBe(true);
});
// MUL-3685: plain Tab accepts the highlighted row exactly like Enter.
it("accepts the highlighted row on plain Tab, like Enter", () => {
const command = vi.fn<(item: MentionItem) => void>();
const ref = createRef<MentionListRef>();
const items: MentionItem[] = [
{ id: "i-1", label: "MUL-1", type: "issue" },
{ id: "i-2", label: "MUL-2", type: "issue" },
];
render(
<I18nWrapper>
<MentionList ref={ref} items={items} query="" command={command} />
</I18nWrapper>,
);
const handled = ref.current?.onKeyDown({
event: new KeyboardEvent("keydown", { key: "Tab" }),
});
expect(handled).toBe(true);
expect(command).toHaveBeenCalledTimes(1);
expect(command.mock.calls[0]?.[0]?.label).toBe("MUL-1");
});
// Shift+Tab and any modifier+Tab stay focus navigation — they must NOT
// accept, so the picker never traps reverse Tab traversal or OS switching.
it("does not accept on Shift+Tab or modifier+Tab", () => {
const command = vi.fn<(item: MentionItem) => void>();
const ref = createRef<MentionListRef>();
const items: MentionItem[] = [{ id: "i-1", label: "MUL-1", type: "issue" }];
render(
<I18nWrapper>
<MentionList ref={ref} items={items} query="" command={command} />
</I18nWrapper>,
);
const press = (init: KeyboardEventInit) =>
ref.current?.onKeyDown({ event: new KeyboardEvent("keydown", init) });
expect(press({ key: "Tab", shiftKey: true })).toBe(false);
expect(press({ key: "Tab", metaKey: true })).toBe(false);
expect(press({ key: "Tab", ctrlKey: true })).toBe(false);
expect(press({ key: "Tab", altKey: true })).toBe(false);
expect(command).not.toHaveBeenCalled();
});
it("captures Tab while the popup has no selectable items, like Enter", () => {
const ref = createRef<MentionListRef>();
render(<I18nWrapper><MentionList ref={ref} items={[]} query="协作" command={vi.fn()} /></I18nWrapper>);
expect(
ref.current?.onKeyDown({ event: new KeyboardEvent("keydown", { key: "Tab" }) }),
).toBe(true);
});
// MUL-3607: groupItems() re-buckets the list (current → recent → search →
// users → issues), so an item that sits LATER in the data array can render
// NEAR THE TOP. Selection must follow the rendered order — otherwise the

View File

@@ -42,7 +42,7 @@ import {
sortUserItemsByRecency,
} from "./mention-recency";
import { matchesPinyin } from "./pinyin-match";
import { createSuggestionPopupRender } from "./suggestion-popup";
import { createSuggestionPopupRender, isPickerAcceptKey } from "./suggestion-popup";
// ---------------------------------------------------------------------------
// Types
@@ -288,7 +288,9 @@ export const MentionList = forwardRef<MentionListRef, MentionListProps>(
setSelectedKey(mentionItemKey(orderedItems[next]!));
return true;
}
if (event.key === "Enter") {
// Enter is the canonical accept; plain Tab is an additive alias (see
// isPickerAcceptKey). Shift/modifier+Tab fall through to focus nav.
if (isPickerAcceptKey(event)) {
if (orderedItems.length === 0) return true;
selectItem(orderedItems[selectedIndex]);
return true;

View File

@@ -306,6 +306,54 @@ describe("SlashCommandList keyboard handling", () => {
).toBe(true);
expect(command).toHaveBeenCalledWith(selectableItems[0]);
});
// MUL-3685: plain Tab accepts the highlighted item like Enter; Shift+Tab and
// modifier+Tab fall through so reverse focus / OS switching are preserved.
it("accepts the highlighted item on plain Tab, ignoring Shift/modifier+Tab", () => {
const ref = createRef<SlashCommandListRef>();
const command = vi.fn();
const selectableItems: SlashCommandItem[] = [
{ id: "s1", label: "deploy", description: "Ship changes" },
{ id: "s2", label: "review", description: "Review code" },
];
render(
<I18nWrapper>
<SlashCommandList
ref={ref}
items={selectableItems}
query=""
command={command}
/>
</I18nWrapper>,
);
const press = (init: KeyboardEventInit) =>
ref.current?.onKeyDown({ event: new KeyboardEvent("keydown", init) });
expect(press({ key: "Tab", shiftKey: true })).toBe(false);
expect(press({ key: "Tab", metaKey: true })).toBe(false);
expect(command).not.toHaveBeenCalled();
expect(press({ key: "Tab" })).toBe(true);
expect(command).toHaveBeenCalledWith(selectableItems[0]);
});
it("lets Tab fall through when there are no selectable items, like Enter", () => {
const ref = createRef<SlashCommandListRef>();
render(
<I18nWrapper>
<SlashCommandList ref={ref} items={[]} query="" command={vi.fn()} />
</I18nWrapper>,
);
expect(
ref.current?.onKeyDown({
event: new KeyboardEvent("keydown", { key: "Tab" }),
}),
).toBe(false);
});
});
describe("SlashCommandList empty states", () => {

View File

@@ -19,7 +19,7 @@ import { isImeComposing } from "@multica/core/utils";
import { workspaceKeys } from "@multica/core/workspace/queries";
import type { Agent, MemberWithUser } from "@multica/core/types";
import { useT } from "../../i18n";
import { createSuggestionPopupRender } from "./suggestion-popup";
import { createSuggestionPopupRender, isPickerAcceptKey } from "./suggestion-popup";
const MAX_ITEMS = 20;
@@ -95,7 +95,9 @@ export const SlashCommandList = forwardRef<
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
// Enter is the canonical accept; plain Tab is an additive alias (see
// isPickerAcceptKey). Shift/modifier+Tab fall through to focus nav.
if (isPickerAcceptKey(event)) {
if (items.length === 0) return false;
selectItem(selectedIndex);
return true;

View File

@@ -6,7 +6,8 @@ import { PluginKey } from "@tiptap/pm/state";
import { forwardRef, useImperativeHandle } from "react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createSuggestionPopupRender } from "./suggestion-popup";
import { createSuggestionPopupRender, isPickerAcceptKey } from "./suggestion-popup";
import { PatchedListItem } from "./list-item";
interface TestItem {
id: string;
@@ -214,3 +215,178 @@ describe("createSuggestionPopupRender", () => {
},
);
});
// ---------------------------------------------------------------------------
// isPickerAcceptKey — the shared accept-key policy (MUL-3685)
// ---------------------------------------------------------------------------
describe("isPickerAcceptKey", () => {
const accepts = (init: KeyboardEventInit) =>
isPickerAcceptKey(new KeyboardEvent("keydown", init));
it("treats Enter and plain Tab as accept keys", () => {
expect(accepts({ key: "Enter" })).toBe(true);
expect(accepts({ key: "Tab" })).toBe(true);
});
it("keeps Enter an accept key regardless of modifiers (Mod-Enter unchanged)", () => {
expect(accepts({ key: "Enter", metaKey: true })).toBe(true);
});
it("does not treat Shift+Tab or Ctrl/Cmd/Alt+Tab as accept keys", () => {
expect(accepts({ key: "Tab", shiftKey: true })).toBe(false);
expect(accepts({ key: "Tab", ctrlKey: true })).toBe(false);
expect(accepts({ key: "Tab", metaKey: true })).toBe(false);
expect(accepts({ key: "Tab", altKey: true })).toBe(false);
});
it("ignores unrelated keys", () => {
expect(accepts({ key: "ArrowDown" })).toBe(false);
expect(accepts({ key: "Escape" })).toBe(false);
expect(accepts({ key: "a" })).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Plugin-order guard (MUL-3685): when a suggestion is open inside a list item,
// the suggestion layer's Tab handling must outrank PatchedListItem's
// Tab -> sinkListItem keymap. This replicates the real extension ordering and
// fires Tab through ProseMirror's actual handleKeyDown dispatch, so a future
// reorder that lets the list keymap win is caught here.
// ---------------------------------------------------------------------------
const AcceptOnTabList = forwardRef<TestListRef, TestListProps>(
function AcceptOnTabList({ items, command }, ref) {
useImperativeHandle(ref, () => ({
// Mirror the real mention/slash lists: accept the highlighted row on the
// shared accept keys (Enter / plain Tab), fall through otherwise.
onKeyDown: ({ event }) => {
if (isPickerAcceptKey(event)) {
command(items[0]!);
return true;
}
return false;
},
}));
return (
<div data-testid="suggestion-popup">
{items.map((item) => (
<button key={item.id} type="button" onClick={() => command(item)}>
{item.label}
</button>
))}
</div>
);
},
);
function makeListEditor() {
const pluginKey = new PluginKey("test-list-suggestion");
const item: TestItem = { id: "u1", label: "Alice" };
const TestListSuggestionExtension = Extension.create({
name: "testListSuggestion",
addProseMirrorPlugins() {
return [
Suggestion<TestItem, TestItem>({
editor: this.editor,
char: "@",
pluginKey,
items: () => [item],
command: ({ editor: ed, range, props }) => {
ed.commands.insertContentAt(range, `@${props.label}`);
},
render: createSuggestionPopupRender<TestItem, TestItem, TestListRef, TestListProps>({
pluginKey,
component: AcceptOnTabList,
getProps: (props: SuggestionProps<TestItem, TestItem>) => ({
items: props.items,
command: props.command,
}),
onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false,
}),
}),
];
},
});
editor = new Editor({
// Mirror the real wiring (extensions/index.ts): StarterKit's stock list
// item is disabled in favour of PatchedListItem, which binds
// Tab -> sinkListItem / Shift-Tab -> liftListItem.
extensions: [
StarterKit.configure({ listItem: false }),
PatchedListItem,
TestListSuggestionExtension,
],
content: "",
});
render(<EditorContent editor={editor} />);
return editor;
}
// Build a two-item bullet list with the caret in the SECOND item. sinkListItem
// can only indent an item that has a PRECEDING sibling, so the cursor must be
// in item 2 for Tab -> sinkListItem to actually fire (Howard, MUL-3685 review):
// in the first item sink is a no-op, and the guard would pass even if the
// suggestion layer did nothing. Built from empty so `@` lands at the start of
// item 2's paragraph (a valid suggestion boundary) without HTML-parse quirks.
async function buildTwoItemList(ed: Editor) {
await act(async () => {
ed.commands.focus();
ed.commands.toggleBulletList();
ed.commands.insertContent("first");
ed.commands.splitListItem("listItem");
});
}
async function openPickerInSecondListItem(ed: Editor) {
await buildTwoItemList(ed);
await triggerSuggestion(ed, "@a");
}
describe("suggestion Tab priority over the list-item keymap", () => {
it("sanity: a bare Tab in the second list item DOES sink it (guard is sink-capable)", async () => {
const ed = makeListEditor();
await buildTwoItemList(ed);
await act(async () => {
fireEvent.keyDown(ed.view.dom, { key: "Tab" });
});
// No picker open: PatchedListItem's Tab -> sinkListItem nests item 2 under
// item 1, producing a second <ul>. This proves the doc/selection actually
// lets sinkListItem fire, so the accept-wins assertion below is meaningful
// rather than passing because sink was a no-op.
expect(ed.getHTML().match(/<ul/g)?.length ?? 0).toBe(2);
});
it("accepts the highlighted row on Tab even when Tab would otherwise sink the item", async () => {
const ed = makeListEditor();
await openPickerInSecondListItem(ed);
await act(async () => {
fireEvent.keyDown(ed.view.dom, { key: "Tab" });
});
// Accept won over PatchedListItem's Tab -> sinkListItem: the mention text
// was inserted and item 2 was NOT nested (still a single <ul>).
await waitFor(() => {
expect(ed.getText()).toContain("@Alice");
});
expect(ed.getHTML().match(/<ul/g)?.length ?? 0).toBe(1);
});
it("does not accept on Shift+Tab inside a list item — reverse nav is preserved", async () => {
const ed = makeListEditor();
await openPickerInSecondListItem(ed);
await act(async () => {
fireEvent.keyDown(ed.view.dom, { key: "Tab", shiftKey: true });
});
// Shift+Tab is not an accept key, so the suggestion never committed.
expect(ed.getText()).not.toContain("@Alice");
});
});

View File

@@ -6,6 +6,32 @@ import { ReactRenderer } from "@tiptap/react";
import { exitSuggestion, type SuggestionKeyDownProps, type SuggestionProps } from "@tiptap/suggestion";
import type { PluginKey } from "@tiptap/pm/state";
/**
* Keys that accept the currently highlighted suggestion row.
*
* `Enter` is the canonical accept (WAI-ARIA combobox guidance). Plain `Tab` is
* an additive convenience that matches terminal / CLI / editor completion
* muscle memory (MUL-3685). `Shift+Tab` and any `Ctrl/Cmd/Alt + Tab` are
* deliberately NOT accept keys: they stay reverse focus navigation / OS window
* switching, so standard keyboard accessibility is preserved.
*
* Centralizing the rule here keeps every picker built on
* `createSuggestionPopupRender` (mention, slash-skill, builtin command, and any
* future suggestion list) consistent instead of each list re-deciding what
* counts as "accept". Callers use it in place of a bare `event.key === "Enter"`
* check, so `Tab` becomes a strict alias of `Enter` inside their accept branch.
*/
export function isPickerAcceptKey(event: KeyboardEvent): boolean {
if (event.key === "Enter") return true;
return (
event.key === "Tab" &&
!event.shiftKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
);
}
interface SuggestionPopupRenderOptions<
TItem,
TSelected = TItem,

View File

@@ -425,21 +425,36 @@ export const ReadonlyContent = memo(function ReadonlyContent({
// <Attachment>, which reads the surrounding AttachmentDownloadProvider.
const components = useMemo(() => buildComponents(), []);
// Memoize the whole react-markdown subtree on its only real inputs
// (`processed` + `components`). Unrelated parent re-renders (e.g. a sibling
// agent task streaming over WebSocket fires one every ~100ms) would otherwise
// re-run react-markdown, which hands `<code>` a fresh `dangerouslySetInnerHTML`
// object each time; React then rewrites the highlighted innerHTML even though
// the HTML string is byte-identical, tearing down and rebuilding every hljs
// <span> — which collapses any active text selection inside a code block
// (MUL-3621). A stable element reference lets React bail out of the subtree.
const markdown = useMemo(
() => (
<ReactMarkdown
remarkPlugins={[
[remarkMath, { singleDollarTextMath: false }],
remarkBreaks,
[remarkGfm, { singleTilde: false }],
]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
urlTransform={urlTransform}
components={components}
>
{processed}
</ReactMarkdown>
),
[processed, components],
);
return (
<AttachmentDownloadProvider attachments={attachments}>
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
<ReactMarkdown
remarkPlugins={[
[remarkMath, { singleDollarTextMath: false }],
remarkBreaks,
[remarkGfm, { singleTilde: false }],
]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
urlTransform={urlTransform}
components={components}
>
{processed}
</ReactMarkdown>
{markdown}
<LinkHoverCard {...hover} />
</div>
</AttachmentDownloadProvider>

View File

@@ -194,6 +194,41 @@ describe("IssueActionsDropdown", () => {
expect(await screen.findByText("Test User")).toBeInTheDocument();
});
it("shows 'Remove parent issue' in the More submenu only when the issue has a parent", async () => {
const childIssue = { ...mockIssue, parent_issue_id: "parent-1" } as Issue;
render(
wrap(
<IssueActionsDropdown
issue={childIssue}
trigger={<button data-testid="trigger">Menu</button>}
/>,
),
);
fireEvent.click(screen.getByTestId("trigger"));
fireEvent.click(await screen.findByText("More"));
expect(await screen.findByText("Remove parent issue")).toBeInTheDocument();
});
it("hides 'Remove parent issue' when the issue has no parent", async () => {
render(
wrap(
<IssueActionsDropdown
issue={mockIssue}
trigger={<button data-testid="trigger">Menu</button>}
/>,
),
);
fireEvent.click(screen.getByTestId("trigger"));
fireEvent.click(await screen.findByText("More"));
// The sibling "Set parent issue..." proves the submenu opened.
expect(await screen.findByText("Set parent issue...")).toBeInTheDocument();
expect(screen.queryByText("Remove parent issue")).not.toBeInTheDocument();
});
it("clicking Delete issue opens the delete-confirm modal", async () => {
render(
wrap(

View File

@@ -74,6 +74,7 @@ vi.mock("sonner", () => ({
}));
// Import AFTER mocks are registered.
import { toast } from "sonner";
import { useIssueActions } from "../use-issue-actions";
const mockIssue: Issue = {
@@ -109,6 +110,8 @@ beforeEach(() => {
mockUpdateMutate.mockReset();
mockCreatePinMutate.mockReset();
mockDeletePinMutate.mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(toast.error).mockReset();
pinListRef.value = [];
localStorage.clear();
Object.defineProperty(navigator, "clipboard", {
@@ -232,6 +235,63 @@ describe("useIssueActions", () => {
});
});
it("removeParent clears parent_issue_id and stage in one write, never via the run-confirm modal", () => {
const childIssue = {
...mockIssue,
parent_issue_id: "parent-1",
stage: 2,
} as Issue;
const { result } = renderHook(() => useIssueActions(childIssue), { wrapper });
act(() => {
result.current.removeParent();
});
expect(mockUpdateMutate).toHaveBeenCalledWith(
{ id: "issue-1", parent_issue_id: null, stage: null },
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
// Detaching never routes through the run-confirm modal.
expect(mockOpenModal).not.toHaveBeenCalled();
});
it("removeParent toasts success only from onSuccess — not eagerly, and not on failure", () => {
const childIssue = {
...mockIssue,
parent_issue_id: "parent-1",
} as Issue;
const { result } = renderHook(() => useIssueActions(childIssue), { wrapper });
act(() => {
result.current.removeParent();
});
// mutate() is fire-and-forget here (mocked), so nothing is confirmed yet.
expect(toast.success).not.toHaveBeenCalled();
expect(mockUpdateMutate).toHaveBeenCalledTimes(1);
const opts = mockUpdateMutate.mock.calls[0]![1] as {
onSuccess: () => void;
onError: (err: unknown) => void;
};
// A failed write surfaces the error, never a false "removed" confirmation.
act(() => {
opts.onError(new Error("forbidden"));
});
expect(toast.error).toHaveBeenCalledWith("forbidden");
expect(toast.success).not.toHaveBeenCalled();
// Only the server-confirmed success toasts.
act(() => {
opts.onSuccess();
});
expect(toast.success).toHaveBeenCalledTimes(1);
});
it("togglePin calls createPin when not pinned and deletePin when pinned", async () => {
pinListRef.value = [];
const { result: r1 } = renderHook(() => useIssueActions(mockIssue), { wrapper });

View File

@@ -15,6 +15,7 @@ import {
PinOff,
Plus,
Trash2,
Unlink,
UserMinus,
} from "lucide-react";
import type { AgentTask, Issue } from "@multica/core/types";
@@ -104,6 +105,7 @@ export function IssueActionsMenuItems({
copyLink,
openCreateSubIssue,
openSetParent,
removeParent,
openAddChild,
openDeleteConfirm,
} = actions;
@@ -284,6 +286,12 @@ export function IssueActionsMenuItems({
<ArrowUp className="h-3.5 w-3.5" />
{t(($) => $.actions.set_parent_issue)}
</P.Item>
{issue.parent_issue_id && (
<P.Item onClick={removeParent}>
<Unlink className="h-3.5 w-3.5" />
{t(($) => $.actions.remove_parent_issue)}
</P.Item>
)}
<P.Item onClick={openAddChild}>
<ArrowDown className="h-3.5 w-3.5" />
{t(($) => $.actions.add_sub_issue)}

View File

@@ -21,6 +21,7 @@ export interface UseIssueActionsResult {
copyLink: () => Promise<void>;
openCreateSubIssue: () => void;
openSetParent: () => void;
removeParent: () => void;
openAddChild: () => void;
openDeleteConfirm: (opts?: { onDeletedNavigateTo?: string }) => void;
}
@@ -133,6 +134,31 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
openModal("issue-set-parent", { issueId });
}, [openModal, issueId]);
// Detach from the parent and promote to a standalone issue. Reversible
// (Set parent re-links it), non-destructive, and mirrors the clear-date
// actions — so it applies directly instead of a confirm modal. `stage`
// only orders sub-issues under a parent, so clear it in the same write to
// avoid an orphaned value on a standalone issue. The success toast fires
// from onSuccess, not eagerly after mutate() — otherwise a request that
// fails on permission/network/validation would flash "removed" before the
// error toast and the optimistic rollback (false confirmation).
const removeParent = useCallback(() => {
if (!issueId) return;
updateIssue.mutate(
{ id: issueId, parent_issue_id: null, stage: null },
{
onSuccess: () =>
toast.success(t(($) => $.actions.remove_parent_issue_success)),
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.detail.update_failed),
),
},
);
}, [issueId, updateIssue, t]);
const openAddChild = useCallback(() => {
if (!issueId) return;
openModal("issue-add-child", { issueId });
@@ -157,6 +183,7 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
copyLink,
openCreateSubIssue,
openSetParent,
removeParent,
openAddChild,
openDeleteConfirm,
};

View File

@@ -314,8 +314,10 @@ function useEditAttachmentState(
const { t } = useT("issues");
const { uploadWithToast } = useFileUpload(api);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const editorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const savingRef = useRef(false);
const [content, setContent] = useState(entry.content ?? "");
const [suppressedAgentIds, setSuppressedAgentIds] = useState<Set<string>>(() => new Set());
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
@@ -398,7 +400,7 @@ function useEditAttachmentState(
};
const saveEdit = async () => {
if (cancelledRef.current) return;
if (cancelledRef.current || savingRef.current) return;
const trimmed = editorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
@@ -417,6 +419,8 @@ function useEditAttachmentState(
const suppressAgentIds = triggerPreview.agents
.filter((agent) => suppressedAgentIds.has(agent.id))
.map((agent) => agent.id);
savingRef.current = true;
setSaving(true);
try {
await onEdit(
entry.id,
@@ -431,11 +435,15 @@ function useEditAttachmentState(
? err.message
: t(($) => $.comment.update_failed),
);
} finally {
savingRef.current = false;
setSaving(false);
}
};
return {
editing,
saving,
editorRef,
editorAttachments,
handleUpload,
@@ -645,8 +653,11 @@ function CommentRow({
multiple
onSelect={(file) => edit.editorRef.current?.uploadFile(file)}
/>
<Button size="sm" variant="ghost" onClick={edit.cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={edit.saveEdit}>{t(($) => $.comment.save_action)}</Button>
<Button size="sm" variant="ghost" onClick={edit.cancelEdit} disabled={edit.saving}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={edit.saveEdit} disabled={edit.saving}>
{edit.saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{t(($) => $.comment.save_action)}
</Button>
</div>
</div>
{edit.isDragOver && <FileDropOverlay />}
@@ -932,8 +943,11 @@ function CommentCardImpl({
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={edit.cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={edit.saveEdit}>{t(($) => $.comment.save_action)}</Button>
<Button size="sm" variant="ghost" onClick={edit.cancelEdit} disabled={edit.saving}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={edit.saveEdit} disabled={edit.saving}>
{edit.saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{t(($) => $.comment.save_action)}
</Button>
</div>
</div>
{edit.isDragOver && <FileDropOverlay />}

View File

@@ -21,6 +21,7 @@ import {
PinOff,
Plus,
Tag,
Unlink,
Users,
} from "lucide-react";
import { BreadcrumbHeader, type BreadcrumbSegment } from "../../layout/breadcrumb-header";
@@ -1573,14 +1574,25 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${parentIssueOpen ? "rotate-90" : ""}`} />
</button>
{parentIssueOpen && <div className="pl-2">
<AppLink
href={paths.issueDetail(parentIssue.id)}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 -mx-2 text-xs hover:bg-accent/50 transition-colors group"
>
<StatusIcon status={parentIssue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground shrink-0">{parentIssue.identifier}</span>
<span className="truncate group-hover:text-foreground">{parentIssue.title}</span>
</AppLink>
<div className="flex items-center gap-0.5 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors group">
<AppLink
href={paths.issueDetail(parentIssue.id)}
className="flex flex-1 min-w-0 items-center gap-1.5 py-1.5 text-xs"
>
<StatusIcon status={parentIssue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground shrink-0">{parentIssue.identifier}</span>
<span className="truncate group-hover:text-foreground">{parentIssue.title}</span>
</AppLink>
<button
type="button"
title={t(($) => $.actions.remove_parent_issue)}
aria-label={t(($) => $.actions.remove_parent_issue)}
onClick={() => actions.removeParent()}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
>
<Unlink className="h-3.5 w-3.5" />
</button>
</div>
</div>}
</div>
)}

View File

@@ -3,10 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@multica/core/api";
import { AppSidebar } from "./app-sidebar";
const { detail, deletePin, navigation, pins } = vi.hoisted(() => ({
const { detail, deletePin, navigation, pins, summary, workspaces } = vi.hoisted(() => ({
detail: { current: { isPending: false, isError: false, data: null as unknown, error: null as unknown } },
deletePin: vi.fn(),
navigation: { current: { pathname: "/acme/issues" } },
summary: { current: [] as { workspace_id: string; count: number }[] },
workspaces: {
current: [] as { id: string; name: string; slug: string; avatar_url: string | null }[],
},
pins: {
current: [
{
@@ -62,7 +66,7 @@ vi.mock("@multica/ui/components/ui/sidebar", () => ({
}));
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: () => null,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@@ -122,7 +126,17 @@ vi.mock("@multica/core/api", async (importOriginal) => {
},
};
});
vi.mock("@multica/core/inbox/queries", () => ({ deduplicateInboxItems: (items: unknown[]) => items, inboxKeys: { list: () => ["inbox"] } }));
vi.mock("@multica/core/inbox/queries", () => ({
deduplicateInboxItems: (items: unknown[]) => items,
inboxKeys: { list: () => ["inbox"], unreadSummary: () => ["inbox", "unread-summary"] },
inboxUnreadSummaryOptions: () => ({ queryKey: ["inbox", "unread-summary"] }),
hasOtherWorkspaceUnread: (
entries: { workspace_id: string; count: number }[],
currentWsId: string | null,
) => entries.some((s) => s.workspace_id !== currentWsId && s.count > 0),
unreadWorkspaceIds: (entries: { workspace_id: string; count: number }[]) =>
new Set(entries.filter((s) => s.count > 0).map((s) => s.workspace_id)),
}));
vi.mock("@multica/core/issues/queries", () => ({ issueDetailOptions: () => ({ queryKey: ["issue"] }) }));
vi.mock("@multica/core/issues/stores/create-mode-store", () => ({
useCreateModeStore: { getState: () => ({ lastMode: "agent" }) },
@@ -145,6 +159,8 @@ vi.mock("@tanstack/react-query", async (importOriginal) => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
if (queryKey[0] === "pins") return { data: pins.current };
if (queryKey[0] === "issue") return detail.current;
if (queryKey[0] === "inbox" && queryKey[1] === "unread-summary") return { data: summary.current };
if (queryKey[0] === "workspaces") return { data: workspaces.current };
return { data: [] };
},
useQueryClient: () => ({ fetchQuery: vi.fn(), invalidateQueries: vi.fn() }),
@@ -155,6 +171,8 @@ describe("PinRow", () => {
deletePin.mockReset();
navigation.current.pathname = "/acme/issues";
detail.current = { isPending: false, isError: false, data: null, error: null };
summary.current = [];
workspaces.current = [];
});
it("unpins missing details", async () => {
@@ -194,3 +212,70 @@ describe("PinRow", () => {
expect(container.querySelector('button[data-href="/acme/issues"]')).not.toHaveAttribute("data-active");
});
});
describe("workspace-switcher unread dot", () => {
beforeEach(() => {
summary.current = [];
workspaces.current = [];
});
// The aggregate switcher dot is the only `.ring-sidebar` span in the tree
// (DraftDot is null when there's no draft, and there are no invitations).
const dot = (container: HTMLElement) => container.querySelector("span.bg-brand.ring-sidebar");
it("shows a dot when another workspace has unread inbox items", () => {
summary.current = [{ workspace_id: "ws-2", count: 3 }];
const { container } = render(<AppSidebar />);
expect(dot(container)).not.toBeNull();
});
it("does not show a dot when only the active workspace has unread", () => {
// Active workspace is ws-1 (see useCurrentWorkspace mock).
summary.current = [{ workspace_id: "ws-1", count: 3 }];
const { container } = render(<AppSidebar />);
expect(dot(container)).toBeNull();
});
it("does not show a dot when no workspace has unread", () => {
summary.current = [];
const { container } = render(<AppSidebar />);
expect(dot(container)).toBeNull();
});
});
describe("workspace-switcher dropdown per-workspace dot", () => {
beforeEach(() => {
summary.current = [];
// Active workspace is ws-1 (see useCurrentWorkspace mock); "Other" is ws-2.
workspaces.current = [
{ id: "ws-1", name: "Active WS", slug: "active", avatar_url: null },
{ id: "ws-2", name: "Other WS", slug: "other", avatar_url: null },
];
});
// Row dots are brand dots WITHOUT the aggregate avatar dot's `ring-sidebar`.
const rowDots = (container: HTMLElement) =>
container.querySelectorAll("span.bg-brand:not(.ring-sidebar)");
it("dots the specific other workspace that has unread", () => {
summary.current = [{ workspace_id: "ws-2", count: 3 }];
const { container } = render(<AppSidebar />);
// Exactly one row dot, sitting right after the "Other WS" name; the active
// row shows the check, not a dot.
expect(rowDots(container)).toHaveLength(1);
expect(screen.getByText("Other WS").nextElementSibling?.className).toContain("bg-brand");
expect(screen.getByText("Active WS").nextElementSibling?.className ?? "").not.toContain("bg-brand");
});
it("does not dot a workspace whose unread count is zero", () => {
summary.current = [{ workspace_id: "ws-2", count: 0 }];
const { container } = render(<AppSidebar />);
expect(rowDots(container)).toHaveLength(0);
});
it("never dots the active workspace even when it has unread", () => {
summary.current = [{ workspace_id: "ws-1", count: 5 }];
const { container } = render(<AppSidebar />);
expect(rowDots(container)).toHaveLength(0);
});
});

View File

@@ -70,7 +70,7 @@ import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/pat
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { inboxKeys, deduplicateInboxItems, inboxUnreadSummaryOptions, hasOtherWorkspaceUnread, unreadWorkspaceIds } from "@multica/core/inbox/queries";
import { api, ApiError } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useConfigStore } from "@multica/core/config";
@@ -101,6 +101,7 @@ const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
const EMPTY_INBOX_SUMMARY: Awaited<ReturnType<typeof api.getInboxUnreadSummary>> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
@@ -364,6 +365,20 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
// Cross-workspace unread summary backs the workspace-switcher dot. One
// shared cache entry across workspaces; gated on an active workspace since
// the endpoint resolves through the workspace-member middleware.
const { data: unreadSummary = EMPTY_INBOX_SUMMARY } = useQuery({
...inboxUnreadSummaryOptions(),
enabled: !!wsId,
});
const otherWorkspaceUnread = React.useMemo(
() => hasOtherWorkspaceUnread(unreadSummary, wsId),
[unreadSummary, wsId],
);
// Which workspaces have unread, so the switcher dropdown can point at the
// specific one(s) rather than just the aggregate avatar dot.
const unreadWsIds = React.useMemo(() => unreadWorkspaceIds(unreadSummary), [unreadSummary]);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = EMPTY_PINS } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
@@ -486,7 +501,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenuButton>
<span className="relative">
<WorkspaceAvatar name={workspace?.name ?? "M"} avatarUrl={workspace?.avatar_url} size="sm" />
{myInvitations.length > 0 && (
{/* Shared brand dot: a pending invitation OR another
workspace with unread inbox items. The active
workspace's own unread stays on the Inbox nav count
(below), so it is deliberately excluded here. */}
{(myInvitations.length > 0 || otherWorkspaceUnread) && (
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-brand ring-1 ring-sidebar" />
)}
</span>
@@ -533,6 +552,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
>
<WorkspaceAvatar name={ws.name} avatarUrl={ws.avatar_url} size="sm" />
<span className="flex-1 truncate">{ws.name}</span>
{/* Points at the specific workspace holding unread
inbox items. Sits 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. */}
{ws.id !== workspace?.id && unreadWsIds.has(ws.id) && (
<span className="size-2 rounded-full bg-brand" />
)}
{ws.id === workspace?.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}

View File

@@ -7,6 +7,7 @@
"highlight": "Highlight",
"link": "Link",
"list": "List",
"task_list": "Task list",
"quote": "Quote",
"url_aria_label": "URL",
"heading_dropdown": {

View File

@@ -432,6 +432,8 @@
"more": "More",
"create_sub_issue": "Create sub-issue",
"set_parent_issue": "Set parent issue...",
"remove_parent_issue": "Remove parent issue",
"remove_parent_issue_success": "Removed parent issue",
"add_sub_issue": "Add sub-issue...",
"delete_issue": "Delete issue"
},

View File

@@ -300,6 +300,38 @@
"install_error_forbidden": "You no longer have permission to install Lark Bots in this workspace. Ask a workspace admin to continue.",
"install_error_generic": "Install failed. Try again."
},
"composio": {
"section_title": "Composio",
"page_description": "Browse the full Composio toolkit catalog and connect the apps your agents can act on. Only toolkits with a configured auth config can be connected right now.",
"not_enabled_title": "Composio integration not enabled",
"not_enabled_description_prefix": "Set",
"not_enabled_description_suffix": "on the server to enable Composio toolkit connections.",
"loading": "Loading toolkits…",
"load_failed": "Failed to load Composio toolkits.",
"empty_title": "No toolkits available",
"empty_description": "Composio returned no toolkits. Check the API key and project configuration.",
"search_placeholder": "Search toolkits…",
"connect": "Connect",
"connecting": "Connecting…",
"connected": "Connected",
"disconnect": "Disconnect",
"disconnecting": "Disconnecting…",
"not_connectable": "Not configured",
"not_connectable_hint": "This toolkit has no auth config in your Composio project yet, so it can't be connected. Add an auth config for it in the Composio dashboard to enable connecting.",
"connect_failed": "Couldn't start the connection. Please try again.",
"disconnect_failed": "Couldn't disconnect. Please try again.",
"toast_disconnected": "Disconnected",
"disconnect_confirm_title": "Disconnect this app?",
"disconnect_confirm_description": "Your connected account will be revoked at Composio and your agents will lose access to this toolkit. You can reconnect later.",
"disconnect_confirm_cancel": "Cancel",
"connections_load_failed": "Couldn't load your existing connections, so connected status may be incomplete.",
"toast_connected": "Connected",
"toast_connect_failed": "Couldn't complete the connection. Please try again.",
"last_used": "Last used {{when}}",
"last_used_never": "Never used",
"expired": "Token expired",
"reconnect": "Reconnect"
},
"repositories": {
"section_title": "Repositories",
"description": "Git repositories associated with this workspace. Agents use these to clone and work on code.",

View File

@@ -7,6 +7,7 @@
"highlight": "ハイライト",
"link": "リンク",
"list": "リスト",
"task_list": "タスクリスト",
"quote": "引用",
"url_aria_label": "URL",
"heading_dropdown": {

View File

@@ -413,6 +413,8 @@
"more": "その他",
"create_sub_issue": "サブイシューを作成",
"set_parent_issue": "親イシューを設定...",
"remove_parent_issue": "親イシューを解除",
"remove_parent_issue_success": "親イシューを解除しました",
"add_sub_issue": "サブイシューを追加...",
"delete_issue": "イシューを削除"
},

View File

@@ -300,6 +300,38 @@
"install_error_forbidden": "このワークスペースに Lark ボットを設置する権限がなくなりました。ワークスペース管理者にお問い合わせください。",
"install_error_generic": "設置に失敗しました。もう一度お試しください。"
},
"composio": {
"section_title": "Composio",
"page_description": "Browse the full Composio toolkit catalog and connect the apps your agents can act on. Only toolkits with a configured auth config can be connected right now.",
"not_enabled_title": "Composio integration not enabled",
"not_enabled_description_prefix": "Set",
"not_enabled_description_suffix": "on the server to enable Composio toolkit connections.",
"loading": "Loading toolkits…",
"load_failed": "Failed to load Composio toolkits.",
"empty_title": "No toolkits available",
"empty_description": "Composio returned no toolkits. Check the API key and project configuration.",
"search_placeholder": "Search toolkits…",
"connect": "Connect",
"connecting": "Connecting…",
"connected": "Connected",
"disconnect": "Disconnect",
"disconnecting": "Disconnecting…",
"not_connectable": "Not configured",
"not_connectable_hint": "This toolkit has no auth config in your Composio project yet, so it can't be connected. Add an auth config for it in the Composio dashboard to enable connecting.",
"connect_failed": "Couldn't start the connection. Please try again.",
"disconnect_failed": "Couldn't disconnect. Please try again.",
"toast_disconnected": "Disconnected",
"disconnect_confirm_title": "Disconnect this app?",
"disconnect_confirm_description": "Your connected account will be revoked at Composio and your agents will lose access to this toolkit. You can reconnect later.",
"disconnect_confirm_cancel": "Cancel",
"connections_load_failed": "Couldn't load your existing connections, so connected status may be incomplete.",
"toast_connected": "接続しました",
"toast_connect_failed": "接続を完了できませんでした。もう一度お試しください。",
"last_used": "最終使用 {{when}}",
"last_used_never": "未使用",
"expired": "トークンの有効期限切れ",
"reconnect": "再接続"
},
"repositories": {
"section_title": "リポジトリ",
"description": "このワークスペースに関連付けられた Git リポジトリです。エージェントはこれらをクローンしてコードを作業します。",

View File

@@ -7,6 +7,7 @@
"highlight": "강조",
"link": "링크",
"list": "목록",
"task_list": "체크리스트",
"quote": "인용",
"url_aria_label": "URL",
"heading_dropdown": {

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