Compare commits

..

5 Commits

Author SHA1 Message Date
Lambda
007c98af03 fix autopilot subscriber template transaction
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:26:46 +08:00
Lambda
803067316b fix(autopilot): align subscriber PR with current main
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 03:21:31 +08:00
Lambda
e0c3ed0695 fix(autopilot): notify template subscribers on issue creation (MUL-2533)
The autopilot create-issue path fans out template subscribers into
issue_subscriber inside the same tx as the issue insert, but the
issue:created notification listener only matches handler.IssueResponse
payloads and only direct-notifies the assignee + @mentions. The autopilot
publishes a map[string]any payload, so the listener falls through and the
template subscribers never receive an inbox item for the creation event —
breaking OQ3 ("reason='autopilot' subscribers receive all subscription
events, consistent with reason='manual'").

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

Adds a new InboxItemType 'issue_subscribed' (en/zh labels) and two
covering tests in autopilot_subscriber_test.go: one asserts the inbox
row lands for a template subscriber on dispatch, the other asserts the
no-subscriber autopilot stays silent.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 03:12:06 +08:00
Lambda
a023cc51e4 feat(autopilot): default subscriber template (MUL-2533) — frontend
New SubscriberMultiSelect picker (members-only search + chips) wired
into the create / edit AutopilotDialog. The detail page renders the
saved template as read-only chips; edits flow through the dialog.

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

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 03:12:06 +08:00
Lambda
21e11ee511 feat(autopilot): default subscriber template (MUL-2533) — server
Add per-autopilot member subscriber template that fans out to every
issue the autopilot spawns. New autopilot_subscriber table; extend
issue_subscriber.reason with 'autopilot' so the dispatch-time fanout
is distinguishable from manual subscriptions.

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

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

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 03:11:22 +08:00
621 changed files with 11525 additions and 52851 deletions

View File

@@ -71,28 +71,6 @@ 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
@@ -255,10 +233,6 @@ 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,99 +11,28 @@ concurrency:
cancel-in-progress: true
jobs:
# 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
@@ -113,9 +42,8 @@ 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/**) so it doesn't add
# to apps/mobile/** + packages/core/types/**) 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.
@@ -170,7 +98,7 @@ jobs:
run: cd server && go run ./cmd/migrate up
- name: Test
run: cd server && go test -race ./...
run: cd server && go test ./...
installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy

View File

@@ -52,7 +52,7 @@ jobs:
cache-dependency-path: server/go.sum
- name: Run tests
run: cd server && go test -race ./...
run: cd server && go test ./...
release:
needs: verify

View File

@@ -3,10 +3,8 @@
This file provides guidance to AI agents when working with code in this repository.
> **Single source of truth:** This file is a concise pointer document.
> All authoritative architecture, coding rules, and conventions
> All authoritative architecture, coding rules, commands, and conventions
> live in **CLAUDE.md** at the project root. Read that file first.
> Use `Makefile`, `package.json`, and `pnpm-workspace.yaml` as the
> source of truth for the full command list.
## Quick Reference
@@ -14,27 +12,27 @@ This file provides guidance to AI agents when working with code in this reposito
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
- `server/` - Go backend (Chi router, sqlc, gorilla/websocket)
- `apps/web/` - Next.js frontend (App Router)
- `apps/desktop/` - Electron desktop app
- `packages/core/` - Headless business logic (Zustand stores, React Query hooks, API client)
- `packages/ui/` - Atomic UI components (shadcn/Base UI, zero business logic)
- `packages/views/` - Shared business pages/components
- `packages/tsconfig/` - Shared TypeScript config
- `server/` Go backend (Chi router, sqlc, gorilla/websocket)
- `apps/web/` Next.js frontend (App Router)
- `apps/desktop/` Electron desktop app
- `packages/core/` Headless business logic (Zustand stores, React Query hooks, API client)
- `packages/ui/` Atomic UI components (shadcn/Base UI, zero business logic)
- `packages/views/` Shared business pages/components
- `packages/tsconfig/` Shared TypeScript config
### State Management (critical)
- **React Query** owns all server state (issues, members, agents, inbox, workspace list)
- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals)
- All Zustand stores live in `packages/core/` - never in `packages/views/` or app directories
- WS events invalidate React Query - never write directly to stores
- All Zustand stores live in `packages/core/` never in `packages/views/` or app directories
- WS events invalidate React Query never write directly to stores
### Package Boundaries (hard rules)
- `packages/core/` - zero react-dom, zero localStorage, zero process.env
- `packages/ui/` - zero `@multica/core` imports
- `packages/views/` - zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
- `apps/web/platform/` - only place for Next.js APIs
- `packages/core/` zero react-dom, zero localStorage, zero process.env
- `packages/ui/` zero `@multica/core` imports
- `packages/views/` zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
- `apps/web/platform/` only place for Next.js APIs
### Commands
@@ -46,4 +44,4 @@ make test # Go tests
make check # Full verification pipeline
```
See CLAUDE.md for the authoritative rules and common commands.
See CLAUDE.md for the complete command reference.

507
CLAUDE.md
View File

@@ -1,226 +1,427 @@
# CLAUDE.md
Guidance for Claude Code when working in this repository. Keep this file short and authoritative: rules here should be hard to infer from code or easy to get wrong.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Conventions
## Conventions reference
The source of truth for code naming, i18n glossary, and Chinese product voice is:
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
- `apps/docs/content/docs/developers/conventions.mdx`
- `apps/docs/content/docs/developers/conventions.zh.mdx`
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
Read it before editing translations in `packages/views/locales/`, naming routes/packages/files/DB columns/types, or writing Chinese UI/docs copy. Do not rely on `packages/views/locales/glossary.md`; it is only a redirect stub.
Read that page before:
## Project Shape
- Writing or editing translations (`packages/views/locales/`)
- Naming a new route, package, file, DB column, or TS type
- Writing Chinese product copy (UI strings, error messages, docs)
Multica is an AI-native task management platform for small teams, with agents as first-class assignees that can own issues, comment, and change status.
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
- `server/`: Go backend, Chi router, sqlc, gorilla/websocket.
- `apps/web/`: Next.js App Router.
- `apps/desktop/`: Electron desktop app.
- `apps/mobile/`: Expo / React Native iOS app. Read `apps/mobile/CLAUDE.md` before touching it.
- `packages/core/`: headless business logic, API client, React Query hooks, Zustand stores.
- `packages/ui/`: atomic UI components only.
- `packages/views/`: shared business pages/components for web and desktop.
- `packages/tsconfig/`: shared TypeScript config.
## Project Context
Shared packages export raw `.ts` / `.tsx` and are compiled by consuming apps. Dependency direction is `views -> core + ui`; `core` and `ui` must stay independent.
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
## State Rules
- Agents can be assigned issues, create issues, comment, and change status
- Supports local (daemon) and cloud agent runtimes
- Built for 2-10 person AI-native teams
Keep server state and client state separate.
## Architecture
- TanStack Query owns server state: issues, users, workspaces, inbox, agents, members, and anything fetched from the API.
- Zustand owns client state: selected workspace, filters, drafts, modals, tab layout, and navigation history.
- Shared Zustand stores live in `packages/core/`, never in `packages/views/` or app directories.
- React Context is for platform plumbing only, such as `WorkspaceIdProvider` and `NavigationProvider`.
- Only auth/workspace stores may call `api.*` directly. Other server interaction belongs in queries/mutations.
- Workspace-scoped query keys must include `wsId`.
- Mutations should be optimistic by default: patch locally, send request, roll back on failure, invalidate on settle.
- WebSocket events invalidate or patch Query cache; they never write directly to Zustand stores.
- Persist durable preferences/drafts/layout. Do not persist server data or ephemeral UI state.
- Zustand selectors must return stable references. Do not return freshly allocated objects/arrays from selectors without shallow comparison.
- Hooks that need workspace context should accept `wsId`; do not call `useWorkspaceId()` internally unless the hook is guaranteed to run under the provider.
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
## Package Boundaries
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js frontend (App Router)
- `apps/desktop/` — Electron desktop app (electron-vite)
- `apps/mobile/` — Expo / React Native iOS app. See `apps/mobile/CLAUDE.md`.
- `packages/core/` — Headless business logic (zero react-dom)
- `packages/ui/` — Atomic UI components (zero business logic)
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
- `packages/tsconfig/` — Shared TypeScript configuration
These are hard constraints:
What lives where for sharing purposes is documented in *Sharing Principles* below — read it once.
- `packages/core/`: no `react-dom`, `localStorage` (use `StorageAdapter`), `process.env`, or UI libraries.
- `packages/ui/`: no `@multica/core` imports and no business logic.
- `packages/views/`: no `next/*`, no `react-router-dom`, no stores. Use `NavigationAdapter`, `useNavigation()`, and `<AppLink>`.
- `apps/web/platform/`: only place for Next.js navigation/platform APIs.
- `apps/desktop/src/renderer/src/platform/`: only place for `react-router-dom` navigation wiring.
- Every workspace under `apps/` and `packages/` must declare directly imported external packages in its own `package.json`.
- Shared dependencies use `catalog:` from `pnpm-workspace.yaml`; `apps/mobile/` pins Expo/React Native related versions directly.
### Key Architectural Decisions
## Sharing Rules
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
Web and desktop share business logic, hooks, stores, components, and views through `packages/core/`, `packages/ui/`, and `packages/views/`.
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*`, `react-router-dom`, or app-specific code.
If the same logic exists in both web and desktop, extract it unless it depends on platform APIs:
**Platform bridge:** `packages/core/platform/` provides `CoreProvider` — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider>` and provides its own `NavigationAdapter` for routing.
1. Next.js, Electron, or router APIs stay in the app/platform layer.
2. Headless logic belongs in `packages/core/`.
3. Shared UI or business views belong in `packages/views/`.
4. Shared primitives belong in `packages/ui/`.
**pnpm catalog**`pnpm-workspace.yaml` defines `catalog:` for version pinning. All shared deps use `catalog:` references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
Mobile is independent. It may import types and pure functions from `@multica/core`, with `import type` for types, but owns its UI, state, hooks, providers, i18n, React version, build pipeline, and release cadence.
### State Management
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so they're shared.
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
**Hard rules — these are how the architecture stays coherent:**
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
- **Workspace-scoped queries must key on `wsId`.** This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed.
- **Mutations are optimistic by default.** Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
- **Persist what's worth preserving across restarts** (user preferences, drafts, tab layout). **Don't persist ephemeral UI state** (modal open/close, transient selections) or server data.
**Common Zustand footguns to avoid:**
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
## Sharing Principles
The monorepo splits into two share zones:
- **Web and desktop** share business logic, components, hooks, stores, and views through `packages/core/`, `packages/ui/`, and `packages/views/`. Existing model — keep using it.
- **Mobile (`apps/mobile/`) is independent.** It shares only **types and pure functions** from `@multica/core/`, with `import type` for types (zero runtime coupling). UI, state, hooks, providers, i18n, React version, build pipeline, release cadence — all mobile-owned.
Mobile is locked to the React version that Expo SDK / React Native ships (which lags React main by 6-12 months). Coupling mobile to the root `catalog:` React would block mobile from upgrading on its own schedule.
See `apps/mobile/CLAUDE.md` for the mobile rules and tech-stack baseline.
## Commands
Use the repo scripts as the source of truth. Common commands:
```bash
make dev # auto-setup and start the app
make start # start backend + frontend
make stop # stop app processes for this checkout
make server # run Go server only
make daemon # run local daemon
make test # Go tests
make sqlc # regenerate sqlc code after SQL changes
# One-command dev (auto-setup + start everything)
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
# Explicit setup & run (if you prefer separate steps)
make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend (all commands go through Turborepo)
pnpm install
pnpm dev:web
pnpm dev:desktop
pnpm build
pnpm typecheck
pnpm lint
pnpm test # TS/Vitest tests through Turborepo
pnpm exec playwright test
pnpm ui:add badge # shadcn/Base UI component into packages/ui
pnpm dev:web # Next.js dev server (port 3000)
pnpm dev:desktop # Electron dev (electron-vite, HMR)
pnpm build # Build all frontend apps
pnpm typecheck # TypeScript check (all packages + apps via turbo)
pnpm lint # ESLint
pnpm test # TS tests (Vitest, all packages + apps via turbo)
# Backend (Go)
make server # Run Go server only (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries to server/bin/
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
make test # Go tests
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single TS test (works for any package with a test script)
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Mobile (Expo) — two environments only: dev and staging
pnpm dev:mobile # Metro, dev env (reads apps/mobile/.env.development.local)
pnpm dev:mobile:staging # Metro, staging env (reads apps/mobile/.env.staging)
pnpm ios:mobile # Native build + install dev-client to iOS Simulator, dev env
pnpm ios:mobile:staging # Native build + install dev-client to iOS Simulator, staging env
pnpm ios:mobile:device # Native build + install dev-client to USB iPhone, dev env
pnpm ios:mobile:device:staging # Native build + install dev-client to USB iPhone, staging env
# Daily flow: run `pnpm dev:mobile:staging` (or :dev). Only re-run `ios:mobile*` when
# native code or any expo-*/react-native-* dependency changes (lockfile drift counts).
# Desktop build & package
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
pnpm ui:add badge # Adds component to packages/ui/components/ui/
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
```
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`.
### CI Requirements
CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
`make dev` auto-detects worktrees and handles everything. For explicit control:
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
make start-worktree # Start using .env.worktree
```
## Coding Rules
- TypeScript strict mode is enabled; keep types explicit.
- Go follows standard conventions: `gofmt`, `go vet`, checked errors.
- Code comments must be English.
- Prefer existing patterns/components over new parallel abstractions.
- Go code follows standard Go conventions (gofmt, go vet).
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Avoid broad refactors unless required by the task.
- For internal, non-boundary code, do not add compatibility layers, fallback paths, dual writes, legacy adapters, or temporary shims unless explicitly requested.
- API boundaries are different: installed desktop clients can talk to newer backends, so response parsing must follow the API compatibility rules below.
- If a flow or API is being replaced and the product is not live, prefer removing the old path instead of preserving both.
- New global pre-workspace routes must be a single word (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Do not add hyphenated root routes like `/new-workspace`.
- Reserved slugs live in `server/internal/handler/reserved_slugs.json`. Edit it, run `pnpm generate:reserved-slugs`, and commit the generated `packages/core/paths/reserved-slugs.ts`.
- When changing CLI commands/flags, API fields, or product behavior documented by built-in skills under `server/internal/service/builtin_skills/*`, update the relevant `SKILL.md` and `references/*-source-map.md` in the same PR.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
- When you change a CLI command or flag, an API request/response field, or product behavior that a built-in skill documents (`server/internal/service/builtin_skills/*`), update that skill's `SKILL.md` **and** its `references/*-source-map.md` in the same PR. The built-in skills are source-traced contracts shipped to agents — if the code moves and the skill doesn't, it silently teaches stale behavior.
## API Compatibility
### API Response Compatibility
Frontend code must survive backend response drift, especially in installed desktop builds.
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
- Parse API JSON with `parseWithFallback` in `packages/core/api/schema.ts` and a zod schema. Do not cast network JSON to `T`.
- Endpoint responses consumed by UI logic must pass through a schema before returning.
- Downstream UI should optional-chain and default fields defensively.
- Prefer explicit boolean checks (`=== true`) over truthy/falsy checks on server fields.
- Do not pin critical affordances to one backend boolean; combine signals when possible.
- Server-driven enum switches need a `default` branch.
- When adding or changing an endpoint, add/update the schema and include a malformed-response test.
When writing code that consumes an API response, follow these rules:
## Backend UUID Rules
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
In `server/internal/handler/`, always know where a UUID came from before using it in write queries.
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
- Resource path params that may be UUIDs or human-readable IDs must be resolved through loaders such as `loadIssueForUser`, `loadSkillForUser`, `loadAgentForUser`, or `requireDaemonRuntimeAccess`; subsequent writes use the resolved `entity.ID`.
- Pure UUID inputs from request boundaries use `parseUUIDOrBadRequest(w, s, fieldName)` and return immediately on `ok=false`.
- Trusted UUID round-trips from sqlc results or test fixtures use `parseUUID(s)`, which panics on invalid input.
- Outside handlers, `util.ParseUUID(s) (pgtype.UUID, error)` is the safe variant; always check the error.
### Backend Handler UUID Parsing Convention
## Web/Desktop Features
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
When adding a shared page or feature for web and desktop:
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
1. Put the page/component in `packages/views/<domain>/`.
2. Add platform wiring in both `apps/web/app/` and the desktop router, unless the desktop flow is a transition overlay.
3. Use `useNavigation().push()` or `<AppLink>` in shared code.
4. Use shared guards/providers such as `DashboardGuard` from `packages/views/layout/`.
5. Keep platform-only UI in the app or inject it through props/slots.
6. Hooks that need workspace context should accept `wsId`.
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
CSS for web/desktop is shared from `packages/ui/styles/`. Use semantic tokens such as `bg-background` and `text-muted-foreground`; avoid hardcoded Tailwind colors and duplicated base styles.
### Dependency Declaration Rule
## Desktop Rules
Every workspace (`apps/` and `packages/` directories) must explicitly declare all directly imported external packages in its own `package.json`. Relying on pnpm hoist to resolve undeclared imports (phantom deps) is prohibited — it causes production build failures when pnpm creates peer-dep variants.
Desktop routing has three categories:
- Use `"pkg": "catalog:"` to reference the shared version from `pnpm-workspace.yaml`.
- CI enforces this via `eslint-plugin-import-x/no-extraneous-dependencies`.
- Exception: `apps/mobile/` uses pinned versions (not `catalog:`) for packages tied to its own React/Expo version.
- Session routes: workspace-scoped tab destinations such as `/:slug/issues`.
- Transition flows: pre-workspace one-shot actions such as create workspace or accept invite. These are `WindowOverlay` state, not routes.
- Error/stale states: stale workspace tabs should auto-heal by dropping stale tab groups, not render desktop error pages.
### Package Boundary Rules
More desktop constraints:
These are hard constraints. Violating them breaks the cross-platform architecture:
- New pre-workspace desktop flows register a `WindowOverlay` type in `stores/window-overlay-store.ts`; do not add them to `routes.tsx`.
- `setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the active workspace source of truth.
- Code that leaves workspace context must call `setCurrentWorkspace(null, null)` explicitly.
- Leave/delete workspace flow order: read cached destination, clear current workspace, navigate, then run the mutation.
- Cross-workspace navigation must go through the navigation adapter so it can call `switchWorkspace(slug, targetPath)`.
- Full-window desktop views outside the dashboard shell must mount `<DragStrip />` from `@multica/views/platform` as the first flex child. Interactive controls in the top 48px need `WebkitAppRegion: "no-drag"`.
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **Shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
## Mobile Rules
### The No-Duplication Rule (web + desktop)
Read `apps/mobile/CLAUDE.md` before touching `apps/mobile/`. It contains the mandatory pre-flight process, import limits, parity rules, tech stack, UI rules, data helpers, realtime strategy, and mobile release flow.
**If the same logic exists in both web and desktop, it must be extracted to a shared package.**
Root-level reminders:
This applies to everything between web and desktop: components, hooks, guards, providers, utility functions. The decision process:
- Mobile shares only `@multica/core` types and pure functions.
- Mobile must match web/desktop product semantics: counts, permissions, enums/transitions, and data identity.
- Mobile may differ in UI/interaction when the phone context requires it.
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
3. Everything else → belongs in `packages/core/` (headless logic) or `packages/views/` (UI components).
## UI Rules
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
- Prefer shadcn/Base UI components over custom implementations. Add them with `pnpm ui:add <component>` from the repo root.
- Use design tokens and semantic classes; avoid hardcoded colors.
- Do not introduce extra local state unless the design requires it.
- Handle overflow, long text, scrolling, alignment, and spacing deliberately.
- If a component is identical between web and desktop, it belongs in a shared package.
### Cross-Platform Development Rules (web + desktop)
## Testing
When adding a new page or feature for web/desktop:
Tests follow the code:
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
| What is tested | Location |
| --- | --- |
| Shared business logic, stores, queries, hooks | `packages/core/*.test.ts` |
| Shared UI components, pages, forms, modals | `packages/views/*.test.tsx` |
| Platform wiring such as cookies, redirects, search params | `apps/web/*.test.tsx` or `apps/desktop/` |
| End-to-end flows | `e2e/*.spec.ts` |
| Backend | `server/` Go tests |
### CSS Architecture (web + desktop)
Rules:
Web and desktop share the same CSS foundation from `packages/ui/styles/`.
- Never test shared component behavior in an app test file.
- `packages/views/` tests must not mock `next/*` or `react-router-dom`.
- Mock `@multica/core` stores with the Zustand callable-store shape (`selectorFn` plus `getState`).
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Mobile-specific Rules
Rules for `apps/mobile/` live in `apps/mobile/CLAUDE.md`. Read it before touching anything in `apps/mobile/` — it covers what may be imported from `@multica/core/`, the React version policy, the build/release pipeline, and the locked tech-stack baseline.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
### Route categories
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace context
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
1. Read destination from cached workspace list.
2. `setCurrentWorkspace(null, null)`.
3. `navigation.push(destination)`.
4. THEN `await mutation.mutateAsync(workspaceId)`.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS)
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
- Use shadcn design tokens for styling. Avoid hardcoded color values.
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
## Testing Rules
### Where to write tests
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
| What you're testing | Where the test lives | Why |
|---|---|---|
| Shared business logic (stores, queries, hooks) | `packages/core/*.test.ts` | No DOM needed, pure logic |
| Shared UI components (pages, forms, modals) | `packages/views/*.test.tsx` | jsdom, no framework mocks |
| Platform-specific wiring (cookies, redirects, searchParams) | `apps/web/*.test.tsx` or `apps/desktop/` | Needs framework-specific mocks |
| End-to-end user flows | `e2e/*.spec.ts` | Real browser, real backend |
**Never test shared component behavior in an app's test file.** If a test requires mocking `next/navigation` or `react-router-dom` to test a component from `@multica/views`, the test is in the wrong place — move it to `packages/views/` and mock `@multica/core` instead.
### Test infrastructure
- `packages/core/` — Vitest, Node environment (no DOM)
- `packages/views/` — Vitest, jsdom environment, `@testing-library/react`
- `apps/web/` — Vitest, jsdom environment, framework-specific mocks
- `e2e/` — Playwright
- `server/` — Go standard `go test`
All test deps are in the pnpm catalog for unified versioning.
### Mocking conventions
- Mock `@multica/core` stores with `vi.hoisted()` + `Object.assign(selectorFn, { getState })` pattern (Zustand stores are both callable and have `.getState()`).
- Mock `@multica/core/api` for API calls.
- E2E tests should use `TestApiClient` for setup/teardown.
- Prefer writing the failing test in the correct package before implementation when the change is behavioral.
- In `packages/views/` tests: never mock `next/*` or `react-router-dom` — those don't exist here.
- In `apps/web/` tests: mock framework-specific APIs only for platform-specific behavior.
## Verification
### TDD workflow
For code changes, run the narrowest useful checks while iterating, then run broader verification when risk justifies it or when asked.
1. Write failing test in the **correct package** first.
2. Write implementation.
3. Run `pnpm test` (Turborepo discovers all packages).
4. Green → done.
Useful checks:
### Go tests
Standard `go test`. Tests should create their own fixture data in a test database.
### E2E tests
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi();
await loginAsDefault(page);
});
test.afterEach(async () => {
await api.cleanup();
});
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue");
await page.goto(`/issues/${issue.id}`);
});
```
## Commit Rules
- Use atomic commits grouped by logical intent.
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
## Minimum Pre-Push Checks
```bash
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
```
Run verification only when the user explicitly asks for it.
For targeted checks when requested:
```bash
pnpm typecheck # TypeScript type errors only
pnpm test # TS unit tests only (Vitest, all packages)
make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
## AI Agent Verification Loop
After writing or modifying code, always run the full verification pipeline:
```bash
pnpm typecheck
pnpm test
make test
pnpm exec playwright test
make check
```
Do not claim verification passed unless you ran it. If you skip checks because the change is docs-only or the user asked not to run them, say so.
**Workflow:**
- Write code to satisfy the requirement
- Run `make check`
- If any step fails, read the error output, fix the code, and re-run
- Repeat until all checks pass
- Only then consider the task complete
## Commits and Releases
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
- Commits should be atomic and use conventional prefixes: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
- A production deployment requires a CLI release tag on `main`: create `v0.x.x`, push it, and let `release.yml` publish binaries and the Homebrew tap.
- Bump patch by default unless the user specifies a version.
## CLI Release
## Domain Reminders
**Prerequisite:** A CLI release must accompany every Production deployment.
- All queries filter by `workspace_id`; membership gates access; `X-Workspace-ID` selects the workspace.
- Issue assignees are polymorphic: `assignee_type` plus `assignee_id` can reference a member or an agent.
1. Create a tag on the `main` branch: `git tag v0.x.x`
2. Push the tag: `git push origin v0.x.x`
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
By default, bump the patch version each release (e.g. `v0.1.12``v0.1.13`), unless the user specifies a specific version.
## Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
## Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).

View File

@@ -149,7 +149,6 @@ The daemon auto-detects these AI CLIs on your PATH:
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
| Kimi | `kimi` | Moonshot coding agent |
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
| [Qoder CLI](https://docs.qoder.com/) | `qodercli` | Qoder ACP coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -221,10 +220,6 @@ Agent-specific overrides:
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
| `MULTICA_QODER_PATH` | Custom path to the `qodercli` binary |
| `MULTICA_QODER_MODEL` | Override the Qoder model used |
The daemon launches Qoder as `qodercli --yolo --acp`, matching Qoders ACP “bypass permissions” mode so tool runs do not block on interactive approval in headless runs.
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
@@ -409,12 +404,12 @@ multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
# Most recently active threads (root + every descendant), grouped by
# thread. Returns N complete conversational arcs, oldest-active first so
# the freshest thread sits closest to "now" in an agent prompt.
multica issue comment list <issue-id> --recent 10
multica issue comment list <issue-id> --recent 20
# Scroll older threads. Under --recent, --before / --before-id are a
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
# `Next thread cursor: --before <ts> --before-id <root-id>`.
multica issue comment list <issue-id> --recent 10 \
multica issue comment list <issue-id> --recent 20 \
--before <ts> --before-id <root-id>
# Incremental polling. Combines with --thread or --recent; filters out
@@ -519,14 +514,8 @@ multica issue run-messages <task-id> --output json
# Incremental fetch (only messages after a given sequence number)
multica issue run-messages <task-id> --since 42 --output json
# Aggregated token usage for an issue (sum across all its task runs)
multica issue usage <issue-id>
multica issue usage <issue-id> --output json
```
The `usage` command returns the aggregated token usage for an issue, summed across all of its task runs: input tokens, output tokens, cache read/write tokens, and the run count (`task_count`). It wraps `GET /api/issues/<id>/usage` — the same figures the issue detail view shows. Use `--output json` to feed billing/cost tooling.
The `runs` command shows all past and current executions for an issue, including running tasks. Table output uses short task UUID prefixes by default; pass `--full-id` to print canonical task UUIDs. The `run-messages` command accepts full task UUIDs directly; copied short task prefixes must be scoped with `--issue <issue-id>` so the CLI only checks that issue's runs. It shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Projects
@@ -659,18 +648,14 @@ multica autopilot create \
--title "Nightly bug triage" \
--description "Scan todo issues and prioritize." \
--agent "Lambda" \
--mode create_issue \
--subscriber "Alice"
--mode create_issue
multica autopilot update <id> --status paused
multica autopilot update <id> --description "New prompt"
multica autopilot update <id> --subscriber "Alice" --subscriber "Bob"
multica autopilot update <id> --clear-subscribers
multica autopilot delete <id>
```
`--mode` accepts `create_issue` (creates a new issue on each run and assigns it to the agent) or `run_only` (enqueues a direct agent task without creating an issue). `--agent` accepts either a name or UUID.
`--subscriber` accepts a workspace member name or user ID and may be repeated; on update it replaces the autopilot's subscriber template. Subscribers receive inbox notifications for issues created by a `create_issue` autopilot. Use `--clear-subscribers` to remove all autopilot subscribers.
### Manual Trigger

View File

@@ -20,7 +20,6 @@ RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSIO
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_codex_usage_cache ./cmd/backfill_codex_usage_cache
# --- Runtime stage ---
FROM alpine:3.21
@@ -33,7 +32,6 @@ COPY --from=builder /src/server/bin/server .
COPY --from=builder /src/server/bin/multica .
COPY --from=builder /src/server/bin/migrate .
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
COPY --from=builder /src/server/bin/backfill_codex_usage_cache .
COPY server/migrations/ ./migrations/
COPY docker/entrypoint.sh .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh

View File

@@ -296,7 +296,7 @@ test: ## Run Go tests after ensuring the target DB exists and migrations are app
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test -race ./...
cd server && go test ./...
# Database
##@ Database
@@ -317,10 +317,5 @@ sqlc: ## Regenerate sqlc code
# Cleanup
##@ Cleanup
clean: ## Remove build caches, generated binaries, and temp files
clean: ## Remove generated server binaries and temp files
rm -rf server/bin server/tmp
rm -rf apps/*/.next apps/*/.source apps/*/.expo
rm -rf apps/*/out apps/*/dist apps/*/dist-electron packages/*/dist
rm -rf .turbo apps/*/.turbo packages/*/.turbo
rm -rf apps/*/*.tsbuildinfo packages/*/*.tsbuildinfo
@echo "✓ Clean complete."

View File

@@ -19,9 +19,8 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/W8gYBn226t)
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [Discord](https://discord.gg/W8gYBn226t) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**
@@ -31,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, **Kiro CLI**, and **Qoder CLI**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
@@ -115,7 +114,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`, `qodercli`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`) on your PATH.
### 2. Verify your runtime
@@ -125,7 +124,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, Antigravity, or Qoder CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Antigravity). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -166,7 +165,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
OpenCode, OpenClaw, Hermes, Gemini,
Pi, Cursor Agent, Kimi, Kiro CLI, Qoder CLI)
Pi, Cursor Agent, Kimi, Kiro CLI)
```
| Layer | Stack |
@@ -174,7 +173,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Qoder CLI |
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
## Development

View File

@@ -19,9 +19,8 @@
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/W8gYBn226t)
[官网](https://multica.ai) · [云服务](https://multica.ai) · [Discord](https://discord.gg/W8gYBn226t) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
@@ -31,7 +30,7 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi**、**Kiro CLI** 与 **Qoder CLI**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi****Kiro CLI**
面向更大的团队Squads小队提供稳定的路由层把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
@@ -116,7 +115,7 @@ multica setup # 连接 Multica Cloud登录启动 daemon
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``copilot``openclaw``opencode``hermes``gemini``pi``cursor-agent``kimi``kiro-cli``qodercli`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``copilot``openclaw``opencode``hermes``gemini``pi``cursor-agent``kimi``kiro-cli`)。
### 2. 确认运行时已连接
@@ -126,7 +125,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi、Kiro CLI 或 Qoder CLI并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -148,7 +147,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
│ Agent Daemon │ 运行在你的机器上
└──────────────┘ Claude Code、Codex、GitHub Copilot CLI、
OpenCode、OpenClaw、Hermes、Gemini、
Pi、Cursor Agent、Kimi、Kiro CLI、Qoder CLI
Pi、Cursor Agent、Kimi、Kiro CLI
```
| 层级 | 技术栈 |
@@ -156,7 +155,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi、Kiro CLI 或 Qoder CLI |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI |
## 开发

View File

@@ -101,7 +101,6 @@ You also need at least one AI agent CLI installed:
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
- Kimi (`kimi` on PATH)
- Kiro CLI (`kiro-cli` on PATH)
- Qoder CLI (`qodercli` on PATH)
### b) One-command setup

View File

@@ -49,7 +49,6 @@
"electron-updater": "^6.8.3",
"fix-path": "^5.0.0",
"lucide-react": "catalog:",
"motion": "^12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "^7.6.0",

View File

@@ -98,29 +98,16 @@ export function stripLeadingSeparator(argv) {
* - "v0.1.36" → "0.1.36"
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
* - "f1415e96" (no tag) → "0.0.0-gf1415e96" (fallback)
* - "2f24057b" (no tag, hash begins with a digit) → "0.0.0-g2f24057b"
* - "0123456" (no tag, all-digit hash w/ leading zero) → "0.0.0-g0123456"
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
*
* Leading `v` is stripped so the result is valid semver for package.json.
* The fallback matters because a bare commit hash is never valid semver —
* even one that happens to start with a digit (e.g. "2f24057b") — and
* electron-updater throws on launch if package.json carries such a version.
* The hash is prefixed with `g` so the pre-release identifier is always
* alphanumeric; a bare all-digit hash with a leading zero (e.g. "0123456")
* would otherwise form `0.0.0-0123456`, which is invalid semver.
*/
export function normalizeGitVersion(raw) {
if (!raw) return null;
const stripped = raw.replace(/^v/, "");
// A real version begins with major.minor.patch. The bare commit hash
// that `git describe --always` falls back to (no reachable tag) does not,
// so coerce it to a 0.0.0 prerelease rather than passing it through.
// Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
// shorthand) so a hash like "0123456" yields "0.0.0-g0123456" — a single
// alphanumeric identifier — instead of the invalid "0.0.0-0123456".
if (!/^\d+\.\d+\.\d+/.test(stripped)) {
return `0.0.0-g${stripped}`;
if (!/^\d/.test(stripped)) {
// No reachable tag — `git describe` fell back to just the commit hash.
return `0.0.0-${stripped}`;
}
return stripped;
}

View File

@@ -38,27 +38,11 @@ describe("normalizeGitVersion", () => {
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
});
it("falls back to 0.0.0-g<hash> when no tags are reachable", () => {
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
// `git describe --tags --always` returns just the short commit hash
// when there are no tags in the history at all. A hash that begins with
// a digit (e.g. "2f24057b") is still not valid semver and must fall
// through — otherwise electron-updater rejects it on launch. The `g`
// prefix mirrors git describe's own `g<hash>` shorthand and keeps the
// pre-release identifier a single alphanumeric token.
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-gf1415e96");
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-gabc1234");
expect(normalizeGitVersion("2f24057b")).toBe("0.0.0-g2f24057b");
});
it("prefixes an all-digit hash so the pre-release is valid semver", () => {
// A short hash that is all decimal digits with a leading zero would
// produce `0.0.0-0123456` — a numeric pre-release identifier must not
// have a leading zero, so that value is invalid semver and
// electron-updater would throw on the no-tag builds this fallback
// exists to protect. The `g` prefix makes it a single alphanumeric
// identifier, which is always valid.
expect(normalizeGitVersion("0123456")).toBe("0.0.0-g0123456");
expect(normalizeGitVersion("04567")).toBe("0.0.0-g04567");
// when there are no tags in the history at all.
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
});
});

View File

@@ -144,7 +144,7 @@ function createWindow(): void {
minWidth: 900,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 17 },
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option.

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
@@ -23,69 +22,41 @@ import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
const TOP_BAR_HEIGHT_CLASS = "h-12";
const WINDOW_TOOLBAR_CLEARANCE = 184;
const toolbarMotion = {
type: "spring",
stiffness: 420,
damping: 38,
mass: 0.8,
} as const;
function WindowToolbar() {
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
const navButtonClassName =
"flex size-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-30";
return (
<div
className={cn(
"fixed left-0 top-0 z-30 flex w-[184px] shrink-0 items-center px-3",
TOP_BAR_HEIGHT_CLASS,
)}
className="h-12 shrink-0 flex items-center justify-end px-2"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
className="flex items-center gap-1 pl-[70px]"
className="flex items-center gap-0.5"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<SidebarTrigger
className="size-7 text-muted-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
title="Go back"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
title="Go forward"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronRight className="size-4" />
</button>
</div>
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
);
}
function SidebarTopSpacer() {
return <div className={cn("shrink-0", TOP_BAR_HEIGHT_CLASS)} />;
}
function useNativeNavigationGestures() {
const { goBack, goForward } = useTabHistory();
@@ -101,31 +72,30 @@ function useNativeNavigationGestures() {
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width, leave room for the fixed window toolbar
// so tabs do not land beneath the traffic lights / navigation controls.
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
// left side so tabs don't land under the macOS traffic lights (which live at
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
// the sidebar can be brought back without keyboard shortcut.
function MainTopBar() {
const { state, isMobile } = useSidebar();
const sidebarHidden = state === "collapsed" || isMobile;
return (
<motion.header
animate={{ paddingLeft: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className={cn("relative shrink-0 flex items-center gap-2", TOP_BAR_HEIGHT_CLASS)}
initial={false}
transition={toolbarMotion}
<header
className={cn(
"h-12 shrink-0 flex items-center gap-2",
sidebarHidden && "pl-20",
)}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<motion.div
aria-hidden
animate={{ left: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className="absolute inset-y-0 right-0"
initial={false}
transition={toolbarMotion}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div className="relative z-10 flex h-full items-center">
<TabBar />
</div>
</motion.header>
{sidebarHidden && (
<SidebarTrigger
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
)}
<TabBar />
</header>
);
}
@@ -213,10 +183,9 @@ export function DesktopShell() {
<DesktopInboxBridge />
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <WindowToolbar />}
{slug && <AppSidebar topSlot={<SidebarTopSpacer />} searchSlot={<SearchTrigger />} />}
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<motion.div layout transition={toolbarMotion} className="flex flex-1 min-w-0 flex-col">
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
@@ -224,7 +193,7 @@ export function DesktopShell() {
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</motion.div>
</div>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}

View File

@@ -13,18 +13,4 @@ import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";
// react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click any
// element to copy its source path + line + component stack for pasting to an AI.
// Opt-in per developer: only loads when VITE_REACT_GRAB is set in a local,
// gitignored apps/desktop/.env.development.local — it never activates for anyone
// else, and the whole branch is tree-shaken out of production builds. The web app
// wires the same tool via next/script in apps/web/app/layout.tsx.
// See https://www.react-grab.com/
if (import.meta.env.DEV && import.meta.env.VITE_REACT_GRAB) {
const grab = document.createElement("script");
grab.src = "//unpkg.com/react-grab/dist/index.global.js";
grab.crossOrigin = "anonymous";
document.head.appendChild(grab);
}
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -54,23 +54,14 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
| `multica issue get <id>` | 単一のイシューを表示(イシューキーまたは UUID を受け取る) |
| `multica issue create --title "..."` | 新しいイシューを作成 |
| `multica issue update <id> ...` | イシューを更新(ステータス、優先度、担当者など) |
| `multica issue assign <id> --to <name>` | メンバー、エージェント、またはスクワッドに割り当て(エージェントへの割り当ては実行をトリガー) |
| `multica issue status <id> <status>` | ステータス変更のショートカット |
| `multica issue assign <id> --agent <slug>` | エージェントに割り当て(即座にタスクをトリガー) |
| `multica issue status <id> --set <status>` | ステータス変更のショートカット |
| `multica issue search <query>` | キーワード検索 |
| `multica issue children <id>` | サブイシューを stage ごとに一覧 |
| `multica issue pull-requests <id>` | 紐付いた pull request と状態を一覧 |
| `multica issue runs <id>` | イシュー上のエージェント実行を表示 |
| `multica issue run-messages <task-id>` | 1 回の実行メッセージを表示 |
| `multica issue usage <id>` | イシュー単位の集計 token 使用量を表示 |
| `multica issue rerun <id>` | イシューの現在のエージェント担当者向けに新しいタスクを再キューイング |
| `multica issue cancel-task <task-id>` | キュー中または実行中のタスクをキャンセル |
| `multica issue comment <id> ...` | ネスト: コメントの表示 / 投稿 |
| `multica issue comment resolve/unresolve <comment-id>` | コメントスレッドを解決済み / 未解決にする |
| `multica issue subscriber <id> ...` | ネスト: 購読 / 購読解除 |
| `multica issue metadata <id> ...` | ネスト: イシューメタデータの読み書き |
| `multica issue label <id> ...` | ネスト: イシューのラベルを管理 |
| `multica project list/get/create/update/delete/status` | プロジェクトの CRUD |
| `multica label list/create/update/delete` | ワークスペースラベルの CRUD |
## エージェントとスキル
@@ -83,8 +74,6 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
| `multica agent archive <slug>` | アーカイブ |
| `multica agent restore <slug>` | アーカイブ済みのエージェントを復元 |
| `multica agent tasks <slug>` | エージェントのタスク履歴を表示 |
| `multica agent avatar <slug>` | エージェントのアバターをアップロード |
| `multica agent env <slug> ...` | エージェントの custom environment variables を読み取り / 置換 |
| `multica agent skills ...` | ネスト: スキルのアタッチ / デタッチ |
| `multica skill list/get/create/update/delete` | スキルの CRUD |
| `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート |
@@ -128,10 +117,6 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica autopilot delete <id>` | 削除 |
| `multica autopilot runs <id>` | 実行履歴を表示 |
| `multica autopilot trigger <id>` | 手動で実行をトリガー |
| `multica autopilot trigger-add/update/delete <id>` | schedule または webhook trigger を管理 |
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | webhook trigger URL をローテート |
`multica autopilot create/update` は、`create_issue` モードの autopilot が作成するイシューの既定購読者を設定する `--subscriber` も受け取ります。`update` では `--clear-subscribers` で削除できます。
## デーモンとランタイム
@@ -145,9 +130,7 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica runtime list` | 現在のワークスペースのランタイムを一覧 |
| `multica runtime usage` | リソース使用量を表示 |
| `multica runtime activity` | 最近のアクティビティログ |
| `multica runtime update <id> ...` | ランタイム上で CLI 更新を開始 |
| `multica runtime delete <id> [--cascade]` | ランタイムを削除し、必要なら紐付くエージェントもアーカイブ |
| `multica runtime profile ...` | カスタム runtime profile とローカルパス上書きを管理 |
| `multica runtime update <id> ...` | ランタイムの構成を更新 |
## その他

View File

@@ -54,23 +54,14 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
| `multica issue get <id>` | 단일 이슈 표시(이슈 키 또는 UUID를 받음) |
| `multica issue create --title "..."` | 새 이슈 생성 |
| `multica issue update <id> ...` | 이슈 업데이트(상태, 우선순위, 담당자 등) |
| `multica issue assign <id> --to <name>` | 멤버, 에이전트, 또는 스쿼드에 할당(에이전트에 할당하면 실행이 트리거) |
| `multica issue status <id> <status>` | 상태 변경 단축 명령 |
| `multica issue assign <id> --agent <slug>` | 에이전트에게 할당(즉시 작업을 트리거) |
| `multica issue status <id> --set <status>` | 상태 변경 단축 명령 |
| `multica issue search <query>` | 키워드 검색 |
| `multica issue children <id>` | 하위 이슈를 stage별로 나열 |
| `multica issue pull-requests <id>` | 연결된 pull request와 상태 나열 |
| `multica issue runs <id>` | 이슈의 에이전트 실행 표시 |
| `multica issue run-messages <task-id>` | 한 실행의 메시지 표시 |
| `multica issue usage <id>` | 이슈의 집계 token 사용량 표시 |
| `multica issue rerun <id>` | 이슈의 현재 에이전트 담당자에게 새 작업을 다시 큐에 넣기 |
| `multica issue cancel-task <task-id>` | 대기 중이거나 실행 중인 작업 취소 |
| `multica issue comment <id> ...` | 중첩: 댓글 보기 / 작성 |
| `multica issue comment resolve/unresolve <comment-id>` | 댓글 스레드를 해결 / 미해결로 표시 |
| `multica issue subscriber <id> ...` | 중첩: 구독 / 구독 취소 |
| `multica issue metadata <id> ...` | 중첩: 이슈 metadata 읽기 / 쓰기 |
| `multica issue label <id> ...` | 중첩: 이슈의 label 관리 |
| `multica project list/get/create/update/delete/status` | 프로젝트 CRUD |
| `multica label list/create/update/delete` | 워크스페이스 label CRUD |
## 에이전트와 스킬
@@ -83,8 +74,6 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
| `multica agent archive <slug>` | 보관 |
| `multica agent restore <slug>` | 보관된 에이전트 복원 |
| `multica agent tasks <slug>` | 에이전트의 작업 기록 표시 |
| `multica agent avatar <slug>` | 에이전트 아바타 업로드 |
| `multica agent env <slug> ...` | 에이전트 custom environment variables 읽기 / 교체 |
| `multica agent skills ...` | 중첩: 스킬 연결 / 분리 |
| `multica skill list/get/create/update/delete` | 스킬 CRUD |
| `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 |
@@ -128,10 +117,6 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica autopilot delete <id>` | 삭제 |
| `multica autopilot runs <id>` | 실행 기록 표시 |
| `multica autopilot trigger <id>` | 수동으로 실행 트리거 |
| `multica autopilot trigger-add/update/delete <id>` | schedule 또는 webhook trigger 관리 |
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | webhook trigger URL 회전 |
`multica autopilot create/update`는 `create_issue` 모드 autopilot이 만드는 이슈의 기본 구독자를 설정하는 `--subscriber`도 받습니다. `update`에서는 `--clear-subscribers`로 제거할 수 있습니다.
## 데몬과 런타임
@@ -145,9 +130,7 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica runtime list` | 현재 워크스페이스의 런타임 나열 |
| `multica runtime usage` | 리소스 사용량 표시 |
| `multica runtime activity` | 최근 활동 로그 |
| `multica runtime update <id> ...` | 런타임에서 CLI 업데이트 시작 |
| `multica runtime delete <id> [--cascade]` | 런타임 삭제, 필요하면 연결된 에이전트도 보관 |
| `multica runtime profile ...` | 사용자 지정 runtime profile과 로컬 경로 override 관리 |
| `multica runtime update <id> ...` | 런타임의 구성 업데이트 |
## 기타

View File

@@ -54,23 +54,14 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica issue get <id>` | Show a single issue (accepts an issue key or a UUID) |
| `multica issue create --title "..."` | Create a new issue |
| `multica issue update <id> ...` | Update an issue (status, priority, assignee, etc.) |
| `multica issue assign <id> --to <name>` | Assign to a member, agent, or squad (assigning to an agent triggers a run) |
| `multica issue status <id> <status>` | Shortcut to change status |
| `multica issue assign <id> --agent <slug>` | Assign to an agent (triggers a task immediately) |
| `multica issue status <id> --set <status>` | Shortcut to change status |
| `multica issue search <query>` | Keyword search |
| `multica issue children <id>` | List sub-issues grouped by stage |
| `multica issue pull-requests <id>` | List linked pull requests and their status |
| `multica issue runs <id>` | Show agent runs on an issue |
| `multica issue run-messages <task-id>` | Show messages for one execution |
| `multica issue usage <id>` | Show aggregated token usage for an issue |
| `multica issue rerun <id>` | Re-enqueue a fresh task for the issue's current agent assignee |
| `multica issue cancel-task <task-id>` | Cancel a queued or running task |
| `multica issue comment <id> ...` | Nested: view / post comments |
| `multica issue comment resolve/unresolve <comment-id>` | Mark a comment thread resolved or unresolved |
| `multica issue subscriber <id> ...` | Nested: subscribe / unsubscribe |
| `multica issue metadata <id> ...` | Nested: read / write issue metadata |
| `multica issue label <id> ...` | Nested: manage labels on an issue |
| `multica project list/get/create/update/delete/status` | Project CRUD |
| `multica label list/create/update/delete` | Workspace label CRUD |
## Agents and skills
@@ -83,8 +74,6 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica agent archive <slug>` | Archive |
| `multica agent restore <slug>` | Restore an archived agent |
| `multica agent tasks <slug>` | Show an agent's task history |
| `multica agent avatar <slug>` | Upload an agent avatar |
| `multica agent env <slug> ...` | Read or replace an agent's custom environment variables |
| `multica agent skills ...` | Nested: attach / detach skills |
| `multica skill list/get/create/update/delete` | Skill CRUD |
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
@@ -134,12 +123,6 @@ See [Squads](/squads) for the full model.
| `multica autopilot delete <id>` | Delete |
| `multica autopilot runs <id>` | Show run history |
| `multica autopilot trigger <id>` | Trigger a run manually |
| `multica autopilot trigger-add/update/delete <id>` | Manage schedule or webhook triggers |
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | Rotate a webhook trigger URL |
`multica autopilot create/update` also accepts `--subscriber` to set default
subscribers for issues created by a `create_issue` autopilot; update accepts
`--clear-subscribers` to remove them.
## Daemon and runtimes
@@ -153,9 +136,7 @@ subscribers for issues created by a `create_issue` autopilot; update accepts
| `multica runtime list` | List runtimes in the current workspace |
| `multica runtime usage` | Show resource usage |
| `multica runtime activity` | Recent activity log |
| `multica runtime update <id> ...` | Initiate a CLI update on a runtime |
| `multica runtime delete <id> [--cascade]` | Delete a runtime, optionally archiving bound agents |
| `multica runtime profile ...` | Manage custom runtime profiles and local path overrides |
| `multica runtime update <id> ...` | Update a runtime's configuration |
## Miscellaneous

View File

@@ -54,23 +54,14 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica issue get <id>` | 查看单条 issue接受 issue key 或 UUID |
| `multica issue create --title "..."` | 创建新 issue |
| `multica issue update <id> ...` | 修改 issue状态、优先级、分配人等 |
| `multica issue assign <id> --to <name>` | 分配给成员、智能体或小队(分配给智能体会触发一次运行 |
| `multica issue status <id> <status>` | 快捷改状态 |
| `multica issue assign <id> --agent <slug>` | 分配给智能体(立即触发任务 |
| `multica issue status <id> --set <status>` | 快捷改状态 |
| `multica issue search <query>` | 关键字搜索 |
| `multica issue children <id>` | 按 stage 分组查看子 issue |
| `multica issue pull-requests <id>` | 查看关联 PR 及其状态 |
| `multica issue runs <id>` | 查看 issue 上智能体跑过的任务 |
| `multica issue run-messages <task-id>` | 查看某次执行的消息 |
| `multica issue usage <id>` | 查看单个 issue 聚合 token 用量 |
| `multica issue rerun <id>` | 给该 issue 当前的智能体分配人重新创建一条任务 |
| `multica issue cancel-task <task-id>` | 取消排队中或运行中的任务 |
| `multica issue comment <id> ...` | 嵌套:看 / 发评论 |
| `multica issue comment resolve/unresolve <comment-id>` | 标记评论线程已解决 / 未解决 |
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
| `multica issue metadata <id> ...` | 嵌套:读写 issue metadata |
| `multica issue label <id> ...` | 嵌套:管理 issue 上的 label |
| `multica project list/get/create/update/delete/status` | Project CRUD |
| `multica label list/create/update/delete` | Workspace label CRUD |
## 智能体和 Skill
@@ -83,8 +74,6 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica agent archive <slug>` | 归档 |
| `multica agent restore <slug>` | 恢复归档的智能体 |
| `multica agent tasks <slug>` | 查看智能体的任务历史 |
| `multica agent avatar <slug>` | 上传智能体头像 |
| `multica agent env <slug> ...` | 读取或替换智能体的 custom environment variables |
| `multica agent skills ...` | 嵌套:挂载 / 卸载 Skill |
| `multica skill list/get/create/update/delete` | Skill CRUD |
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
@@ -128,10 +117,6 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica autopilot delete <id>` | 删除 |
| `multica autopilot runs <id>` | 查看运行历史 |
| `multica autopilot trigger <id>` | 手动触发一次 |
| `multica autopilot trigger-add/update/delete <id>` | 管理 schedule 或 webhook trigger |
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | 轮换 webhook trigger URL |
`multica autopilot create/update` 还支持 `--subscriber`,为 `create_issue` 模式创建的 issue 设置默认订阅人;`update` 支持 `--clear-subscribers` 清空默认订阅人。
## 守护进程和运行时
@@ -145,9 +130,7 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
| `multica runtime list` | 列出当前工作区的 runtime |
| `multica runtime usage` | 查看资源使用情况 |
| `multica runtime activity` | 近期活动记录 |
| `multica runtime update <id> ...` | 在某个 runtime 上触发 CLI 更新 |
| `multica runtime delete <id> [--cascade]` | 删除 runtime可选择同时归档绑定的智能体 |
| `multica runtime profile ...` | 管理自定义 runtime profile 和本机路径覆盖 |
| `multica runtime update <id> ...` | 更新 runtime 配置 |
## 杂项

View File

@@ -58,29 +58,6 @@ graph TD
- **同じデーモン、ワークスペース、ツールは、ちょうど 1 つのランタイムを作ります** — デーモンを再起動しても重複レコードは生まれません
- Multica UI の**ランタイム**ページがこれらの行を一覧表示します
## カスタムランタイムプロファイル
組み込み provider 検出は一般的なツールを対象にしていますが、Multica が対応するプロトコルファミリーで動作し、ワークスペース固有の起動コマンドが必要な AI CLI には **custom runtime profile** を定義できます。Runtimes UI か CLI で管理します。
```bash
multica runtime profile list
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
multica runtime profile update <profile-id> --command-name agent
multica runtime profile delete <profile-id>
```
入力するコマンドは shell 文字列ではなく argv 形式です。Multica は実行ファイル名と固定引数を保存し、デーモンは `exec.Command(command_name, fixed_args...)` で直接起動します。通常の引数、引用符、バックスラッシュエスケープは使えますが、パイプ、リダイレクト、`&&`、`;`、バッククォート、`$VAR` / `$(...)` 展開は使えません。shell の動作が必要な場合は wrapper script を使ってください。
現在、コマンドと引数の解析は Runtimes UI が担当します。CLI の profile コマンドは profile 行とローカルのパス上書きを管理します。
デスクトップアプリから起動したデーモンが、ターミナルでは動くコマンドを見つけられない場合は、そのマシンで絶対パスを固定できます。
```bash
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
multica runtime profile unset-path <profile-id>
```
profile のコマンドや引数の変更は、デーモンが再登録された後に新しく取得するタスクへ適用されます。実行中のタスクは開始時の引数を使い続けます。混在デプロイでは、先に server をアップグレードしてから daemon を順次更新することを推奨します。`fixed_args` の入力は server 側の Runtimes UI が担い、`failed_profiles` 登録レポートも server が表示します。古いコンポーネントは未知のフィールドを明示的に失敗させず無視することがあるため、server を先に更新すると rollout を観測しやすくなります。
<Callout type="info">
**クラウドランタイムが近日提供されます。** 現在は順番待ちリストの段階です。提供が始まれば、ローカルのデーモンを実行せずに Multica Cloud 上で直接エージェントタスクを実行できるようになります。[ダウンロードページ](https://multica.ai/download)でメールアドレスを登録すると通知を受け取れます。
</Callout>

View File

@@ -58,29 +58,6 @@ graph TD
- **같은 데몬, 워크스페이스, 도구는 정확히 하나의 런타임을 만듭니다.** 데몬을 재시작해도 중복 레코드가 생기지 않습니다
- Multica UI의 **런타임** 페이지가 이 행들을 나열합니다
## 사용자 지정 런타임 프로필
기본 provider 감지는 일반적인 도구를 다룹니다. 팀에서 Multica가 지원하는 프로토콜 패밀리로 동작하지만 워크스페이스별 실행 명령이 필요한 AI CLI를 쓰는 경우에는 **custom runtime profile**을 정의할 수 있습니다. Runtimes UI 또는 CLI에서 관리합니다.
```bash
multica runtime profile list
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
multica runtime profile update <profile-id> --command-name agent
multica runtime profile delete <profile-id>
```
명령은 shell 문자열이 아니라 argv 형식입니다. Multica는 실행 파일 이름과 고정 인수를 저장하고, 데몬은 `exec.Command(command_name, fixed_args...)`로 직접 실행합니다. 일반 인수, 따옴표, 백슬래시 이스케이프는 지원하지만 파이프, 리다이렉션, `&&`, `;`, 백틱, `$VAR` / `$(...)` 확장은 지원하지 않습니다. shell 동작이 필요하면 wrapper script를 사용하세요.
현재 명령과 인수 파싱은 Runtimes UI가 담당합니다. CLI의 profile 명령은 profile 행과 로컬 경로 override를 관리합니다.
데스크톱 앱에서 시작한 데몬이 터미널에서는 동작하는 명령을 찾지 못한다면, 해당 기기에서 절대 경로를 고정할 수 있습니다.
```bash
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
multica runtime profile unset-path <profile-id>
```
profile의 명령이나 인수 변경은 데몬이 다시 등록된 뒤 새로 가져오는 작업부터 적용됩니다. 이미 실행 중인 작업은 시작할 때의 인수를 유지합니다. 혼합 배포에서는 server를 먼저 업그레이드한 뒤 daemon을 순차적으로 업데이트하는 것을 권장합니다. `fixed_args` 입력은 server 쪽 Runtimes UI가 담당하고, `failed_profiles` 등록 보고도 server가 표시합니다. 오래된 구성요소는 알 수 없는 필드를 명확히 실패시키기보다 무시할 수 있으므로 server를 먼저 올리면 rollout을 더 잘 관찰할 수 있습니다.
<Callout type="info">
**클라우드 런타임이 곧 제공됩니다.** 현재는 대기자 명단 단계입니다. 제공이 시작되면 로컬 데몬을 실행하지 않고도 Multica Cloud에서 직접 에이전트 작업을 실행할 수 있습니다. [다운로드 페이지](https://multica.ai/download)에서 이메일로 등록하면 알림을 받을 수 있습니다.
</Callout>

View File

@@ -58,44 +58,6 @@ Key points:
- **The same daemon, workspace, and tool produces exactly one runtime** — restarting the daemon never creates duplicate records
- The **Runtimes** page in the Multica UI lists these rows
## Custom runtime profiles
Built-in provider detection covers the common tools, but teams can also define
**custom runtime profiles** for AI CLIs that speak one of Multica's supported
protocol families and need a workspace-specific launch command. Profiles are
managed from the Runtimes UI or the CLI:
```bash
multica runtime profile list
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
multica runtime profile update <profile-id> --command-name agent
multica runtime profile delete <profile-id>
```
The command is argv-oriented, not a shell string. Multica stores an executable
name plus fixed arguments, then the daemon launches it directly with
`exec.Command(command_name, fixed_args...)`. Plain arguments, quotes, and
backslash escaping are supported; pipes, redirects, `&&`, `;`, backticks, and
`$VAR` / `$(...)` expansion are not. Use a wrapper script when the runtime needs
shell behavior. Today the Runtimes UI owns command-and-argument parsing; the CLI
profile commands manage the profile row and local path overrides.
If a desktop-launched daemon cannot find a command that works in your terminal,
pin the absolute path on that machine:
```bash
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
multica runtime profile unset-path <profile-id>
```
Profile command or argument edits apply to newly claimed tasks after the daemon
re-registers. Running tasks keep the launch arguments they started with. For
mixed deployments, upgrade the server before rolling out newer daemons: the
server-side Runtimes UI stores `fixed_args`, and the server is what surfaces
`failed_profiles` registration reports. Older components may ignore fields they
do not understand instead of failing loudly, so treating the server upgrade as
the first step keeps the rollout observable.
<Callout type="info">
**Cloud runtimes are coming**, currently in a waitlist phase. Once available, you'll be able to execute agent tasks directly on Multica Cloud without running a local daemon. Sign up with your email on the [download page](https://multica.ai/download) to get notified.
</Callout>

View File

@@ -58,29 +58,6 @@ graph TD
- **同一个守护进程在同一个工作区同一款工具上只会有一条运行时**——重启守护进程不会产生重复记录
- Multica 界面的 **Runtimes** 页面列的就是这些行
## 自定义运行时配置
内置 provider 探测覆盖常见工具;如果团队有一款兼容 Multica 已支持协议族、但需要工作区级启动命令的 AI CLI可以定义 **custom runtime profile**。你可以在 Runtimes UI 里管理,也可以用 CLI
```bash
multica runtime profile list
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
multica runtime profile update <profile-id> --command-name agent
multica runtime profile delete <profile-id>
```
这里填写的是 argv 风格命令,不是 shell 字符串。Multica 存的是可执行文件名和固定参数,守护进程会直接用 `exec.Command(command_name, fixed_args...)` 启动。支持普通参数、引号和反斜杠转义;不支持管道、重定向、`&&`、`;`、反引号、`$VAR` / `$(...)` 展开。需要 shell 行为时,用 wrapper script 包一层。
目前命令和参数的解析入口在 Runtimes UICLI 的 profile 命令负责管理 profile 记录和本机路径覆盖。
如果桌面应用拉起的守护进程找不到你在终端里能运行的命令,可以在这台机器上固定绝对路径:
```bash
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
multica runtime profile unset-path <profile-id>
```
修改 profile 的命令或参数后,已开始的任务仍使用启动时的参数;守护进程重新注册后,新领取的任务才会使用新配置。混合版本部署时,建议先升级 server再逐步升级 daemon`fixed_args` 的录入在 server 侧 Runtimes UI`failed_profiles` 注册报告也由 server 展示。旧组件可能会忽略自己不认识的字段,而不是明确报错;先升 server 能让 rollout 更可观察。
<Callout type="info">
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
</Callout>

View File

@@ -181,19 +181,9 @@ 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,19 +181,9 @@ 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,19 +181,9 @@ 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,19 +184,9 @@ 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

@@ -1,11 +1,11 @@
---
title: エージェントランタイムをインストールする
description: Multica はあなたのマシンにインストールされている AI コーディングツールを駆動します。このページでは、デーモンがそれらを検出できるように、サポートされている 13 種のツールをそれぞれインストールする方法を説明します。
description: Multica はあなたのマシンにインストールされている AI コーディングツールを駆動します。このページでは、デーモンがそれらを検出できるように、サポートされている 12 種のツールをそれぞれインストールする方法を説明します。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica における**ランタイム**とは、あなたのマシンのデーモンと、デーモンが `PATH` で見つけた AI コーディングツール 1 つが組になったものです。オンボーディングの「ランタイムを接続」ステップで **No supported tools detected** と表示される場合、それはデーモンが `PATH` をスキャンしたものの、駆動方法を知っている 13 種のツールのいずれも見つけられなかったことを意味します。以下のツールのいずれか(または複数)をインストールしてから、そのステップに戻って再スキャンしてください — 数秒以内にランタイムが表示されます。
Multica における**ランタイム**とは、あなたのマシンのデーモンと、デーモンが `PATH` で見つけた AI コーディングツール 1 つが組になったものです。オンボーディングの「ランタイムを接続」ステップで **No supported tools detected** と表示される場合、それはデーモンが `PATH` をスキャンしたものの、駆動方法を知っている 12 種のツールのいずれも見つけられなかったことを意味します。以下のツールのいずれか(または複数)をインストールしてから、そのステップに戻って再スキャンしてください — 数秒以内にランタイムが表示されます。
このページは次のドキュメントのインストール側の補完ドキュメントです。
@@ -31,13 +31,13 @@ multica daemon restart
または、デスクトップアプリではアプリを再起動するだけで構いません。デーモンは起動するたびに `PATH` を再スキャンします。
## サポートされている 13 種のツール
## サポートされている 12 種のツール
おおよそ利用者の多い順に並べています。すでに認証情報を持っているものを選んで使ってください — 13 種すべてをインストールする必要はありません。
おおよそ利用者の多い順に並べています。すでに認証情報を持っているものを選んで使ってください — 12 種すべてをインストールする必要はありません。
### Claude Code (Anthropic)
最も完全な連携です。セッション再開が動作し、MCP が動作し、エージェントの `mcp_config` フィールドを消費します(詳しくは[マトリクス](/providers)を参照)。
最も完全な連携です。セッション再開が動作し、MCP が動作し、**11 種のうちエージェントの `mcp_config` フィールドを実際に読み込む唯一のツール**です(詳しくは[マトリクス](/providers#mcp-configuration-only-claude-code-actually-reads-it)を参照)。
| | |
|---|---|
@@ -77,6 +77,16 @@ 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 エージェントです。独自の設定ファイルから利用可能なモデルを動的に発見します — 自分のモデルカタログを持ち込みたいユーザーによく合います。
@@ -137,26 +147,6 @@ 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 코딩 도구를 구동합니다. 이 페이지에서는 데몬이 도구를 감지할 수 있도록 지원되는 13종의 도구를 각각 설치하는 방법을 설명합니다.
description: Multica는 사용자 기기에 설치된 AI 코딩 도구를 구동합니다. 이 페이지에서는 데몬이 도구를 감지할 수 있도록 지원되는 12종의 도구를 각각 설치하는 방법을 설명합니다.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica에서 **런타임**이란 사용자 기기의 데몬과, 데몬이 `PATH`에서 찾아낸 AI 코딩 도구 하나가 짝을 이룬 것입니다. 온보딩의 "런타임 연결" 단계에서 **지원되는 도구를 감지하지 못했습니다**라고 표시된다면, 데몬이 `PATH`를 스캔했지만 구동 방법을 아는 13종의 도구 중 어느 것도 찾지 못했다는 뜻입니다. 아래 도구 중 하나(또는 여러 개)를 설치한 다음 해당 단계로 돌아와 다시 스캔하세요. 몇 초 안에 런타임이 나타납니다.
Multica에서 **런타임**이란 사용자 기기의 데몬과, 데몬이 `PATH`에서 찾아낸 AI 코딩 도구 하나가 짝을 이룬 것입니다. 온보딩의 "런타임 연결" 단계에서 **지원되는 도구를 감지하지 못했습니다**라고 표시된다면, 데몬이 `PATH`를 스캔했지만 구동 방법을 아는 12종의 도구 중 어느 것도 찾지 못했다는 뜻입니다. 아래 도구 중 하나(또는 여러 개)를 설치한 다음 해당 단계로 돌아와 다시 스캔하세요. 몇 초 안에 런타임이 나타납니다.
이 페이지는 다음 문서의 설치 측면 동반 문서입니다.
@@ -31,9 +31,9 @@ multica daemon restart
또는 데스크톱 앱에서는 앱을 다시 실행하기만 하면 됩니다. 데몬은 시작될 때마다 `PATH`를 다시 스캔합니다.
## 지원되는 13종의 도구
## 지원되는 12종의 도구
대략 많이 쓰이는 순서대로 나열했습니다. 이미 자격 증명을 갖고 있는 것을 골라 사용하세요. 13종을 모두 설치할 필요는 없습니다.
대략 많이 쓰이는 순서대로 나열했습니다. 이미 자격 증명을 갖고 있는 것을 골라 사용하세요. 12종을 모두 설치할 필요는 없습니다.
### Claude Code (Anthropic)
@@ -77,6 +77,16 @@ 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` 필드도 소비합니다.
@@ -137,26 +147,6 @@ 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 13 supported tools so the daemon can detect them.
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 12 supported tools so the daemon can detect them.
---
import { Callout } from "fumadocs-ui/components/callout";
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 13 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 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.
This page is the install-side companion to:
@@ -31,9 +31,9 @@ multica daemon restart
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
## The 13 supported tools
## The 12 supported tools
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 13.
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 12.
### Claude Code (Anthropic)
@@ -77,6 +77,16 @@ 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`.
@@ -137,26 +147,6 @@ 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)).

View File

@@ -1,11 +1,11 @@
---
title: 安装一个 Agent 运行时
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 13 款工具,让守护进程能扫到。
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 12 款工具,让守护进程能扫到。
---
import { Callout } from "fumadocs-ui/components/callout";
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 13 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 12 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
这一页是装机的入口,和它配套的是:
@@ -31,9 +31,9 @@ multica daemon restart
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
## 13 款支持的工具
## 12 款支持的工具
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 13 个全装。
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 12 个全装。
### Claude CodeAnthropic
@@ -77,6 +77,16 @@ 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` 字段。
@@ -137,26 +147,6 @@ 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))。

View File

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

View File

@@ -28,15 +28,12 @@ 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"
}
}
```
`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.
`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.
## Resource type: `local_directory`
@@ -171,7 +168,6 @@ 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 —
@@ -255,7 +251,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." 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.
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.
## What's intentionally **not** in scope here

View File

@@ -1,11 +1,11 @@
---
title: AI コーディングツール対応表
description: Multica は 13 個の AI コーディングツールをサポートしています。すべて同じインターフェースを実装していますが、機能の詳細は大きく異なります。
description: Multica は 12 個の AI コーディングツールをサポートしています。すべて同じインターフェースを実装していますが、機能の詳細は大きく異なります。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica は **13 個の AI コーディングツール**を標準でサポートしています。これらはすべて同じインターフェース(キューへの投入、ディスパッチ、実行、結果の返却)を実装しているため、同じ Multica ボードからどれでも動かすことができます。**しかし機能の詳細は大きく異なります**: セッション再開が実際に動作するか、MCP をサポートするか、スキルファイルがどこに置かれるか、モデルをどう選択するか。このページがその完全な対応表です。
Multica は **12 個の AI コーディングツール**を標準でサポートしています。これらはすべて同じインターフェース(キューへの投入、ディスパッチ、実行、結果の返却)を実装しているため、同じ Multica ボードからどれでも動かすことができます。**しかし機能の詳細は大きく異なります**: セッション再開が実際に動作するか、MCP をサポートするか、スキルファイルがどこに置かれるか、モデルをどう選択するか。このページがその完全な対応表です。
エージェントを作成するときにツールを選ぶ際のガイダンスは、[エージェントの作成と構成](/agents-create)を参照してください。
@@ -15,17 +15,16 @@ Multica は **13 個の 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/` | 動的探索 |
## 各ツールの用途
@@ -37,10 +36,6 @@ 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 にフォールバックしてタスクを続行します。
@@ -53,18 +48,14 @@ 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 のフォールバックとは異なります。
@@ -85,19 +76,22 @@ 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)で扱います。**サポートされているすべてのツールがセッションを再開できます** — 再開 id を渡すと、タスクは以前のコンテキストから続行します。唯一の例外は Pi で、再開 id が文字列 ID ではなくディスク上のセッションファイルへのパスです(上記の [Pi](#pi) を参照)
セッション再開のメカニズムは[タスク](/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 に再開メカニズムがありません |
**意思決定のために**: ワークフローでエージェントがタスク間でコンテキストを保持する必要がある場合(失敗時のリトライ、手動の再実行、対話的な反復)、✅ の行にあるツールだけを選んでください。
## MCP 構成: ツールごとの対応
**13 個のツールのうち、`mcp_config` を実際に消費するのは 10 個です: Claude Code、CodeBuddy、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Qoder**。残りの 3 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
**12 個のツールのうち、`mcp_config` を実際に消費するのは 8 個です: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。残りの 4 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
接続方式はツールごとに異なります: 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` を書き換えません。
接続方式はツールごとに異なります: 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` を書き換えません。
<Callout type="warning">
エージェント構成で `mcp_config` を設定しても、MCP 列に ✅ がないツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。MCP 連携はツールごとに実装されています。
@@ -110,7 +104,6 @@ Alibaba が提供します。エージェント型のコーディング CLI で
| ツール | パス | ネイティブ探索か |
|---|---|---|
| Claude Code | `.claude/skills/` | ✅ ネイティブ |
| CodeBuddy | `.claude/skills/` | ✅ ネイティブ |
| Codex | `$CODEX_HOME/skills/` | ✅ ネイティブ |
| Copilot | `.github/skills/` | ✅ ネイティブ |
| Cursor | `.cursor/skills/` | ✅ ネイティブ |
@@ -118,14 +111,12 @@ Alibaba が提供します。エージェント型のコーディング CLI で
| 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/` | ⚠️ 汎用フォールバック |
フォールバックパスを使うツールが実際にこのディレクトリを読み取るかどうかは、そのツール自体のドキュメントによって異なり、保証されません。Hermes / OpenClaw でスキルが適用されない場合は、まずこの点を確認してください。
ネイティブなプロジェクトレベルのパスでは、リポジトリスコープの探索は想定された挙動です。チェックアウトされたリポジトリが対応するディレクトリをすでに含んでいる場合、基盤となるツールはそのコミット済みスキルを自分で検出できます。そのリポジトリで使うためだけに、これらの repo skills を Multica へインポートする必要はありません。Multica はそれらのリポジトリファイルをそのまま保持します。ワークスペーススキルの自然なディレクトリ名が同じ場合、デーモンは `review-helper-multica` のような衝突しない兄弟ディレクトリへワークスペースコピーを書き込みます。
フォールバックパスを使うツールが実際にこのディレクトリを読み取るかどうかは、そのツール自体のドキュメントによって異なり、保証されません。Gemini / Hermes / OpenClaw でスキルが適用されない場合は、まずこの点を確認してください。
スキルの作成と使用については、[スキル](/skills)を参照してください。
@@ -134,4 +125,4 @@ Alibaba が提供します。エージェント型のコーディング CLI で
- [エージェントの作成と構成](/agents-create) — エージェントに使うツールを選ぶ
- [タスク](/tasks) — タスクのライフサイクルとセッション再開のメカニズム
- [デーモンとランタイム](/daemon-runtimes) — ツールが実行される場所と Multica への接続方法
- [エージェントランタイムのインストール](/install-agent-runtime) — サポートされる 13 個のツールそれぞれのインストールと認証
- [エージェントランタイムのインストール](/install-agent-runtime) — サポートされる 12 個のツールそれぞれのインストールと認証

View File

@@ -1,11 +1,11 @@
---
title: AI 코딩 도구 대조표
description: Multica는 13개의 AI 코딩 도구를 지원합니다. 모두 동일한 인터페이스를 구현하지만, 기능 세부사항은 크게 다릅니다.
description: Multica는 12개의 AI 코딩 도구를 지원합니다. 모두 동일한 인터페이스를 구현하지만, 기능 세부사항은 크게 다릅니다.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica는 **13개의 AI 코딩 도구**를 기본 지원합니다. 이들은 모두 동일한 인터페이스(대기열 적재, 디스패치, 실행, 결과 반환)를 구현하므로, 같은 Multica 보드에서 어느 것이든 구동할 수 있습니다. **하지만 기능 세부사항은 크게 다릅니다**: 세션 재개가 실제로 동작하는지, MCP를 지원하는지, 스킬 파일이 어디에 위치하는지, 모델을 어떻게 선택하는지. 이 페이지가 전체 대조표입니다.
Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은 모두 동일한 인터페이스(대기열 적재, 디스패치, 실행, 결과 반환)를 구현하므로, 같은 Multica 보드에서 어느 것이든 구동할 수 있습니다. **하지만 기능 세부사항은 크게 다릅니다**: 세션 재개가 실제로 동작하는지, MCP를 지원하는지, 스킬 파일이 어디에 위치하는지, 모델을 어떻게 선택하는지. 이 페이지가 전체 대조표입니다.
에이전트를 생성할 때 도구를 고르는 방법은 [에이전트 생성 및 구성](/agents-create)을 참고하세요.
@@ -15,17 +15,16 @@ Multica는 **13개의 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/` | 동적 탐색 |
## 각 도구의 용도
@@ -37,10 +36,6 @@ 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로 폴백해 작업을 계속 실행합니다.
@@ -53,18 +48,14 @@ 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과는 다릅니다.
@@ -85,19 +76,22 @@ 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)에서 다룹니다. **지원되는 모든 도구가 세션을 재개합니다** — 재개 id를 전달하면 작업이 이전 컨텍스트에서 이어집니다. 유일한 예외는 Pi로, 재개 id가 문자열 ID가 아니라 디스크상의 세션 파일 경로입니다(위의 [Pi](#pi) 참고).
세션 재개 메커니즘은 [작업](/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에 재개 메커니즘이 없습니다 |
**의사결정을 위해**: 워크플로에서 에이전트가 작업 간에 컨텍스트를 유지해야 한다면(실패 재시도, 수동 재실행, 대화형 반복), ✅ 행에 있는 도구만 선택하세요.
## MCP 구성: 도구별 지원
**13개 도구 중 `mcp_config`를 실제로 소비하는 것은 10개입니다: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Qoder**. 나머지 3개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 8개입니다: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 4개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
각 도구의 연결 방식은 다릅니다: 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`을 다시 쓰지 않습니다.
각 도구의 연결 방식은 다릅니다: 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`을 다시 쓰지 않습니다.
<Callout type="warning">
에이전트 구성에서 `mcp_config`를 설정했더라도 MCP 열에 ✅가 없는 도구를 선택하면, MCP 서버가 해당 에이전트에 **아무런 효과**도 미치지 않습니다. MCP 연동은 도구별로 구현됩니다.
@@ -110,7 +104,6 @@ Alibaba에서 제공합니다. 에이전트형 코딩 CLI입니다. stdio 위에
| 도구 | 경로 | 기본 탐색 여부 |
|---|---|---|
| Claude Code | `.claude/skills/` | ✅ 기본 |
| CodeBuddy | `.claude/skills/` | ✅ 기본 |
| Codex | `$CODEX_HOME/skills/` | ✅ 기본 |
| Copilot | `.github/skills/` | ✅ 기본 |
| Cursor | `.cursor/skills/` | ✅ 기본 |
@@ -118,14 +111,12 @@ Alibaba에서 제공합니다. 에이전트형 코딩 CLI입니다. stdio 위에
| 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 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는 해당 도구 자체의 문서에 따라 달라지며 — 보장되지 않습니다. Hermes / OpenClaw에서 스킬이 적용되지 않는다면, 먼저 이 점을 확인하세요.
기본 프로젝트 수준 경로에서는 저장소 범위 탐색이 의도된 동작입니다. 체크아웃된 저장소가 이미 해당 디렉터리를 포함하고 있으면, 기반 도구가 커밋된 스킬을 자체적으로 탐색할 수 있습니다. 해당 저장소에서 사용하기 위해 이러한 repo skills를 Multica로 먼저 가져올 필요는 없습니다. Multica는 이러한 저장소 파일을 그대로 둡니다. 워크스페이스 스킬의 자연 디렉터리 이름이 같으면 데몬은 `review-helper-multica` 같은 충돌 없는 형제 디렉터리에 워크스페이스 사본을 씁니다.
fallback 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는 해당 도구 자체의 문서에 따라 달라지며 — 보장되지 않습니다. Gemini / Hermes / OpenClaw에서 스킬이 적용되지 않는다면, 먼저 이 점을 확인하세요.
스킬의 생성과 사용은 [스킬](/skills)을 참고하세요.
@@ -134,4 +125,4 @@ fallback 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는
- [에이전트 생성 및 구성](/agents-create) — 에이전트에 사용할 도구를 선택하세요
- [작업](/tasks) — 작업 생명주기와 세션 재개 메커니즘
- [데몬과 런타임](/daemon-runtimes) — 도구가 실행되는 곳과 Multica에 연결되는 방식
- [에이전트 런타임 설치](/install-agent-runtime) — 지원되는 13개 도구 각각의 설치 및 인증
- [에이전트 런타임 설치](/install-agent-runtime) — 지원되는 12개 도구 각각의 설치 및 인증

View File

@@ -1,11 +1,11 @@
---
title: AI coding tools matrix
description: Multica supports 13 AI coding tools; they implement the same interface, but the capability details diverge significantly.
description: Multica supports 12 AI coding tools; they implement the same interface, but the capability details diverge significantly.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica ships with built-in support for **13 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
Multica ships with built-in support for **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.
For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create).
@@ -15,17 +15,16 @@ 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
@@ -37,10 +36,6 @@ From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service a
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.
@@ -53,18 +48,14 @@ 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.
@@ -85,19 +76,22 @@ 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). **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).
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.
## MCP configuration: provider-specific support
**Of the 13 tools, ten consume `mcp_config`: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Qoder**. The other three accept the field but **ignore it** — no error, no warning, the config just has no effect.
**Of the 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.
The runtime paths are provider-specific: Claude Code and CodeBuddy receive it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, Kiro CLI, and Qoder receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
The runtime paths are provider-specific: Claude Code 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`.
<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.
@@ -110,7 +104,6 @@ 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 |
@@ -118,14 +111,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 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`.
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.
For creating and using skills, see [Skills](/skills).
@@ -134,4 +125,4 @@ For creating and using skills, see [Skills](/skills).
- [Creating and configuring agents](/agents-create) — pick a tool for your agent
- [Tasks](/tasks) — task lifecycle and session-resumption mechanics
- [Daemon and runtimes](/daemon-runtimes) — where the tools run and how they connect to Multica
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 13 supported tools
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 12 supported tools

View File

@@ -1,11 +1,11 @@
---
title: AI 编程工具对照
description: Multica 支持 13 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
description: Multica 支持 12 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica 内置支持 **13 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
创建智能体时挑选工具的指引见 [创建和配置智能体](/agents-create)。
@@ -15,17 +15,16 @@ Multica 内置支持 **13 款 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/` | 动态发现 |
## 每款工具的定位
@@ -37,10 +36,6 @@ Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,
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让任务继续执行。
@@ -53,18 +48,14 @@ 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 不一样。
@@ -85,19 +76,22 @@ 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#任务能接着上次的上下文继续吗) 里讲过。**所有支持的工具都能恢复会话**——传 resume id任务就会从上次的上下文接着继续。唯一的特例是 Pi它的 resume id 是磁盘上的会话文件路径,而不是字符串 ID见上文 [Pi](#pi))。
会话恢复的机制在 [执行任务](/tasks#任务能接着上次的上下文继续吗) 里讲过。这里按工具列**精确现状**
| 状态 | 工具 | 含义 |
|---|---|---|
| ✅ 真用 | Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id会从上次上下文接着继续 |
| ❌ 无 | Gemini | CLI 无 resume 机制 |
**对你的决策**:如果工作流需要智能体在多次任务之间保持上下文(失败重试、手动重跑、对话式迭代),只选 ✅ 那一行的工具。
## MCP 配置:按工具不同
**13 款工具里有 10 款实际消费 `mcp_config`Claude Code、CodeBuddy、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Qoder**。其他 3 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
**12 款工具里有 8 款实际消费 `mcp_config`Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 4 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
各工具的接入方式不同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`。
各工具的接入方式不同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`。
<Callout type="warning">
如果你在智能体配置里设置了 `mcp_config`,但选了矩阵 MCP 列没有标 ✅ 的工具,你的 MCP server 对这个智能体**没有效果**。MCP 集成是按工具实现的。
@@ -110,7 +104,6 @@ Alibaba 出品。一款 agentic 编程 CLI。使用 ACP 协议(和 Hermes、Ki
| 工具 | 路径 | 是否原生发现 |
|---|---|---|
| Claude Code | `.claude/skills/` | ✅ 原生 |
| CodeBuddy | `.claude/skills/` | ✅ 原生 |
| Codex | `$CODEX_HOME/skills/` | ✅ 原生 |
| Copilot | `.github/skills/` | ✅ 原生 |
| Cursor | `.cursor/skills/` | ✅ 原生 |
@@ -118,14 +111,12 @@ Alibaba 出品。一款 agentic 编程 CLI。使用 ACP 协议(和 Hermes、Ki
| 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 对 Hermes / OpenClaw 没起效,先查这个问题。
对原生项目级路径来说repo-scoped discovery 是预期行为:如果检出的仓库已经包含对应目录,底层工具可以自己发现这些提交在仓库里的 Skill。你不需要为了在这个仓库里使用这些 repo skills 而先把它们导入 Multica。Multica 会保持这些仓库文件不变。如果某个工作区 Skill 的自然目录名相同,守护进程会把工作区副本写到类似 `review-helper-multica` 的无冲突 sibling 目录。
fallback 路径对应的工具是否真的读取这个目录,取决于工具本身的文档——没保证。如果你的 skill 对 Gemini / Hermes / OpenClaw 没起效,先查这个问题。
skill 的创建和使用详见 [技能](/skills)。

View File

@@ -12,16 +12,10 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica は 2 つのスキルソースに対応しています。
- **ワークスペーススキル** — Multica のクラウドに保存されます。エージェントに取り付けると、タスク実行時にあなたのデーモンへ同期されます。これが**チーム全体でスキルを共有する標準的な方法**です。
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。デーモンは**2 つのルートを優先順位順に**確認します。まずランタイム自身のスキルディレクトリ(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`)、次にツール横断の汎用ディレクトリ `~/.agents/skills/`Codex や Gemini CLI などのエコシステムが共有する場所)です。同じスキル名が両方に存在する場合は**プロバイダー専用ディレクトリが優先**されるため、汎用ルートは*追加の*スキルを表示するだけで、既存スキルの解決結果を変えることはありません
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。
ほとんどの場合は**ワークスペーススキル**が望ましいでしょう。一度インポートすれば、すべてのチームメイトのエージェントが使えるからです。ローカルスキルは、まずローカルでテストしたい場合や、内容に機密性の高いローカル資料が含まれる場合に適しています。
## リポジトリスコープのスキル
リポジトリスコープのスキルは想定された挙動です。一部の AI コーディングツールは、`.claude/skills/`、`.cursor/skills/`、`.opencode/skills/`、`.agents/skills/` など、**リポジトリにコミットされたプロジェクトレベルのスキル**を検出します。Multica のタスクがそのリポジトリをチェックアウトすると、それらのファイルは作業ディレクトリに残り、基盤となるツールが自身のネイティブ探索ルールで読み込めます。そのリポジトリで使うためだけに、これらの repo skills を Multica へインポートする必要はありません。また Multica は、それらをワークスペーススキルレジストリへ自動インポートしません。
プロジェクトレベルの探索に対応するツールでは、ワークスペーススキルも同じプロバイダー固有の場所へ同期されます。ワークスペーススキルが既存のリポジトリスキルディレクトリと衝突する場合、Multica はリポジトリのファイルを上書きせず、`review-helper-multica` のような兄弟名でワークスペースコピーを書き込みます。そのためツールには、調整後の名前を持つワークスペースコピーを含めて両方のスキルが見えることがあります。
## スキルをインポートする
ワークスペーススキルは 4 つのソースから取得します。

View File

@@ -12,16 +12,10 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica는 두 가지 스킬 소스를 지원합니다.
- **워크스페이스 스킬** — Multica 클라우드에 저장됩니다. 에이전트에 연결되면 작업 실행 시점에 여러분의 데몬으로 동기화됩니다. 이것이 **팀 전체에서 스킬을 공유하는 표준 방식**입니다.
- **로컬 스킬** — 여러분의 기기에 있는 디렉터리에 존재합니다. 여러분이 요청하면 [데몬](/daemon-runtimes)이 기기를 스캔하고, 어떤 스킬을 워크스페이스로 가져올지 직접 고릅니다. 데몬은 **두 개의 루트를 우선순위 순서로** 확인합니다. 먼저 런타임 자체의 스킬 디렉터리(각 AI 코딩 도구마다 관례적인 기본 경로가 있습니다. 예: Claude Code의 `~/.claude/skills/`), 그다음 도구 간 공용 디렉터리 `~/.agents/skills/`(Codex, Gemini CLI 등 생태계가 공유하는 위치)입니다. 동일한 스킬 이름이 양쪽에 모두 있으면 **프로바이더 전용 디렉터리가 우선**하므로, 공용 루트는 *추가* 스킬만 노출할 뿐 기존 스킬의 해석 결과를 절대 바꾸지 않습니다.
- **로컬 스킬** — 여러분의 기기에 있는 디렉터리에 존재합니다(각 AI 코딩 도구마다 관례적인 기본 경로가 있습니다. 예: Claude Code의 `~/.claude/skills/`). 여러분이 요청하면 [데몬](/daemon-runtimes)이 기기를 스캔하고, 어떤 스킬을 워크스페이스로 가져올지 직접 고릅니다.
대부분의 경우 **워크스페이스 스킬**을 원하게 됩니다. 한 번만 가져오면 모든 팀원의 에이전트가 사용할 수 있기 때문입니다. 로컬 스킬은 먼저 로컬에서 테스트하고 싶거나, 콘텐츠에 민감한 로컬 자료가 포함된 경우에 적합합니다.
## 저장소 범위 스킬
저장소 범위 스킬은 의도된 동작입니다. 일부 AI 코딩 도구는 `.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, `.agents/skills/`처럼 **저장소에 커밋된 프로젝트 수준 스킬**을 탐색합니다. Multica 작업이 해당 저장소를 체크아웃하면 이 파일들은 작업 디렉터리에 남아 있고, 기반 도구가 자체 기본 탐색 규칙에 따라 읽어 들일 수 있습니다. 해당 저장소에서 사용하기 위해 이러한 repo skills를 Multica로 먼저 가져올 필요는 없습니다. Multica도 이를 워크스페이스 스킬 레지스트리로 자동 가져오지 않습니다.
프로젝트 수준 탐색을 지원하는 도구에서는 워크스페이스 스킬도 같은 프로바이더 기본 위치로 동기화됩니다. 워크스페이스 스킬이 기존 저장소 스킬 디렉터리와 충돌하면, Multica는 저장소 파일을 덮어쓰지 않고 `review-helper-multica` 같은 형제 이름으로 워크스페이스 사본을 씁니다. 그러면 도구에는 조정된 이름을 가진 워크스페이스 사본을 포함해 두 스킬이 모두 보일 수 있습니다.
## 스킬 가져오기
워크스페이스 스킬은 네 가지 소스에서 가져옵니다.

View File

@@ -12,16 +12,10 @@ A Skill is a **knowledge pack** for an [agent](/agents) — a `SKILL.md` plus op
Multica supports two skill sources:
- **Workspace skill** — stored in Multica's cloud. Once attached to an agent, it's synced down to your daemon at task execution time. This is the **standard way to share skills across a team**.
- **Local skill** — lives in a directory on your machine. On your request, the [daemon](/daemon-runtimes) scans your machine, and you manually pick which ones to bring into the workspace. The daemon checks **two roots, in priority order**: first the runtime's own skill directory (each AI coding tool has a conventional default path, e.g. Claude Code's `~/.claude/skills/`), then the cross-tool universal directory `~/.agents/skills/` — a shared location used by ecosystems like Codex and Gemini CLI. When the same skill name exists in both, the **provider-specific directory wins**, so the universal root only ever surfaces *additional* skills and never changes what an existing skill resolves to.
- **Local skill** — lives in a directory on your machine (each AI coding tool has a conventional default path, e.g. Claude Code's `~/.claude/skills/`). On your request, the [daemon](/daemon-runtimes) scans your machine, and you manually pick which ones to bring into the workspace.
Most of the time you want **workspace skills**: import once, every teammate's agent can use it. Local skills are a fit when you want to test locally first, or when the content involves sensitive local material.
## Repository-scoped skills
Repo-scoped skills are expected behavior. Some AI coding tools discover **project-level skills committed inside a repository**, such as `.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, or `.agents/skills/`. When a Multica task checks out that repository, those files remain in the workdir and the underlying tool can load them through its native discovery rules. You do **not** need to import those repo skills into Multica just to use them in that repo; Multica does **not** import them into the workspace skill registry automatically.
Workspace skills still sync into the same provider-native location for tools that support project-level discovery. If a workspace skill would collide with an existing repo skill directory, Multica writes the workspace copy to a sibling name such as `review-helper-multica` instead of overwriting the repo's files. The tool may then see both skills, with the workspace copy carrying the adjusted name.
## Importing a skill
Workspace skills come from four sources:

View File

@@ -12,16 +12,10 @@ Skill 是给 [智能体](/agents) 的**专业知识包**——一个 `SKILL.md`
Multica 支持两种 Skill 来源:
- **工作区 Skillworkspace skill** —— 存在 Multica 云端。挂到智能体后,任务执行时自动同步到你本机的守护进程。这是**团队共享 Skill 的标准方式**。
- **本机 Skilllocal skill** —— 直接存在你本机的某个目录里。[守护进程](/daemon-runtimes) 按你的请求扫描本机,发现后由你手动选入工作区。守护进程会**按优先级扫描两个根目录**:先扫该运行时自己的 skill 目录(每款 AI 编程工具有约定的默认路径,比如 Claude Code 的 `~/.claude/skills/`),再扫跨工具通用目录 `~/.agents/skills/`——这是 Codex、Gemini CLI 等生态共用的位置。当同名 skill 在两处都存在时,**provider 专用目录优先**,所以通用根目录只会*额外*带出新的 skill绝不会改变已有 skill 的解析结果
- **本机 Skilllocal skill** —— 直接存在你本机的某个目录里(每款 AI 编程工具有约定的默认路径,比如 Claude Code 的 `~/.claude/skills/`。[守护进程](/daemon-runtimes) 按你的请求扫描本机,发现后由你手动选入工作区。
大多数情况用**工作区 Skill**:导入一次,团队所有成员的智能体都能用。本机 Skill 适合先在本地测试、或涉及敏感本地内容的场景。
## 仓库级 Skill
仓库级 Skill 是预期行为。有些 AI 编程工具会发现**提交在仓库里的项目级 Skill**,例如 `.claude/skills/`、`.cursor/skills/`、`.opencode/skills/` 或 `.agents/skills/`。当 Multica 任务检出这个仓库时,这些文件会留在工作目录里,底层工具可以按自己的原生发现规则加载它们。你不需要为了在这个仓库里使用这些 repo skills 而先把它们导入 MulticaMultica 也不会自动把它们导入工作区 Skill 注册表。
对支持项目级发现的工具,工作区 Skill 也会同步到同一个 provider 原生位置。如果某个工作区 Skill 会和已有的仓库 Skill 目录冲突Multica 会把工作区副本写到类似 `review-helper-multica` 的 sibling 名称下,而不是覆盖仓库文件。工具随后可能会同时看到两个 Skill其中工作区副本使用调整后的名称。
## 导入 Skill
工作区 Skill 有四种来源:

View File

@@ -366,6 +366,5 @@ export function commentToTimelineEntry(comment: Comment): TimelineEntry {
resolved_at: comment.resolved_at,
resolved_by_type: comment.resolved_by_type,
resolved_by_id: comment.resolved_by_id,
source_task_id: comment.source_task_id,
};
}

View File

@@ -89,7 +89,6 @@ export const CommentSchema = z.object({
resolved_at: z.string().nullable().default(null),
resolved_by_type: z.string().nullable().default(null),
resolved_by_id: z.string().nullable().default(null),
source_task_id: z.string().nullable().optional(),
}).loose() as unknown as z.ZodType<Comment>;
export const EMPTY_COMMENT: Comment = {
@@ -664,7 +663,6 @@ export const EMPTY_ISSUE_FALLBACK: import("@multica/core/types").Issue = {
parent_issue_id: null,
project_id: null,
position: 0,
stage: null,
start_date: null,
due_date: null,
metadata: {},

View File

@@ -1,5 +1,4 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
@@ -117,24 +116,6 @@ export default async function RootLayout({
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
>
<body className="h-full overflow-hidden">
{/*
react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click
any element to copy its source path + line + component stack for pasting
to an AI. Opt-in per developer: only loads when VITE_REACT_GRAB is set in
a local, gitignored apps/web/.env.local — it never activates for anyone
else. Both guards are read server-side, so the <Script> is omitted from
the HTML entirely unless you opted in. The VITE_ prefix is shared with the
desktop renderer (apps/desktop/src/renderer/src/main.tsx), where Vite only
exposes VITE_-prefixed vars to client code, so one var name covers both
apps. See https://www.react-grab.com/
*/}
{process.env.NODE_ENV === "development" && process.env.VITE_REACT_GRAB && (
<Script
src="//unpkg.com/react-grab/dist/index.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
)}
<ThemeProvider>
<WebProviders locale={locale} resources={resources}>
{children}

View File

@@ -5,14 +5,7 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@multica/core/auth";
import { captureDownloadIntent } from "@multica/core/analytics";
import {
XMark,
GitHubMark,
DiscordMark,
githubUrl,
twitterUrl,
discordUrl,
} from "./shared";
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
import { useLocale, locales, localeLabels } from "../i18n";
export function LandingFooter() {
@@ -53,15 +46,6 @@ export function LandingFooter() {
>
<GitHubMark className="size-4" />
</Link>
<Link
href={discordUrl}
target="_blank"
rel="noreferrer"
aria-label="Discord"
className="text-white/40 transition-colors hover:text-white"
>
<DiscordMark className="size-4" />
</Link>
</div>
<div className="mt-6">
<Link

View File

@@ -7,7 +7,6 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@multica/core/auth";
import { docsHrefForLocale, useLocale } from "../i18n";
import { formatStarCount, useGithubStars } from "../utils/use-github-stars";
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
export function LandingHeader({
@@ -17,8 +16,6 @@ export function LandingHeader({
}) {
const { t, locale } = useLocale();
const user = useAuthStore((s) => s.user);
const stars = useGithubStars();
const starsLabel = stars != null ? formatStarCount(stars) : null;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const docsHref = docsHrefForLocale(locale);
const navLinks = [
@@ -102,7 +99,6 @@ export function LandingHeader({
>
<GitHubMark className="size-3.5" />
{t.header.github}
{starsLabel ? <GitHubStarsBadge label={starsLabel} /> : null}
</Link>
<Link
href={ctaHref}
@@ -149,7 +145,6 @@ export function LandingHeader({
>
<GitHubMark className="size-3.5" />
{t.header.github}
{starsLabel ? <GitHubStarsBadge label={starsLabel} /> : null}
</Link>
</div>
</div>
@@ -158,20 +153,6 @@ export function LandingHeader({
);
}
/** Star-count segment appended to the header's GitHub button — a faint
* divider and the compact count (e.g. "37.6k"). No star glyph: in the GitHub
* button context the number reads as the star count on its own. Inherits the
* button's text color so it adapts to both the dark and light header
* variants. */
function GitHubStarsBadge({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-1.5 tabular-nums">
<span aria-hidden className="h-3 w-px bg-current opacity-25" />
{label}
</span>
);
}
function navLinkClassName(variant: "dark" | "light") {
return cn(
"inline-flex h-9 items-center rounded-[9px] px-3 text-[13px] font-medium transition-colors",

View File

@@ -2,7 +2,6 @@ import { cn } from "@multica/ui/lib/utils";
export const githubUrl = "https://github.com/multica-ai/multica";
export const twitterUrl = "https://x.com/MulticaAI";
export const discordUrl = "https://discord.gg/W8gYBn226t";
export function GitHubMark({ className }: { className?: string }) {
return (
@@ -17,19 +16,6 @@ export function GitHubMark({ className }: { className?: string }) {
);
}
export function DiscordMark({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
fill="currentColor"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
);
}
export function XMark({ className }: { className?: string }) {
return (
<svg

View File

@@ -1,4 +1,4 @@
import { githubUrl, discordUrl } from "../components/shared";
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export function createEnDict(allowSignup: boolean): LandingDict {
@@ -244,7 +244,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{ label: "Documentation", href: "/docs" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "Discord", href: discordUrl },
],
},
company: {
@@ -293,154 +292,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.30",
date: "2026-06-25",
title: "Slack Channel Integration, a Smoother Editor, and Many Reliability Fixes",
changes: [],
features: [
"Slack conversations now run on the new unified collaboration channel, putting Slack on the same reliable footing as Feishu and Lark",
"The Issue composer now accepts the highlighted @mention or suggestion when you press Tab, so picking the right teammate or Issue is a single keypress",
"Task list items can be toggled from a one-click button in the editor's floating menu",
],
improvements: [
"Frontend continuous integration now skips automatically when a pull request does not touch frontend code, freeing up build time for the changes that actually need it",
"Command line subcommands have broader automated test coverage so everyday workflows stay stable across releases",
"Provider-specific default agent arguments now have explicit documentation, and a one-time Lark cutover flag was retired now that the unified channel adapter is fully in production",
],
fixes: [
"OpenClaw is more forgiving about config file mismatches and supports the newer 2026.6.x agents schema, keeping existing OpenClaw runtimes connected",
"Moving an Issue between projects now removes it from the old project list right away, and board column counts stay accurate when an Issue's status changes off-screen",
"Attachment previews open correctly even when files are served from a different origin",
"Command line agents wait for the daemon to be ready before falling back to a personal access token, and the self-host setup flow now respects existing configuration and surfaces server URL changes",
"Lark messages now link to the configured app URL instead of falling back to a generic web address",
"Codex runs clean up correctly even when their output overflows, Kiro runs preserve their goal completion state through close errors, and agent shutdown now terminates the entire opencode process group before closing",
"Quick-create reliably keeps every uploaded file attached when several uploads happen at the same time",
"Redis webhook rate limiting no longer throttles unrelated webhooks together, and daemon skill bundles load reliably even for large skill libraries",
"Issue label names now reject control characters so labels stay readable everywhere",
],
},
{
version: "0.3.29",
date: "2026-06-24",
title: "Feishu Channel Upgrade, Feature Rollout Controls, and More Reliable Autopilots",
changes: [],
features: [
"Feishu conversations now run on a new unified collaboration channel, making message handling more stable and consistent and laying the groundwork for more chat platforms",
"New feature rollout controls cover both the app and the daemon, so teams can open up risky changes gradually and to a limited audience",
"When agents read long Issue discussions, resolved threads now fold down to their key conclusion to keep the context focused",
"Feishu users can start a fresh conversation with the `/new` command, and Feishu WebSocket connections can use a configured proxy",
],
improvements: [
"Scheduled autopilots are more dependable: even with missed schedules, retries, or several runners working at once, they settle on the intended single run",
"Agent runtime briefings can switch to a slimmer version that drops redundant detail, with the full version still available as a fallback",
"Runtime provider docs now match the current provider list, with Qoder, CodeBuddy, and Antigravity guidance added and the outdated Gemini CLI runtime removed",
"The branch or version pinned in a project's repository settings now takes effect during local agent work, so agents no longer end up on the wrong branch",
],
fixes: [
"Sub-Issues now stay in stable creation order inside a parent Issue",
"Attachment previews now open correctly inside Issues",
"The @mention picker now selects the highlighted person or Issue even when search results reorder",
"Cancelled chat drafts stay deleted after you navigate away and come back",
"Autopilot cold starts, the agent status in the Issue header, and Antigravity provider errors now report more accurately",
],
},
{
version: "0.3.28",
date: "2026-06-23",
title: "Staged Sub-Issues and Qoder Runtime Support",
changes: [],
features: [
"Sub-Issues can now be organized into stages, so parallel work moves forward together and the parent Issue is updated only when a stage is complete",
"Assigning or batch-updating an Issue now confirms upfront whether it will start an agent — and which one — so you can apply the change without launching a run; when a run does start, you can attach a handoff note that the agent receives as context for that run",
"Qoder is now available as an agent provider, including model discovery and provider branding",
"Custom runtimes can include fixed launch arguments, with clearer feedback when a saved runtime cannot register",
],
improvements: [
"Project descriptions now travel with agent work, giving agents more durable context from the project they are working in",
"Command line workflows now cover comment resolve actions, Issue usage summaries, and autopilot subscriber management",
"Readonly code blocks include a copy button, and the marketing header now shows the live GitHub star count",
"Agent skill delivery is more efficient for newer daemons while keeping older daemons compatible",
],
fixes: [
"Issue batch edit menus now show the real shared status, priority, and assignee for the selected Issues",
"Dragging Issues across board and list views no longer snaps cards back before settling",
"GitHub PR links and check updates are routed to the workspace that owns the repository",
"Live task transcripts now keep updating while a run is still in progress",
"Custom runtime deletion now removes the saved profile instead of only removing a row that could return later",
],
},
{
version: "0.3.27",
date: "2026-06-22",
title: "Threaded Lark Replies and Smoother Team Workflows",
changes: [],
features: [
"Lark conversations now reply inside the original topic when a message starts from a topic, keeping team discussions easier to follow",
"Squad leaders can see member skills in the roster, making delegation more precise",
"Discord is now available from the website footer, help menu, README, and a dismissible in-app sidebar card",
],
improvements: [
"Agent activity in Issue headers opens on hover, so live work is easier to check at a glance",
"Desktop sidebars and pinned navigation feel smoother, clearer, and less noisy",
"Chat replies, assignment catch-up, and contributor guidance are tighter so agent work stays in the right place with less noise",
"Remote CLI setup and custom runtime deletion now give clearer guidance before users continue",
],
fixes: [
"Backlog parent Issues stay parked when child work finishes, avoiding unexpected follow-up automation",
"Project deletion now requires an owner or admin, and private GitHub skill imports work when a valid token is available",
"Login verification focuses the code field automatically, and detail sidebars no longer animate unexpectedly when pages open",
"Codex and daemon diagnostics are more reliable when permissions or slow task claims need investigation",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "More Reliable Agent Work Across Skills, Autopilots, and Chat",
changes: [],
features: [
"Local skill libraries on a developer machine can now be picked up automatically for agent runs",
"Autopilots can include default subscribers so the right teammates are included when new Issues are created",
"Chat attachments now stay tied to the current workspace and messages can continue sending without blocking the conversation",
"Failed agent comments can be retried directly from the Issue timeline",
],
improvements: [
"Usage reporting is more accurate when the same model name is available from different providers",
"Older Codex usage records can be filled in for more complete usage history",
"Runtime storage reporting is more complete across multiple workspace locations",
"Background task guidance and release checks are stricter, helping catch risky changes earlier",
],
fixes: [
"Issue mention chips in chat and comments now fit their container and no longer overlap nearby text",
"Workspace links now use the correct deployment host more reliably",
"Autopilot run folders are cleaned up after terminal runs finish",
"Desktop builds now handle commit-based version names correctly",
"Tencent CodeBuddy shows the correct provider logo",
"Daemon claim responses are smaller and faster to transfer",
],
},
{
version: "0.3.24",
date: "2026-06-17",
title: "Custom Runtimes",
changes: [],
features: [
"Teams can create custom runtimes so agents use the right local tools and models",
"CLI agent create and update now supports thinking level",
],
improvements: [
"Runtime profiles sync faster and prefer the best match for the current environment",
"Client error and freeze reports now group duplicates",
"Issue trigger previews are easier to read",
],
fixes: [
"Office 365 email delivery is more reliable",
"GitHub installation context and pending CI display are more reliable",
"Codex runs fail quickly when the app server exits",
"Self-healing runtimes can be deleted again, and incompatible models are cleared on runtime switch",
"Unknown Issue icons and plain filenames are handled safely",
],
},
{
version: "0.3.23",
date: "2026-06-16",

View File

@@ -1,4 +1,4 @@
import { githubUrl, discordUrl } from "../components/shared";
import { githubUrl } from "../components/shared";
import { createEnDict } from "./en";
import type { LandingDict } from "./types";
@@ -244,7 +244,6 @@ export function createJaDict(allowSignup: boolean): LandingDict {
{ label: "ドキュメント", href: "/docs/ja" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "Discord", href: discordUrl },
],
},
company: {
@@ -269,154 +268,6 @@ export function createJaDict(allowSignup: boolean): LandingDict {
fixes: "バグ修正",
},
entries: [
{
version: "0.3.30",
date: "2026-06-25",
title: "Slack 連携チャネルの追加、より使いやすいエディター、多数の安定性修正",
changes: [],
features: [
"Slack の会話が新しい統合連携チャネル上で動くようになり、Feishu や Lark と同じ安定感でメッセージをやり取りできます。",
"Issue エディター上で Tab を押すと、ハイライト中の @メンションや候補がそのまま挿入され、相手や Issue を 1 回のキー操作で選べます。",
"エディターのフローティングメニューに追加されたワンクリックボタンで、段落をタスクリストに素早く切り替えられます。",
],
improvements: [
"フロントエンドのコードを変更していないプルリクエストはフロントエンド CI を自動的にスキップし、本当に検証が必要な変更にビルド時間を回せます。",
"コマンドラインのサブコマンドに対する自動テストの範囲が広がり、リリースを重ねても日常の作業フローが安定して動きます。",
"プロバイダーごとのデフォルト エージェント引数を制御する環境変数が公式に文書化され、統合連携チャネルが完全に定着したことで使われなくなった Lark 切り替えスイッチを整理しました。",
],
fixes: [
"OpenClaw が設定ファイルの差異により寛容になり、新しい 2026.6.x の agents スキーマに対応したため、既存の OpenClaw ランタイムが切断されにくくなりました。",
"Issue を別プロジェクトに移すと旧プロジェクトの一覧からすぐに外れ、ボード表示外でステータスが変わってもカラムの件数が正しく揃います。",
"添付ファイルが別オリジンから配信されている場合でも、プレビューが正しく開けます。",
"コマンドライン エージェントはデーモンの準備完了を待ってから個人アクセストークンへフォールバックするため、認証が静かにダウングレードしなくなり、セルフホスティングのセットアップも既存設定を尊重しつつサーバー URL の変更をはっきり知らせます。",
"Lark メッセージの Web リンクは汎用 URL ではなく、設定したアプリ URL を使うようになりました。",
"Codex の実行は出力があふれても適切にクリーンアップされて止まらなくなり、Kiro の実行は終了時にエラーが出ても目標達成状態を保持し、エージェント終了時には opencode プロセスグループ全体を先に終了させてから出力を閉じます。",
"Issue のクイック作成で複数ファイルを同時にアップロードしても、すべての添付が確実に残ります。",
"Redis ベースの Webhook レート制限が無関係な Webhook を巻き込まなくなり、大きなスキルパックを含めてデーモンが安定して読み込めるようになりました。",
"Issue ラベル名は制御文字を受け付けなくなり、どの画面でもラベルが読みやすく保たれます。",
],
},
{
version: "0.3.29",
date: "2026-06-24",
title: "Feishu 連携チャネルの刷新、機能ロールアウト、オートパイロットの信頼性向上",
changes: [],
features: [
"Feishu の会話が新しい統合連携チャネル上で動くようになり、メッセージの送受信がより安定・一貫し、今後さらに多くのチャットプラットフォームを追加しやすくなりました。",
"機能ロールアウトの制御がアプリとデーモンの両方に広がり、チームは影響の大きい変更を段階的かつ限定的に有効化できます。",
"エージェントが長い Issue 議論を読むとき、解決済みスレッドが要点となる結論まで自動で折りたたまれ、コンテキストがより集中します。",
"Feishu では `/new` コマンドで新しい会話を始められ、Feishu の WebSocket 接続には指定のプロキシを使えます。",
],
improvements: [
"スケジュールされたオートパイロットの信頼性が向上しました。取り逃し、再試行、複数ランナーの同時処理があっても、意図したとおり 1 回だけ実行されます。",
"エージェントのランタイム説明は、より簡潔な版に切り替えて冗長な内容を省けます。必要なときは従来の詳しい版に戻せます。",
"ランタイムプロバイダーのドキュメントを現在の対応プロバイダーに合わせ、Qoder・CodeBuddy・Antigravity の案内を追加し、古い Gemini CLI ランタイムを削除しました。",
"プロジェクトのリポジトリ設定で指定したブランチ / バージョンが、ローカルエージェントの作業時に正しく反映され、誤ったブランチを取得しなくなります。",
],
fixes: [
"親 Issue 内の子 Issue が作成順で安定して表示されます。",
"Issue 内の添付ファイルプレビューが正しく開けるようになりました。",
"@メンション候補は、検索結果の並びが変わってもハイライト中の人または Issue を正しく選びます。",
"キャンセルしたチャット下書きを削除すると、画面を移動して戻っても再表示されなくなりました。",
"オートパイロットのコールドスタート、Issue ヘッダーのエージェント状態、Antigravity のプロバイダーエラー表示がより正確になりました。",
],
},
{
version: "0.3.28",
date: "2026-06-23",
title: "子 Issue のステージ対応と Qoder ランタイム対応",
changes: [],
features: [
"子 Issue をステージごとに整理できるようになり、同じステージの作業を並行して進め、ステージ完了時だけ親 Issue に更新できます。",
"Issue を割り当て・一括更新する際に、エージェントを起動するか(どのエージェントか)を事前に確認できるようになり、実行を起こさずに変更だけ適用できます。起動する場合は、その実行のコンテキストとしてエージェントに渡される引き継ぎメモを添えられます。",
"Qoder をエージェントプロバイダーとして選べるようになり、モデル検出とプロバイダー表示にも対応しました。",
"カスタムランタイムに固定の起動引数を設定でき、保存済みランタイムの登録に失敗した場合も分かりやすく表示されます。",
],
improvements: [
"プロジェクト説明がエージェント作業に引き継がれるようになり、プロジェクトの背景をより安定して参照できます。",
"コマンドラインでコメントの解決状態、Issue の利用状況サマリー、オートパイロットの購読者管理を扱えるようになりました。",
"読み取り専用コードブロックにコピーボタンが付き、ウェブサイトの GitHub ボタンには現在のスター数が表示されます。",
"新しいデーモンではエージェントスキルの受け渡しがより効率的になり、古いデーモンとの互換性も保たれます。",
],
fixes: [
"Issue の一括編集メニューで、選択した Issue に共通するステータス、優先度、担当者が正しく表示されます。",
"ボードやリストで Issue をドラッグしても、カードが元の位置に戻ってから移動するちらつきが起きにくくなりました。",
"GitHub PR のリンクとチェック更新は、そのリポジトリを所有するワークスペースに正しく届きます。",
"実行中のタスク記録ダイアログは、タスク完了やページ更新を待たずに新しい内容を表示し続けます。",
"カスタムランタイムの削除は、後から戻ってくる可能性のある行だけでなく保存済み設定を削除します。",
],
},
{
version: "0.3.27",
date: "2026-06-22",
title: "Lark のスレッド返信とチームワークフローの改善",
changes: [],
features: [
"Lark のトピックから始まった会話は元のトピック内に返信され、議論の流れを追いやすくなりました。",
"小隊リーダーはメンバーのスキルを一覧で確認でき、より適切に作業を任せられます。",
"Discord への入口がウェブサイトのフッター、ヘルプメニュー、README、閉じられるアプリ内サイドバーカードに追加されました。",
],
improvements: [
"Issue ヘッダーのエージェント活動はホバーで開けるようになり、進行中の作業をすばやく確認できます。",
"デスクトップのサイドバーと固定ナビゲーションがよりなめらかで分かりやすくなり、表示のノイズが減りました。",
"チャット返信、割り当て時のコメント確認、コントリビューター向けガイドが整理され、エージェント作業が適切な場所に収まりやすくなりました。",
"リモート環境での CLI セットアップとカスタムランタイム削除の案内がより分かりやすくなりました。",
],
fixes: [
"親 Issue がバックログにある場合、子タスク完了後も意図しない自動処理は起動しません。",
"プロジェクト削除にはオーナーまたは管理者権限が必要になり、有効なトークンがあれば private GitHub リポジトリからのスキル取り込みも動作します。",
"ログイン確認コード欄に自動でフォーカスし、詳細ページを開くときのサイドバーも意図せず動きません。",
"Codex の権限処理とデーモンの遅いタスク受け取り診断がより信頼しやすくなりました。",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "スキル、オートパイロット、チャットでのエージェント作業をより信頼性高く",
changes: [],
features: [
"開発者のマシンにあるローカルスキルライブラリを自動で見つけ、エージェント実行で使いやすくなりました。",
"オートパイロットに既定の購読者を設定でき、新しい Issue の確認に必要なチームメイトを含めやすくなりました。",
"チャット添付ファイルは現在のワークスペースに結び付き、送信中も会話を続けやすくなりました。",
"失敗したエージェントコメントを Issue タイムラインから直接再試行できます。",
],
improvements: [
"同じモデル名が複数のプロバイダーから提供される場合も、利用状況がより正確に集計されます。",
"過去の Codex 利用状況を補完でき、利用履歴がより完全になります。",
"複数のワークスペース場所にまたがるランタイムのストレージ使用量が分かりやすくなりました。",
"バックグラウンドタスクの案内とリリース前チェックがより厳密になり、リスクのある変更を早く見つけやすくなりました。",
],
fixes: [
"チャットとコメント内の Issue メンションチップが枠内に収まり、周辺テキストと重なりません。",
"ワークスペースリンクが正しいデプロイ先ホストをより安定して使います。",
"オートパイロット実行フォルダーは、ターミナル実行が終わると片付けられます。",
"デスクトップビルドはコミット由来のバージョン名を正しく扱います。",
"Tencent CodeBuddy に正しいプロバイダーロゴが表示されます。",
"デーモンのタスク受け取り応答が小さくなり、転送が速くなりました。",
],
},
{
version: "0.3.24",
date: "2026-06-17",
title: "カスタムランタイム",
changes: [],
features: [
"チームはカスタムランタイムで、エージェントに合うローカルツールとモデルを使えます。",
"コマンドラインでエージェントを作成または更新するときに思考レベルを選べます。",
],
improvements: [
"ランタイムプロファイルの同期が速くなり、現在の環境に合うものが優先されます。",
"クライアントのエラーやフリーズ報告の重複が減りました。",
"Issue コメントのトリガープレビューが読みやすくなりました。",
],
fixes: [
"Office 365 メールの代替送信がより安定しました。",
"GitHub のインストール情報と CI 待機表示がより安定しました。",
"Codex サービスが終了したときはすばやく失敗します。",
"自己修復ランタイムを再び削除でき、合わないモデル選択は整理されます。",
"不明な Issue アイコンと通常のファイル名リンクを安全に扱います。",
],
},
{
version: "0.3.23",
date: "2026-06-16",

View File

@@ -1,4 +1,4 @@
import { githubUrl, discordUrl } from "../components/shared";
import { githubUrl } from "../components/shared";
import { createEnDict } from "./en";
import type { LandingDict } from "./types";
@@ -243,7 +243,6 @@ export function createKoDict(allowSignup: boolean): LandingDict {
{ label: "문서", href: "/docs/ko" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "Discord", href: discordUrl },
],
},
company: {
@@ -268,154 +267,6 @@ export function createKoDict(allowSignup: boolean): LandingDict {
fixes: "버그 수정",
},
entries: [
{
version: "0.3.30",
date: "2026-06-25",
title: "Slack 협업 채널 추가, 더 편한 에디터, 다수의 안정성 개선",
changes: [],
features: [
"Slack 대화가 새로운 통합 협업 채널 위에서 동작해 Feishu·Lark와 동일한 안정성으로 메시지를 주고받을 수 있습니다.",
"Issue 작성기에서 Tab을 누르면 현재 강조된 @멘션이나 추천 항목이 바로 입력되어, 동료나 Issue를 한 번의 키 입력으로 고를 수 있습니다.",
"에디터의 플로팅 메뉴에 추가된 원클릭 버튼으로 단락을 할 일 목록으로 빠르게 전환할 수 있습니다.",
],
improvements: [
"프런트엔드 코드를 건드리지 않은 풀 리퀘스트는 프런트엔드 CI를 자동으로 건너뛰어, 실제로 검증이 필요한 변경에 빌드 시간이 돌아갑니다.",
"명령줄 서브커맨드의 자동화 테스트 범위가 넓어져서, 릴리스를 거듭해도 일상적인 작업 흐름이 안정적으로 유지됩니다.",
"제공자별 기본 에이전트 인자 환경 변수에 대한 공식 문서가 추가되었고, 통합 협업 채널이 완전히 정착함에 따라 일회성 Lark 전환 스위치를 정리했습니다.",
],
fixes: [
"OpenClaw가 설정 파일 차이에 더 너그러워졌고, 새로운 2026.6.x agents 스키마를 지원해 기존 OpenClaw 런타임이 끊기지 않습니다.",
"Issue를 다른 프로젝트로 옮기면 이전 프로젝트 목록에서 즉시 빠지고, 보드 화면 밖에서 상태가 바뀌어도 컬럼 카운트가 정확하게 맞춰집니다.",
"파일이 다른 출처에서 제공되더라도 첨부 파일 미리보기가 정상적으로 열립니다.",
"명령줄 에이전트는 데몬이 준비된 뒤에야 개인 액세스 토큰으로 폴백하므로 인증이 조용히 다운그레이드되지 않고, 자체 호스팅 설정도 기존 구성을 존중하면서 서버 URL 변경을 분명히 보여 줍니다.",
"Lark 메시지의 웹 링크는 일반 주소로 떨어지지 않고, 구성된 앱 URL을 사용합니다.",
"Codex 실행은 출력이 넘쳐도 깔끔하게 정리되어 더 이상 멈추지 않고, Kiro 실행은 종료 중 오류가 발생해도 목표 완료 상태를 유지하며, 에이전트 종료 시에는 opencode 프로세스 그룹 전체를 먼저 끝낸 뒤 출력을 닫습니다.",
"Issue 빠른 생성 시 동시에 업로드되는 여러 파일이 안정적으로 모두 첨부됩니다.",
"Redis 기반 Webhook 속도 제한이 더 이상 서로 무관한 Webhook을 한꺼번에 제한하지 않으며, 데몬은 큰 스킬 묶음도 안정적으로 로드합니다.",
"Issue 라벨 이름은 제어 문자를 거부해 모든 화면에서 라벨이 깔끔하게 표시됩니다.",
],
},
{
version: "0.3.29",
date: "2026-06-24",
title: "Feishu 협업 채널 개선, 기능 출시 제어, 더 안정적인 오토파일럿",
changes: [],
features: [
"Feishu 대화가 새로운 통합 협업 채널에서 동작해 메시지 송수신이 더 안정적이고 일관되며, 앞으로 더 많은 채팅 플랫폼을 붙이기 쉬워졌습니다.",
"기능 출시 제어가 앱과 데몬 양쪽으로 확장되어, 팀이 영향이 큰 변경을 단계적으로 그리고 제한된 범위로 켤 수 있습니다.",
"에이전트가 긴 Issue 토론을 읽을 때 해결된 스레드가 핵심 결론으로 자동으로 접혀 컨텍스트가 더 집중됩니다.",
"Feishu 사용자는 `/new` 명령으로 새 대화를 시작할 수 있고, Feishu WebSocket 연결은 지정한 프록시를 사용할 수 있습니다.",
],
improvements: [
"예약 오토파일럿의 안정성이 향상되었습니다. 놓친 일정, 재시도, 여러 러너의 동시 처리가 있어도 의도한 대로 한 번만 실행됩니다.",
"에이전트 런타임 안내를 더 간결한 버전으로 전환해 불필요한 내용을 줄일 수 있으며, 필요하면 기존의 자세한 버전으로 되돌릴 수 있습니다.",
"런타임 제공자 문서를 현재 지원 목록에 맞추고 Qoder·CodeBuddy·Antigravity 안내를 추가했으며, 오래된 Gemini CLI 런타임을 제거했습니다.",
"프로젝트 저장소 설정에서 지정한 브랜치 / 버전이 로컬 에이전트 작업에 올바르게 반영되어, 더 이상 잘못된 브랜치를 가져오지 않습니다.",
],
fixes: [
"부모 Issue 안의 하위 Issue가 생성 순서대로 안정적으로 표시됩니다.",
"Issue 안의 첨부 파일 미리보기가 올바르게 열립니다.",
"@멘션 선택기는 검색 결과 순서가 바뀌어도 현재 강조된 사람이나 Issue를 정확히 선택합니다.",
"취소한 채팅 초안을 삭제하면 화면을 이동했다가 돌아와도 다시 나타나지 않습니다.",
"오토파일럿 콜드 스타트, Issue 헤더의 에이전트 상태, Antigravity 제공자 오류 표시가 더 정확해졌습니다.",
],
},
{
version: "0.3.28",
date: "2026-06-23",
title: "하위 Issue 단계 지원과 Qoder 런타임 지원",
changes: [],
features: [
"하위 Issue를 단계별로 정리할 수 있어 같은 단계의 작업을 함께 진행하고, 단계가 끝났을 때만 부모 Issue가 업데이트됩니다.",
"이제 Issue를 할당하거나 일괄 업데이트할 때 에이전트를 시작할지(어떤 에이전트인지)를 먼저 확인할 수 있어, 실행을 일으키지 않고 변경만 적용할 수 있습니다. 시작할 때는 그 실행의 컨텍스트로 에이전트에 전달되는 인계 메모를 첨부할 수 있습니다.",
"Qoder를 에이전트 제공자로 선택할 수 있으며, 모델 검색과 제공자 표시도 함께 지원됩니다.",
"사용자 지정 런타임에 고정 실행 인수를 둘 수 있고, 저장된 런타임이 등록되지 못할 때 더 명확한 피드백을 제공합니다.",
],
improvements: [
"프로젝트 설명이 에이전트 작업에 함께 전달되어 프로젝트 배경을 더 안정적으로 참고할 수 있습니다.",
"명령줄에서 댓글 해결 상태, Issue 사용량 요약, 오토파일럿 구독자 관리를 처리할 수 있습니다.",
"읽기 전용 코드 블록에 복사 버튼이 추가되고, 웹사이트의 GitHub 버튼은 실시간 스타 수를 보여 줍니다.",
"새 데몬에서는 에이전트 스킬 전달이 더 효율적이며, 이전 데몬과의 호환성도 유지됩니다.",
],
fixes: [
"Issue 일괄 편집 메뉴가 선택한 Issue들의 공통 상태, 우선순위, 담당자를 올바르게 보여 줍니다.",
"보드와 목록에서 Issue를 드래그할 때 카드가 원래 위치로 되돌아갔다가 다시 이동하는 깜박임이 줄었습니다.",
"GitHub PR 연결과 체크 업데이트가 해당 저장소를 소유한 워크스페이스로 올바르게 전달됩니다.",
"실행 중인 작업 기록 대화상자가 작업 종료나 페이지 새로 고침 없이도 계속 최신 내용을 보여 줍니다.",
"사용자 지정 런타임 삭제가 나중에 다시 나타날 수 있는 행만 지우지 않고 저장된 설정을 삭제합니다.",
],
},
{
version: "0.3.27",
date: "2026-06-22",
title: "Lark 스레드 답장과 팀 작업 흐름 개선",
changes: [],
features: [
"Lark 토픽에서 시작된 대화는 이제 원래 토픽 안에 답장되어 팀 논의를 더 쉽게 따라갈 수 있습니다.",
"스쿼드 리더가 멤버의 스킬을 명단에서 바로 확인할 수 있어 작업 위임이 더 정확해졌습니다.",
"Discord 진입점이 웹사이트 푸터, 도움말 메뉴, README, 닫을 수 있는 앱 사이드바 카드에 추가되었습니다.",
],
improvements: [
"Issue 헤더의 에이전트 활동 상태가 hover로 열려 진행 중인 작업을 더 빠르게 확인할 수 있습니다.",
"데스크톱 사이드바와 고정 탐색이 더 부드럽고 명확해져 화면의 불필요한 시각 소음이 줄었습니다.",
"채팅 답변, 할당 시 댓글 확인, 기여자 안내가 정리되어 에이전트 작업이 올바른 위치에 머물기 쉬워졌습니다.",
"원격 CLI 설정과 사용자 지정 런타임 삭제 안내가 더 명확해졌습니다.",
],
fixes: [
"부모 Issue가 백로그에 있을 때는 하위 작업이 완료되어도 원치 않는 후속 자동화가 시작되지 않습니다.",
"프로젝트 삭제는 소유자 또는 관리자만 할 수 있으며, 유효한 토큰이 있으면 private GitHub 저장소의 스킬 가져오기도 동작합니다.",
"로그인 인증 코드 입력칸에 자동으로 포커스되고, 상세 화면을 열 때 사이드바가 의도치 않게 움직이지 않습니다.",
"Codex 권한 처리와 데몬의 느린 작업 수신 진단이 더 신뢰할 수 있게 개선되었습니다.",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "스킬, 오토파일럿, 채팅 전반의 에이전트 작업 안정성 강화",
changes: [],
features: [
"개발자 기기의 로컬 스킬 라이브러리를 자동으로 찾아 에이전트 실행에서 더 쉽게 사용할 수 있습니다.",
"오토파일럿에 기본 구독자를 설정할 수 있어 새 Issue를 만들 때 필요한 팀원을 함께 포함하기 쉽습니다.",
"채팅 첨부 파일이 현재 워크스페이스에 연결되고, 메시지를 보내는 동안에도 대화를 계속하기 쉬워졌습니다.",
"실패한 에이전트 댓글을 Issue 타임라인에서 바로 다시 시도할 수 있습니다.",
],
improvements: [
"같은 모델 이름이 여러 제공자에서 제공될 때도 사용량 보고가 더 정확합니다.",
"이전 Codex 사용 기록을 보완해 사용량 이력을 더 완전하게 만들 수 있습니다.",
"여러 워크스페이스 위치에 걸친 런타임 저장 공간 사용량을 더 명확하게 보여 줍니다.",
"백그라운드 작업 안내와 릴리스 전 검사가 더 엄격해져 위험한 변경을 더 일찍 찾을 수 있습니다.",
],
fixes: [
"채팅과 댓글의 Issue 멘션 칩이 영역 안에 맞게 표시되고 주변 텍스트와 겹치지 않습니다.",
"워크스페이스 링크가 올바른 배포 호스트를 더 안정적으로 사용합니다.",
"오토파일럿 실행 폴더는 터미널 실행이 끝난 뒤 정리됩니다.",
"데스크톱 빌드는 커밋 기반 버전 이름을 올바르게 처리합니다.",
"Tencent CodeBuddy에 올바른 제공자 로고가 표시됩니다.",
"데몬의 작업 수신 응답이 더 작아져 전송이 빨라졌습니다.",
],
},
{
version: "0.3.24",
date: "2026-06-17",
title: "사용자 지정 런타임",
changes: [],
features: [
"팀은 사용자 지정 런타임으로 에이전트에 맞는 로컬 도구와 모델을 사용할 수 있습니다.",
"명령줄에서 에이전트를 만들거나 업데이트할 때 사고 수준을 선택할 수 있습니다.",
],
improvements: [
"런타임 프로필이 더 빠르게 동기화되고 현재 환경에 맞게 우선 적용됩니다.",
"클라이언트 오류와 멈춤 보고의 중복이 줄었습니다.",
"Issue 댓글 트리거 미리보기가 더 읽기 쉬워졌습니다.",
],
fixes: [
"Office 365 메일의 대체 전송이 더 안정적입니다.",
"GitHub 설치 맥락과 CI 대기 상태 표시가 더 안정적입니다.",
"Codex 서비스가 종료되면 빠르게 실패합니다.",
"셀프 힐링 런타임을 다시 삭제할 수 있고, 맞지 않는 모델 선택은 정리됩니다.",
"알 수 없는 Issue 아이콘과 일반 파일 이름 링크 처리가 더 안전해졌습니다.",
],
},
{
version: "0.3.23",
date: "2026-06-16",

View File

@@ -1,4 +1,4 @@
import { githubUrl, discordUrl } from "../components/shared";
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export function createZhDict(allowSignup: boolean): LandingDict {
@@ -244,7 +244,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{ label: "\u6587\u6863", href: "/docs/zh" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "Discord", href: discordUrl },
],
},
company: {
@@ -293,154 +292,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.30",
date: "2026-06-25",
title: "Slack 协作通道接入,编辑器更顺手,多项稳定性修复",
changes: [],
features: [
"Slack 对话接入全新的统一协作通道与飞书、Lark 一样稳定,消息收发更可靠",
"在 Issue 编辑器里按 Tab可以直接选中当前高亮的 @ 提及或建议项,挑选同事或 Issue 一键完成",
"在编辑器的浮动菜单里新增一键开关,能够快速把段落切换成任务清单",
],
improvements: [
"前端持续集成会自动跳过没有改动前端代码的 PR把构建时间留给真正需要的改动",
"命令行子命令的自动化测试覆盖更广,让日常工作流在每次发版后依然稳定",
"为每个服务商默认的智能体启动参数补齐说明文档,并下线了一次性的飞书切换开关——统一协作通道已经在生产环境完全接管",
],
fixes: [
"OpenClaw 对配置文件差异更宽容,并且支持新版 2026.6.x 的 agents 配置格式,已有的 OpenClaw 运行时不会因此掉线",
"把 Issue 移动到其他项目时,会立刻从原来的项目列表里消失;并且在 Issue 状态从看板视野外切换时,看板列上的数字也会正确同步",
"当附件由不同来源的资源服务器提供时,预览也可以正常打开",
"命令行智能体会等待守护进程就绪后再决定鉴权来源,避免悄悄回落到个人访问令牌;自托管环境配置流程也会沿用现有设置并清晰展示服务地址的变化",
"飞书消息中的网页链接现在会指向你配置的应用 URL而不是回退到通用网址",
"Codex 任务在输出过载时也能正常清理不会再卡住Kiro 任务即便关闭过程中出现错误,也能保留目标完成状态;智能体退出时会先终止整组 opencode 子进程,再关闭输出",
"在快速创建 Issue 时同时上传多个文件,所有附件都会稳定地保留下来",
"Redis 上的 Webhook 限流不会再把无关的 Webhook 合并计算,避免被一起误伤;守护进程加载多个 skill 包时,即便 skill 体积较大也能稳定完成",
"Issue 标签名不再接受控制字符,标签在各端展示都更整洁可读",
],
},
{
version: "0.3.29",
date: "2026-06-24",
title: "飞书协作通道升级,新增功能灰度发布,定时自动化更可靠",
changes: [],
features: [
"飞书对话升级到全新的统一协作通道,消息收发更稳定一致,也为后续接入更多聊天平台打下基础",
"新增功能灰度能力,覆盖应用和守护进程两侧,团队可以分阶段、小范围地开放高风险改动",
"智能体阅读很长的 Issue 讨论时,会自动把已解决的讨论折叠到关键结论,让上下文更聚焦",
"飞书用户可以用 `/new` 开启新会话,飞书的 WebSocket 连接也支持配置代理",
],
improvements: [
"定时自动化更可靠:遇到漏跑、重试或多个执行端同时处理时,也能稳定地只按预期执行一次",
"智能体运行的开场说明可以切换到更精简的版本,去掉冗余内容,必要时仍可切回完整版本",
"运行时服务商文档已更新到当前支持的服务商,新增 Qoder、CodeBuddy、Antigravity 说明,并移除过时的 Gemini CLI 信息",
"项目仓库设置里指定的分支 / 版本,现在会在本地智能体工作时正确生效,不会再拿到错误的分支",
],
fixes: [
"父 Issue 下的子 Issue 现在会按创建顺序稳定展示",
"Issue 内的附件预览现在可以正常打开",
"@ 提及时即使搜索结果重新排序,也会准确选中当前高亮的人或 Issue",
"删除已取消的聊天草稿后,切换页面再回来不会再次出现",
"自动化冷启动、Issue 顶部智能体状态和 Antigravity 服务商错误提示更准确",
],
},
{
version: "0.3.28",
date: "2026-06-23",
title: "子 Issue 支持分阶段,新增 Qoder 运行时支持",
changes: [],
features: [
"子 Issue 现在可以按阶段组织,同一阶段的工作可以并行推进,父 Issue 只会在整个阶段完成后收到更新",
"现在指派或批量更新 Issue 时,会先确认这次操作是否会启动智能体、启动的是哪一个,让你可以只改动而不触发运行;确认启动时,还能附上一段交接说明,作为智能体这次运行的开场上下文",
"Qoder 现在可以作为智能体服务商使用,并带有模型发现和服务商品牌展示",
"自定义运行时可以配置固定启动参数;保存的运行时无法注册时,也会给出更清楚的提示",
],
improvements: [
"项目描述现在会跟随智能体工作一起提供,让智能体获得更稳定的项目上下文",
"命令行现在支持处理评论解决状态、查看 Issue 用量汇总,以及管理自动任务订阅人",
"只读代码块新增复制按钮,官网页头的 GitHub 按钮也会显示实时星标数",
"新版守护进程获取智能体技能时更高效,同时继续兼容旧版本守护进程",
],
fixes: [
"批量编辑 Issue 时,菜单现在会正确显示所选 Issue 共有的状态、优先级和指派人",
"在看板和列表中拖动 Issue 时,卡片不会再先跳回原位再移动到目标位置",
"GitHub PR 关联和检查更新会路由到真正拥有该仓库的工作空间",
"运行中的任务记录弹窗现在会持续更新,不必等任务结束或刷新页面",
"删除自定义运行时时会删除保存的配置,而不是只删除之后可能重新出现的运行时行",
],
},
{
version: "0.3.27",
date: "2026-06-22",
title: "Lark 话题回复和团队协作流程优化",
changes: [],
features: [
"Lark 里的话题消息现在会回到原话题中,团队讨论更容易保持上下文",
"小队负责人现在可以在成员列表里看到成员技能,分配任务时更容易选对人",
"Discord 入口已加入官网页脚、帮助菜单、README以及可关闭的应用侧边栏卡片",
],
improvements: [
"Issue 顶部的智能体活动状态现在悬停即可展开,更方便快速查看当前进展",
"桌面侧边栏和固定导航更顺滑、更清爽,减少不必要的视觉干扰",
"聊天回复、任务分配补读和贡献者指引更克制,智能体工作更容易留在正确位置",
"远程命令行初始化和自定义运行时删除现在会给出更清楚的操作提示",
],
fixes: [
"父 Issue 仍在待办池时,子任务完成不会意外唤起后续自动处理",
"删除项目现在需要所有者或管理员权限;私有 GitHub 仓库的技能导入在配置有效令牌后可以正常完成",
"登录验证码输入框会自动聚焦,进入详情页时侧边栏也不会再意外播放动画",
"Codex 权限处理和守护进程慢任务诊断更可靠,排查问题时信息更完整",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "让技能、自动任务和聊天中的智能体工作更可靠",
changes: [],
features: [
"开发者机器上的本地技能库现在可以被自动识别,智能体运行时更容易复用团队能力",
"自动任务可以配置默认订阅人,新建 Issue 时更容易把相关队友带入确认",
"聊天附件会绑定到当前工作空间,发送消息时也不会阻塞后续对话",
"智能体评论发送失败后,可以直接在 Issue 时间线里重试",
],
improvements: [
"同名模型来自不同服务商时,使用量统计会更准确",
"历史 Codex 使用量可以补齐,用量记录更完整",
"运行时存储统计会覆盖更多工作目录,空间占用更清楚",
"后台任务指引和发版检查更严格,可以更早发现高风险改动",
],
fixes: [
"聊天和评论里的 Issue 提及标签会适配容器宽度,不再和周围文字重叠",
"工作空间链接会更稳定地使用正确的部署域名",
"自动任务运行结束后,会清理对应的运行目录",
"桌面端可以正确处理基于提交版本的版本号",
"Tencent CodeBuddy 会显示正确的服务商图标",
"守护进程领取任务的响应更小,传输更快",
],
},
{
version: "0.3.24",
date: "2026-06-17",
title: "自定义运行时",
changes: [],
features: [
"团队可以创建自定义运行时,让智能体按环境使用合适的本地工具和模型",
"命令行创建和更新智能体时可以选择思考强度",
],
improvements: [
"运行时配置会更快同步到应用,并优先匹配当前环境",
"客户端错误和卡顿反馈会合并重复信息",
"Issue 评论触发预览文案更清楚",
],
fixes: [
"Office 365 邮件的备用发送方式更稳定",
"GitHub 安装上下文和 CI 等待状态显示更可靠",
"Codex 服务退出时会快速失败",
"自修复运行时可再次删除,切换运行时时会清理不兼容模型",
"未知 Issue 图标和普通文件名链接识别更安全",
],
},
{
version: "0.3.23",
date: "2026-06-16",

View File

@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import { formatStarCount } from "./use-github-stars";
describe("formatStarCount", () => {
it("renders counts below 1,000 exactly", () => {
expect(formatStarCount(0)).toBe("0");
expect(formatStarCount(7)).toBe("7");
expect(formatStarCount(999)).toBe("999");
});
it("formats thousands with one decimal, GitHub-style", () => {
expect(formatStarCount(37_600)).toBe("37.6k");
expect(formatStarCount(1_234)).toBe("1.2k");
expect(formatStarCount(12_300)).toBe("12.3k");
});
it("trims a trailing .0 ('1k', not '1.0k')", () => {
expect(formatStarCount(1_000)).toBe("1k");
expect(formatStarCount(2_000)).toBe("2k");
});
it("rounds to one decimal like the repo header", () => {
expect(formatStarCount(1_949)).toBe("1.9k");
expect(formatStarCount(1_990)).toBe("2k");
});
it("formats millions with an 'm' suffix", () => {
expect(formatStarCount(1_200_000)).toBe("1.2m");
expect(formatStarCount(2_000_000)).toBe("2m");
});
});

View File

@@ -1,85 +0,0 @@
"use client";
import { useEffect, useState } from "react";
/**
* Live GitHub star count for the landing header's "GitHub" button.
*
* Fetched client-side on purpose: the badge lives in the shared
* {@link LandingHeader}, which renders on every marketing page, so a single
* client fetch covers them all without threading a server value through eight
* render sites. Each visitor calls the GitHub API from their own IP, which
* sidesteps the shared-outbound-IP rate limit that the server-side
* `github-release.ts` fetcher has to work around with a PAT.
*
* The result is memoized at module scope (plus an in-flight promise) so
* client-side navigation between landing pages reuses the first fetch instead
* of hitting the API again. A failed fetch caches `null` so we don't retry in
* a loop; the button just degrades to its plain "GitHub" label.
*/
const REPO = "multica-ai/multica";
// `undefined` = never fetched; `number` = resolved count; `null` = fetch failed.
let cachedStars: number | null | undefined;
let inFlight: Promise<number | null> | null = null;
async function loadStars(): Promise<number | null> {
if (cachedStars !== undefined) return cachedStars;
if (inFlight) return inFlight;
inFlight = fetch(`https://api.github.com/repos/${REPO}`, {
headers: { Accept: "application/vnd.github+json" },
})
.then((res) => {
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`);
return res.json() as Promise<{ stargazers_count?: unknown }>;
})
.then((data) => {
const count =
typeof data.stargazers_count === "number" ? data.stargazers_count : null;
cachedStars = count;
return count;
})
.catch(() => {
cachedStars = null;
return null;
})
.finally(() => {
inFlight = null;
});
return inFlight;
}
export function useGithubStars(): number | null {
const [stars, setStars] = useState<number | null>(cachedStars ?? null);
useEffect(() => {
let active = true;
void loadStars().then((count) => {
if (active && count != null) setStars(count);
});
return () => {
active = false;
};
}, []);
return stars;
}
/**
* Compact star count matching GitHub's own repo-header style: one decimal
* thousands/millions with the trailing ".0" trimmed ("1k", "37.6k", "1.2m").
* Counts below 1,000 render exactly. Mirrors GitHub's `toFixed(1)` rounding so
* our badge reads the same as the figure on the repo page.
*/
export function formatStarCount(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return String(n);
}

View File

@@ -11,11 +11,6 @@ if (typeof globalThis.ResizeObserver === "undefined") {
} as unknown as typeof ResizeObserver;
}
// jsdom doesn't implement elementFromPoint; input-otp uses it internally.
if (typeof document.elementFromPoint !== "function") {
document.elementFromPoint = () => null;
}
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
// Create a proper localStorage mock if methods are missing.
if (

View File

@@ -1,91 +0,0 @@
# Codex Usage Cache Backfill
This runbook describes the one-time hosted data repair for Codex usage rows
created before cached input was normalized at ingestion time.
Do not run this as an automatic database migration. The write step needs an
operator-selected cutoff, dry-run review, and an explicit execute command.
## When To Run
Run this only after the backend image containing `backfill_codex_usage_cache`
has been deployed, and only for databases that need historical Codex usage
correction.
Use the actual hosted deployment time of PR #4083 as `--cutoff`. Do not use the
PR merge time unless it is also the real production cutover time.
## Execution Model
Run the command from the released backend image as a one-time operator job, such
as a Kubernetes Job with the normal backend database secret and network access.
Override the command to execute `./backfill_codex_usage_cache`.
The command defaults to dry-run. It mutates data only when `--execute` is passed.
## Dry Run
First run:
```bash
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME>
```
Optionally limit scope while validating:
```bash
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME> --workspace-id <workspace-uuid>
```
Review the per-workspace/date output:
- `rows`
- `input_before`
- `input_after`
- `input_tokens_removed`
- `clamped_rows`
Proceed only if the totals match the expected overcount shape.
## Execute
After dry-run review:
```bash
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME> --execute
```
For large datasets, throttle writes:
```bash
./backfill_codex_usage_cache \
--cutoff <RFC3339_DEPLOY_TIME> \
--execute \
--batch-size 500 \
--sleep-between-batches 1s
```
By default, execution rebuilds affected hourly rollups by calling
`rollup_task_usage_hourly_window(...)` for the database update window. Leave
`--rebuild-rollup=true` unless an operator intentionally plans a separate rollup
rebuild.
## Verification
After execution, run the dry-run command again with the same cutoff and scope.
Eligible rows should be zero.
Then verify Usage / Runtime dashboard periods that were previously inflated.
## Safety Boundaries
The command updates only rows that match all of these conditions:
- `provider = 'codex'`
- `cache_read_tokens > 0`
- `input_tokens > 0`
- `COALESCE(updated_at, created_at) < --cutoff`
- optional `--workspace-id` match
Rows without persisted `cache_read_tokens` are intentionally ignored because the
current database cannot accurately reconstruct cached input for them.

View File

@@ -1,61 +0,0 @@
# Custom runtimes
Custom runtime profiles let a workspace register an AI CLI that speaks one of
Multica's supported protocol families but is launched through a team-specific
command.
## Command and arguments
Paste the same argv-style command you would run in a terminal:
```sh
agent --model composer-2.5
```
Multica stores this as:
- `command_name`: `agent`
- `fixed_args`: `["--model", "composer-2.5"]`
The daemon starts the process directly with `exec.Command(command_name,
fixed_args...)`; it does not run a shell.
Supported input:
- plain arguments separated by whitespace
- single or double quotes for values with spaces
- backslash escaping for literal spaces or quote characters
The UI parser is argv-oriented, not a full POSIX shell. Inside double quotes,
`\` escapes the next character directly; use single quotes when you need `$` or
backticks to stay literal. Running tasks keep the launch args they started with;
profile command or argument edits apply to newly claimed tasks after the daemon
re-registers.
Unsupported input:
- pipes, redirects, `;`, `&&`, `||`
- backticks
- `$VAR` or `$(...)` expansion
Use a wrapper script when the runtime needs shell behavior.
## Command not found
Desktop-launched daemons may not inherit the same `PATH` as an interactive
terminal. If a custom runtime shows a registration error even though the command
works in your shell, pin the absolute path on that machine:
```sh
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
```
Then restart or refresh the daemon so it re-registers the profile.
## Upgrade order
Custom runtime arguments and registration-error reporting require both the
server and daemon versions that support `fixed_args` launch specs and
`failed_profiles` registration reports. In mixed deployments, upgrade the server
before rolling out newer daemons so failed custom-only profiles can be recorded
instead of being rejected as an empty runtime registration.

View File

@@ -1,252 +0,0 @@
# Feature Flags
Multica ships a framework-level feature flag implementation:
- **Backend**: `server/pkg/featureflag` — Go package.
- **Frontend**: `@multica/core/feature-flags` — TypeScript module with React hooks.
Both sides share the same vocabulary (`Decision`, `EvalContext`, `Rule`, `PercentRollout`) and the same FNV-1a percent bucketing, so a flag evaluated on the server and on the client lands in the same bucket for the same user.
The package is designed so new features can adopt feature flags without writing any infrastructure code — drop a rule into the static config, call `Service.IsEnabled` / `useFlag`, done.
---
## Core concepts
```
[Toggle Point] --query--> [Service / Router] --read--> [Provider / Configuration]
business code static / env / chain
```
- A **Toggle Point** is the single `if` in business code. It always calls the Service, never the provider directly.
- The **Service** (`Service` in Go, `FeatureFlagService` in TS) is the router. Business code never depends on which provider is behind it.
- A **Provider** is the configuration backend. Today we ship `StaticProvider` (in-memory rules), `EnvProvider` (Go only — env-var override), and `ChainProvider` (composition). A future DB or LaunchDarkly provider plugs in without changing any caller.
- A **Decision** is the structured result: `{ enabled, variant, reason, source }`. `IsEnabled` is the boolean projection, `Variant` is the raw string. Use `Decision` for diagnostic endpoints.
Four flag categories (Martin Fowler):
| Category | Lifetime | Owner | Example |
|---|---|---|---|
| **Release** | Daysweeks | Engineering | Hide a half-finished page behind `flags_release_v2` |
| **Experiment** | Hoursweeks | Product / Data | A/B test `checkout_algo` between `control` and `experiment-v2` |
| **Ops** | Short or evergreen | SRE | Kill switch `billing_disable_invoice_pdf` |
| **Permission** | Years | Product | `plan_gate_enterprise_dashboard` |
Manage them in the same provider but treat them differently: Release flags get deleted; Ops flags need fast override paths (`FF_<KEY>` env var); Permission flags use `Allow` lists; Experiment flags use `PercentRollout`.
---
## Backend (Go)
### Wiring at startup
The server constructs a `featureflag.Service` once in `cmd/server/main.go` via the standard helper:
```go
flags, err := featureflag.NewServiceFromEnv(featureflag.WithLogger(slog.Default()))
if err != nil {
slog.Error("feature flag configuration failed to load", "error", err)
os.Exit(1)
}
```
`NewServiceFromEnv` reads two env vars — both follow the same `MULTICA_*_FILE` / `FF_*` conventions documented in `.env.example`:
| Env var | Role |
|---|---|
| `MULTICA_FEATURE_FLAGS_FILE` | Path to the YAML rule set (optional; absent = no static rules). |
| `FF_<FLAG_KEY>` | Per-flag runtime override. `FF_BILLING_NEW_INVOICE_EMAIL=false` / `25%` / `experiment-v2`. Beats the YAML, no redeploy. |
The provider chain is `EnvProvider → YAML StaticProvider`. The server can boot with zero flag config — every `IsEnabled` call falls back to the caller's default until someone authors a rule.
### Daemon-bound flags
Daemon-bound flags are evaluated by the server and delivered to local daemons
over the daemon heartbeat ack. This is for process-level daemon behavior where
operators need one rollout and kill-switch path across cloud runtimes, Desktop
embedded daemons, and user-run CLI daemons.
Only flags listed in `server/internal/featureflagdispatch/registry.go` are sent
to daemons. The registry is intentionally short:
```go
var DaemonBoundFlags = []string{
"runtime_brief_slim",
}
```
On each HTTP or WebSocket heartbeat, the server evaluates every registered key
as a daemon/process-level decision. The snapshot EvalContext exposes
`daemon_id` only; workspace/runtime/task/user scoped rollout is intentionally
not part of this channel because the daemon stores one process-global snapshot.
The heartbeat ack carries a full snapshot:
```json
{
"feature_flags": {
"version": 1,
"flags": {
"runtime_brief_slim": "on"
}
}
}
```
The daemon installs that snapshot into its process-level feature flag service.
The daemon provider order is:
1. `EnvProvider` (`FF_*`) for local emergency overrides.
2. `ServerSnapshotProvider` from the latest heartbeat ack.
3. local YAML `StaticProvider` as a fallback for old servers or self-hosted rescue.
4. the toggle point's caller-supplied default.
That means `FF_RUNTIME_BRIEF_SLIM=false` always suppresses a server snapshot
that enables `runtime_brief_slim`. New daemons talking to old servers receive no
`feature_flags` field and automatically fall back to local env/YAML behavior.
Old daemons talking to new servers ignore the unknown JSON field.
To add another daemon-bound process-level flag, add its key to the registry and
use the existing daemon feature flag service at the toggle point. Do not add
workspace percent rollout, task payload fields, or task-scoped readers for
daemon-bound flags unless a separate design explicitly introduces scoped daemon
flag evaluation.
### YAML schema
```yaml
# /etc/multica/feature-flags.yaml
billing_new_invoice_email:
default: true
checkout_algo:
default: false
variant: experiment-v2
percent:
percent: 25
by: user_id
ops_disable_recommendations:
default: false
allow: ["user-internal-1", "user-internal-2"]
allow_by: user_id
```
Every field except `default` is optional. `variant` is the on-variant — see the multi-arm note below. An empty file is a valid "no flags yet" state. Malformed YAML fails startup the same way `DATABASE_URL` parse errors do, so misconfig surfaces loudly.
### Attaching evaluation context to the request
```go
func middleware(flags *featureflag.Service, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ec := featureflag.EvalContext{
UserID: currentUserID(r),
WorkspaceID: currentWorkspaceID(r),
Attributes: map[string]string{"plan": currentPlan(r)},
}
ctx := featureflag.WithEvalContext(r.Context(), ec)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
```
### Toggle point in business code
```go
if flags.IsEnabled(ctx, "billing_new_invoice_email", false) {
return s.sendNewInvoiceEmail(ctx, invoice)
}
return s.sendLegacyInvoiceEmail(ctx, invoice)
```
For multi-arm flags:
```go
switch flags.Variant(ctx, "checkout_algo", "control") {
case "experiment-v2":
return checkoutV2(ctx, order)
case "experiment-v3":
return checkoutV3(ctx, order)
default:
return checkoutControl(ctx, order)
}
```
`Rule.Variant` is the **on-variant**: it is only returned when the rule evaluates to enabled=true (allow hit, percent hit, default-on). When the rule evaluates to disabled (deny hit, percent miss, default-off) the Service returns `"off"` so callers branching on `Variant()` cannot route control users into the experiment arm. This is exercised by `TestStaticProviderVariantOnlyWhenEnabled` and is the same on the TS side.
The Service is nil-safe and missing-key-safe: `(*Service)(nil).IsEnabled(ctx, "any", true)` returns `true`. Business code never needs to guard against a missing flag.
---
## Frontend (TypeScript / React)
### Mounting once at the root
```tsx
// apps/web/app/_providers.tsx (or the equivalent root)
import {
FeatureFlagsProvider,
FeatureFlagService,
StaticProvider,
} from "@multica/core/feature-flags";
const service = new FeatureFlagService(
new StaticProvider({
billing_v2_dashboard: { default: false, allow: ["user-internal"] },
checkout_algo: { default: true, variant: "experiment-v2",
percent: { percent: 25 } },
}),
);
export function Providers({ children }: { children: ReactNode }) {
const userId = useCurrentUserId();
return (
<FeatureFlagsProvider service={service} context={{ userId }}>
{children}
</FeatureFlagsProvider>
);
}
```
When the backend pushes a fresh rule set (via an API response or WebSocket), call `service.setProvider(new StaticProvider(remoteRules))` and the whole tree re-evaluates.
### Toggle point in a component
```tsx
import { useFlag, useVariant } from "@multica/core/feature-flags";
function BillingPage() {
const showV2 = useFlag("billing_v2_dashboard", false);
return showV2 ? <BillingV2 /> : <BillingV1 />;
}
function Checkout() {
const variant = useVariant("checkout_algo", "control");
switch (variant) {
case "experiment-v2": return <CheckoutV2 />;
case "experiment-v3": return <CheckoutV3 />;
default: return <CheckoutControl />;
}
}
```
Outside a `FeatureFlagsProvider` (Storybook, unit tests, error pages) `useFlag` / `useVariant` return the supplied default. You never have to mount the provider just to render a component in isolation.
### Security note: never rely on the frontend alone
A frontend feature flag controls what the user *sees*. It does NOT enforce access. Any API route exposing the same capability MUST evaluate the matching backend flag independently. The two flags can share a key but they live in two `Service` instances and the backend value is the source of truth.
---
## Best-practice checklist
Adopted from Martin Fowler, ConfigCat and Octopus.
- **Naming**: `{team}_{area}_{behavior}`, e.g. `billing_checkout_new_payment_flow`. No `enable_` / `disable_` prefixes (redundant).
- **One flag, one purpose**: never repurpose an old flag for a new feature. Add a new flag and delete the old one.
- **Plan the death of the flag at birth**: open a follow-up issue to remove the flag when the rollout completes. Release flags should live days, not quarters.
- **Convention**: `Off` is the legacy / safe state, `On` is the new behavior. Lets CI test "all-off (today)" and "all-on (tomorrow)".
- **Kill switch fast path**: ops-critical flags should be exposed via `EnvProvider` so SREs can flip them without a deploy.
- **Backend protection**: anything controlling access goes through the backend Service; the frontend flag is presentation only.
- **No secrets in flags**: variant values are not Secrets Manager / KMS. Use those for tokens, keys, and passwords.
See `docs/design.md` and `docs/timezone-architecture-rfc.md` for prior examples of how this pattern is used across the codebase.

View File

@@ -82,7 +82,7 @@ Multica 做的事:
Multica **不自己训模型**,也不锁定某一家厂商。它是调度器,本地 daemon 会自动探测以下 CLI 工具并接入:
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent · Kimi · Kiro CLI · Qoder CLI
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent · Kimi · Kiro CLI
每个 agent 可以配置自己的模型、API Key、环境变量、MCP 服务器。
@@ -244,7 +244,7 @@ Project 相比 Issue 是更高层的组织单元。一个 issue 可以不属于
#### 配置字段
- **基本信息**:名字、描述、头像(自动生成)
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor / Kimi / Kiro / Qoder 中的哪一个
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor / Kimi / Kiro 中的哪一个
- **Runtime**:绑定到哪个运行时(即在哪台机器上跑)
- **Instructions 说明书**agent 的系统提示词("你是一个资深工程师..."
- **Custom Env**:要注入到 CLI 进程的环境变量(如 `ANTHROPIC_API_KEY``ANTHROPIC_BASE_URL``CLAUDE_CODE_USE_BEDROCK`
@@ -291,7 +291,7 @@ Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent
`multica` CLI 在用户的机器上启动一个后台进程macOS launchd / Linux systemd / Windows 服务风格),它:
1. **自动探测** `$PATH` 上安装的 coding CLI`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `qodercli`
1. **自动探测** `$PATH` 上安装的 coding CLI`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`
2. 向 server **注册** 为一组 runtime一个 CLI = 一个 runtime
3. 每 3 秒 **轮询** 一次 server有任务就认领
4. 每 15 秒 **心跳**keepalive报告自己还活着

View File

@@ -1,72 +0,0 @@
import { describe, expect, it } from "vitest";
import { isBenignException } from "./benign-exceptions";
describe("isBenignException", () => {
it("drops ResizeObserver loop errors via $exception_list value", () => {
expect(
isBenignException({
$exception_list: [
{
type: "Error",
value: "ResizeObserver loop completed with undelivered notifications.",
},
],
}),
).toBe(true);
});
it("drops the older 'loop limit exceeded' phrasing", () => {
expect(
isBenignException({
$exception_list: [
{ type: "Error", value: "ResizeObserver loop limit exceeded" },
],
}),
).toBe(true);
});
it("drops when the signal is on the top-level $exception_message", () => {
expect(
isBenignException({
$exception_message: "ResizeObserver loop limit exceeded",
}),
).toBe(true);
});
it("matches case-insensitively", () => {
expect(
isBenignException({ $exception_message: "resizeobserver LOOP limit exceeded" }),
).toBe(true);
});
it("keeps real errors", () => {
expect(
isBenignException({
$exception_list: [
{
type: "TypeError",
value: "Cannot read properties of undefined (reading 'split')",
},
],
}),
).toBe(false);
});
it("does not match an unrelated mention of ResizeObserver", () => {
// Only the benign "loop" phrasing is silenced; a genuine bug in
// ResizeObserver usage must still be reported.
expect(
isBenignException({
$exception_message: "ResizeObserver is not defined",
}),
).toBe(false);
});
it("fails open on missing or malformed properties", () => {
expect(isBenignException(undefined)).toBe(false);
expect(isBenignException({})).toBe(false);
expect(isBenignException({ $exception_list: "not-an-array" })).toBe(false);
expect(isBenignException({ $exception_list: [null, 42, {}] })).toBe(false);
expect(isBenignException({ $exception_message: 123 })).toBe(false);
});
});

View File

@@ -1,52 +0,0 @@
// Known-benign browser exceptions that are pure noise in `$exception`
// telemetry. These are dropped ENTIRELY in `before_send` (not merely deduped by
// exception-dedupe.ts) — they carry no actionable signal, the browser
// self-recovers, and at scale they dominate the error stream, drowning real
// failures and burning the billed event budget.
//
// ResizeObserver "loop ..." errors are the canonical case: the spec fires them
// when observation callbacks don't settle within a single animation frame. The
// browser resumes delivery on the next frame, so nothing actually breaks. Every
// app that uses ResizeObserver (directly or via a UI library) emits them. The
// CSSWG explicitly considers them benign — see w3c/csswg-drafts#5023. Across
// Chrome versions the message is either "ResizeObserver loop limit exceeded"
// (older) or "ResizeObserver loop completed with undelivered notifications"
// (newer); both contain "ResizeObserver loop".
//
// The bar for adding a pattern here is high: it must be a benign,
// self-recovering error with no actionable signal. A real bug must never be
// silenced — when unsure, leave it to the dedupe fuse, which only caps repeats.
const BENIGN_MESSAGE_PATTERNS: RegExp[] = [/ResizeObserver loop/i];
/**
* Whether this `$exception` event is known-benign browser noise that should be
* dropped entirely. Reads the message from the (pre-redaction) event
* properties — the matched messages carry no PII, so reading them raw is safe,
* and matching before redaction avoids any chance of a scrub mangling the
* signal. Never throws: any unexpected shape returns `false` (keep the event),
* the fail-open direction `before_send` requires.
*/
export function isBenignException(
properties: Record<string, unknown> | undefined,
): boolean {
if (!properties || typeof properties !== "object") return false;
const messages: unknown[] = [properties.$exception_message];
const list = properties.$exception_list;
if (Array.isArray(list)) {
for (const entry of list) {
if (entry && typeof entry === "object" && "value" in entry) {
messages.push((entry as { value: unknown }).value);
}
}
}
for (const message of messages) {
if (typeof message !== "string") continue;
for (const pattern of BENIGN_MESSAGE_PATTERNS) {
if (pattern.test(message)) return true;
}
}
return false;
}

View File

@@ -1,234 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { shouldDropException } from "./exception-dedupe";
const STORAGE_KEY = "mc_exc_fp";
// In-memory sessionStorage stand-in. Optional flags let a test force getItem /
// setItem to throw (quota, disabled storage) so we can assert the fail-open
// direction.
function makeStorage(opts: { throwOnGet?: boolean; throwOnSet?: boolean } = {}) {
const data = new Map<string, string>();
return {
data,
getItem(k: string): string | null {
if (opts.throwOnGet) throw new Error("getItem blocked");
return data.has(k) ? data.get(k)! : null;
},
setItem(k: string, v: string): void {
if (opts.throwOnSet) throw new Error("quota exceeded");
data.set(k, v);
},
removeItem(k: string): void {
data.delete(k);
},
clear(): void {
data.clear();
},
key(i: number): string | null {
return Array.from(data.keys())[i] ?? null;
},
get length(): number {
return data.size;
},
};
}
// Build a redacted-shape `$exception` properties object. By the time dedupe
// runs, redactExceptionProperties has already scrubbed value/message.
function exc(o: {
type?: string;
value?: string;
frames?: Array<Record<string, unknown>> | null;
} = {}): Record<string, unknown> {
const entry: Record<string, unknown> = {
type: o.type ?? "TypeError",
value: o.value ?? "boom",
};
if (o.frames !== null) {
entry.stacktrace = {
type: "raw",
frames: o.frames ?? [
{ filename: "app.tsx", function: "render", lineno: 10, colno: 5 },
],
};
}
return { $exception_list: [entry] };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("shouldDropException — per-fingerprint limit", () => {
beforeEach(() => {
vi.stubGlobal("sessionStorage", makeStorage());
});
it("keeps the first 3 of a fingerprint and drops from the 4th", () => {
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(true);
expect(shouldDropException(exc())).toBe(true);
});
it("treats different fingerprints independently — one does not drop the other", () => {
// Exhaust fingerprint A.
const a = () => exc({ type: "TypeError", value: "a" });
const b = () => exc({ type: "RangeError", value: "b" });
shouldDropException(a());
shouldDropException(a());
shouldDropException(a());
expect(shouldDropException(a())).toBe(true); // A fused
// B is untouched.
expect(shouldDropException(b())).toBe(false);
expect(shouldDropException(b())).toBe(false);
expect(shouldDropException(b())).toBe(false);
expect(shouldDropException(b())).toBe(true);
});
it("discriminates on colno (minified bundles collapse statements onto one line)", () => {
const at = (colno: number) =>
exc({ frames: [{ filename: "b.js", function: "x", lineno: 1, colno }] });
// Same file/line/function, different column → distinct fingerprints, so
// each keeps its own first-3 budget.
shouldDropException(at(10));
shouldDropException(at(10));
shouldDropException(at(10));
expect(shouldDropException(at(10))).toBe(true);
expect(shouldDropException(at(20))).toBe(false);
});
it("stores only a hash + counter — no raw value reaches storage", () => {
const storage = makeStorage();
vi.stubGlobal("sessionStorage", storage);
shouldDropException(exc({ value: "secret-marker-12345" }));
const blob = storage.data.get(STORAGE_KEY) ?? "";
expect(blob).not.toContain("secret-marker-12345");
expect(blob).not.toContain("app.tsx");
});
});
describe("shouldDropException — degraded frames", () => {
beforeEach(() => {
vi.stubGlobal("sessionStorage", makeStorage());
});
it("tolerates missing lineno/colno/function and still dedupes", () => {
const partial = () => exc({ frames: [{ filename: "only-file.js" }] });
expect(() => shouldDropException(partial())).not.toThrow();
shouldDropException(partial());
shouldDropException(partial());
expect(shouldDropException(partial())).toBe(true);
});
it("tolerates no stacktrace at all (fingerprints on type + value)", () => {
const noframes = () => exc({ frames: null });
shouldDropException(noframes());
shouldDropException(noframes());
shouldDropException(noframes());
expect(shouldDropException(noframes())).toBe(true);
});
it("keeps events with no usable signal (empty type/value/frames)", () => {
const empty = { $exception_list: [{ type: "", value: "" }] };
expect(shouldDropException(empty)).toBe(false);
expect(shouldDropException(empty)).toBe(false);
expect(shouldDropException(empty)).toBe(false);
expect(shouldDropException(empty)).toBe(false); // never fused — no fingerprint
});
it("is safe on undefined / malformed properties", () => {
expect(shouldDropException(undefined)).toBe(false);
expect(
shouldDropException({ $exception_list: "nope" as unknown as [] }),
).toBe(false);
});
});
describe("shouldDropException — storage fail-open", () => {
it("fails open when sessionStorage is undefined (SSR)", () => {
vi.stubGlobal("sessionStorage", undefined);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
});
it("fails open when accessing sessionStorage throws (sandboxed iframe)", () => {
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
get() {
throw new Error("blocked by sandbox");
},
});
try {
expect(() => shouldDropException(exc())).not.toThrow();
expect(shouldDropException(exc())).toBe(false);
} finally {
// Remove the throwing getter so it doesn't leak into other tests.
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: undefined,
});
}
});
it("fails open when getItem throws", () => {
vi.stubGlobal("sessionStorage", makeStorage({ throwOnGet: true }));
expect(() => shouldDropException(exc())).not.toThrow();
expect(shouldDropException(exc())).toBe(false);
});
it("fails open on a corrupted JSON blob and re-seeds clean state", () => {
const storage = makeStorage();
storage.data.set(STORAGE_KEY, "{not valid json");
vi.stubGlobal("sessionStorage", storage);
expect(shouldDropException(exc())).toBe(false);
// Blob is now valid JSON again with this fingerprint counted once.
const reseeded = JSON.parse(storage.data.get(STORAGE_KEY)!);
expect(typeof reseeded).toBe("object");
expect(Object.values(reseeded)).toEqual([1]);
});
it("setItem failure under-counts (fewer drops), never over-drops", () => {
vi.stubGlobal("sessionStorage", makeStorage({ throwOnSet: true }));
// Persisting the increment always fails, so the counter never advances and
// no event is ever dropped — the required "less drop" direction.
for (let i = 0; i < 5; i++) {
expect(shouldDropException(exc())).toBe(false);
}
});
});
describe("shouldDropException — distinct-fingerprint cap", () => {
it("keeps (does not track) a new fingerprint once the cap is reached", () => {
const storage = makeStorage();
// Seed 50 distinct fingerprints already at count 1.
const seed: Record<string, number> = {};
for (let i = 0; i < 50; i++) seed[`seed-${i}`] = 1;
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
vi.stubGlobal("sessionStorage", storage);
// The 51st, brand-new fingerprint is kept and NOT added to the blob.
expect(shouldDropException(exc({ value: "fingerprint-51" }))).toBe(false);
const after = JSON.parse(storage.data.get(STORAGE_KEY)!);
expect(Object.keys(after)).toHaveLength(50);
});
it("still fuses a fingerprint that is already tracked at the cap", () => {
const storage = makeStorage();
const seed: Record<string, number> = {};
for (let i = 0; i < 49; i++) seed[`seed-${i}`] = 1;
vi.stubGlobal("sessionStorage", storage);
// Track a real one to reach 50 distinct, exhausting its budget.
const target = () => exc({ value: "tracked-at-cap" });
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
shouldDropException(target()); // 50th distinct, count 1
shouldDropException(target()); // 2
shouldDropException(target()); // 3
expect(shouldDropException(target())).toBe(true); // fused despite cap
});
});

View File

@@ -1,193 +0,0 @@
// Session-scoped dedupe / throttle for `$exception` events.
//
// Runs in posthog-js `before_send` AFTER `redactExceptionProperties`, so the
// fingerprint is built purely from already-redacted fields — no raw message,
// value, or PII is ever written to storage (only a hash + a small counter).
//
// The fuse: keep the first EXCEPTION_SAMPLE_LIMIT of each (tab-session,
// fingerprint) pair and drop the rest. One runaway error — a render loop, a
// polling fetch that keeps throwing — otherwise emits 100+ identical
// `$exception` events per session (MUL-3331 / MUL-3330). Different fingerprints
// never affect each other.
//
// Safety invariant (load-bearing): `before_send` must never throw — a throw
// there breaks ALL event delivery — and every storage failure must fail OPEN.
// When in doubt we KEEP the event: emitting a duplicate is cheap, silently
// dropping a real first-occurrence error is not. setItem failures therefore
// only ever under-count (fewer drops), never over-drop.
//
// Scope is the browser tab session (`sessionStorage`): cleared when the tab
// closes, isolated per tab. This is intentionally NOT the posthog 30-min
// session — see the dedupe discussion on MUL-3331.
const STORAGE_KEY = "mc_exc_fp";
// Keep the first N of each fingerprint per session, drop from N+1.
const EXCEPTION_SAMPLE_LIMIT = 3;
// Cap distinct fingerprints tracked per session so a session that throws many
// *different* errors can't grow the blob without bound. Past the cap, new
// fingerprints are not tracked and fail open (kept).
const MAX_FINGERPRINTS = 50;
type FingerprintCounts = Record<string, number>;
/**
* Decide whether this already-redacted `$exception` event should be dropped as
* a session-level duplicate. Returns `true` to drop, `false` to keep.
*
* Never throws. Any missing fingerprint signal, unavailable/corrupt storage, or
* unexpected error results in `false` (keep) — the fail-open direction.
*/
export function shouldDropException(
properties: Record<string, unknown> | undefined,
): boolean {
const fingerprint = buildFingerprint(properties);
// Nothing stable to dedupe on → keep.
if (fingerprint === null) return false;
const storage = getSessionStorage();
if (!storage) return false;
// The entire read-decide-write sequence is guarded: a throw anywhere (parse,
// getItem, property access) degrades to keep.
try {
const counts = readCounts(storage);
const current = typeof counts[fingerprint] === "number" ? counts[fingerprint] : 0;
// Already at the limit for this fingerprint → fuse blows, drop.
if (current >= EXCEPTION_SAMPLE_LIMIT) return true;
// A brand-new fingerprint once the cap is reached: don't track it (would
// grow the blob), and keep the event.
if (current === 0 && Object.keys(counts).length >= MAX_FINGERPRINTS) {
return false;
}
counts[fingerprint] = current + 1;
try {
storage.setItem(STORAGE_KEY, JSON.stringify(counts));
} catch {
// Persisting the increment failed (quota / disabled). We still keep this
// event (return false below). The unpersisted increment only means the
// next identical error is also kept — under-counting toward the limit,
// i.e. fewer drops, never more. This is the required failure direction.
}
return false;
} catch {
return false;
}
}
/** Read and validate the counts blob. A corrupt or unexpected payload is
* treated as empty (fail open — this event is kept and re-seeds the blob). */
function readCounts(storage: Storage): FingerprintCounts {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as FingerprintCounts;
}
} catch {
// Corrupt JSON blob → start fresh.
}
return {};
}
/**
* Build a stable fingerprint from the redacted exception properties. Uses the
* exception type, the redacted message/value, and a single deterministic stack
* frame. Returns `null` when there's nothing stable to key on (keep the event).
*
* Every frame field (`function` / `lineno` / `colno`) is treated as optional
* and degrades to empty — minified or partial stacks must not throw or collapse
* every error into one bucket via an undefined access.
*/
function buildFingerprint(properties: Record<string, unknown> | undefined): string | null {
if (!properties || typeof properties !== "object") return null;
const list = properties.$exception_list;
const entry =
Array.isArray(list) && list.length > 0 && list[0] && typeof list[0] === "object"
? (list[0] as Record<string, unknown>)
: undefined;
const type = readString(entry?.type) ?? readString(properties.$exception_type) ?? "";
const value =
readString(entry?.value) ?? readString(properties.$exception_message) ?? "";
const frame = topFrame(entry);
// No signal at all → don't dedupe.
if (type === "" && value === "" && !frame) return null;
const parts = [type, value];
if (frame) {
// colno is kept (load-bearing): minified bundles collapse many statements
// onto one line, so line alone under-discriminates distinct errors.
parts.push(frame.filename, frame.fn, frame.lineno, frame.colno);
}
return hash(parts.join(""));
}
interface TopFrame {
filename: string;
fn: string;
lineno: string;
colno: string;
}
/**
* Extract a single deterministic stack frame for fingerprinting. We always take
* the LAST frame in the array — a fixed end, with NO engine/order detection.
* The same error within a session yields the same frames array and therefore
* the same chosen frame, which is all the fingerprint needs; we don't care
* which end is semantically "topmost". Missing pieces degrade to "".
*/
function topFrame(entry: Record<string, unknown> | undefined): TopFrame | null {
if (!entry) return null;
const stacktrace = entry.stacktrace;
const frames =
stacktrace && typeof stacktrace === "object"
? (stacktrace as Record<string, unknown>).frames
: undefined;
if (!Array.isArray(frames) || frames.length === 0) return null;
const f = frames[frames.length - 1];
if (!f || typeof f !== "object") return null;
const frame = f as Record<string, unknown>;
return {
filename: readString(frame.filename) ?? "",
fn: readString(frame.function) ?? "",
lineno: readNumberAsString(frame.lineno) ?? "",
colno: readNumberAsString(frame.colno) ?? "",
};
}
function readString(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function readNumberAsString(v: unknown): string | undefined {
return typeof v === "number" && Number.isFinite(v) ? String(v) : undefined;
}
/** djb2 — a tiny stable string hash. Only used to bound the storage-key length;
* collision risk across a single tab session's exceptions is negligible. */
function hash(input: string): string {
let h = 5381;
for (let i = 0; i < input.length; i++) {
h = ((h << 5) + h) ^ input.charCodeAt(i);
}
return (h >>> 0).toString(36);
}
/** Resolve `sessionStorage`, returning `null` if it is absent (SSR) or throws
* on access (sandboxed iframe, storage disabled). */
function getSessionStorage(): Storage | null {
try {
if (typeof sessionStorage === "undefined") return null;
return sessionStorage;
} catch {
return null;
}
}

View File

@@ -216,75 +216,3 @@ describe("captureException", () => {
expect(posthog.captureException).toHaveBeenCalledWith(err, expect.any(Object));
});
});
describe("before_send $exception pipeline", () => {
// before_send is registered inside posthog.init's config; pull it back out of
// the mock and drive it directly. Dedupe needs a working sessionStorage.
function makeMemoryStorage() {
const data = new Map<string, string>();
return {
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
clear: () => data.clear(),
key: (i: number) => Array.from(data.keys())[i] ?? null,
get length() {
return data.size;
},
};
}
type BeforeSend = (
e: { event: string; properties: Record<string, unknown> } | null,
) => unknown;
function getBeforeSend(posthog: { init: ReturnType<typeof vi.fn> }): BeforeSend {
const config = posthog.init.mock.calls[0]?.[1] as { before_send: BeforeSend };
return config.before_send;
}
function excEvent() {
return {
event: "$exception",
properties: {
$exception_list: [
{
type: "TypeError",
value: "Bad email bob@corp.com",
stacktrace: {
frames: [{ filename: "a.tsx", function: "f", lineno: 1, colno: 2 }],
},
},
],
},
};
}
beforeEach(() => {
vi.stubGlobal("sessionStorage", makeMemoryStorage());
});
it("redacts the message, then drops repeats past the per-fingerprint limit", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
const beforeSend = getBeforeSend(posthog);
const first = beforeSend(excEvent()) as { properties: { $exception_list: Array<{ value: string }> } };
// Redaction still runs before the fuse.
expect(first.properties.$exception_list[0]!.value).toBe("Bad email [redacted]");
expect(beforeSend(excEvent())).not.toBeNull();
expect(beforeSend(excEvent())).not.toBeNull();
// 4th identical exception is dropped.
expect(beforeSend(excEvent())).toBeNull();
});
it("passes non-$exception events through untouched", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
const beforeSend = getBeforeSend(posthog);
const evt = { event: "$pageview", properties: { $current_url: "/acme/issues" } };
expect(beforeSend(evt)).toBe(evt);
});
});

View File

@@ -14,8 +14,6 @@
import posthog from "posthog-js";
import { redactExceptionProperties } from "./redact-exception";
import { shouldDropException } from "./exception-dedupe";
import { isBenignException } from "./benign-exceptions";
export const EVENT_SCHEMA_VERSION = 2;
@@ -158,22 +156,10 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
// typed value, a URL with a token), so `before_send` scrubs the message
// and `$exception_list[].value` before the event leaves the client. Stack
// frames (code locations) are kept. See redact-exception.ts.
//
// After scrubbing, a session-level fuse drops repeats of the same error so
// a render loop or a polling fetch that keeps throwing can't emit 100+
// identical `$exception` events per session (MUL-3331). The fingerprint is
// built only from the already-redacted fields, so no PII reaches storage.
// Order matters: redact first, then fingerprint the redacted shape.
capture_exceptions: true,
before_send: (event) => {
if (event && event.event === "$exception") {
// Drop known-benign browser noise (e.g. ResizeObserver loop) entirely
// — checked on the raw message before redaction. These dominate the
// stream and carry no signal, so they skip both redaction and the
// dedupe fuse. See benign-exceptions.ts.
if (isBenignException(event.properties)) return null;
redactExceptionProperties(event.properties);
if (shouldDropException(event.properties)) return null;
}
return event;
},

View File

@@ -24,15 +24,10 @@ import type {
AgentActivityBucket,
AgentRunCount,
AgentRuntime,
RuntimeProfile,
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
InboxItem,
IssueSubscriber,
Comment,
CommentTriggerPreview,
IssueTriggerPreview,
IssueTriggerPreviewParams,
Reaction,
IssueReaction,
Workspace,
@@ -142,7 +137,6 @@ import {
ChildIssuesResponseSchema,
CommentsListSchema,
CommentTriggerPreviewSchema,
IssueTriggerPreviewSchema,
CloudRuntimeNodeListSchema,
CloudRuntimeNodeSchema,
CreateAgentFromTemplateResponseSchema,
@@ -159,8 +153,6 @@ 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,
@@ -179,8 +171,6 @@ import {
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
SearchIssuesResponseSchema,
SearchProjectsResponseSchema,
SquadSchema,
SquadListSchema,
SquadMemberStatusListResponseSchema,
@@ -555,13 +545,7 @@ 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");
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",
});
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
}
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
@@ -569,13 +553,7 @@ 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");
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",
});
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
}
async getIssue(id: string): Promise<Issue> {
@@ -705,26 +683,6 @@ export class ApiClient {
});
}
/** Dry-run the unified run-enqueue predicate for a prospective issue write
* (create / single assign / single status / batch). Returns the runs that
* would start; no side effect. The four entry points consult this instead
* of re-implementing the rule (MUL-3375). */
async previewIssueTrigger(params: IssueTriggerPreviewParams): Promise<IssueTriggerPreview> {
const raw = await this.fetch<unknown>("/api/issues/preview-trigger", {
method: "POST",
body: JSON.stringify({
...(params.issueIds?.length ? { issue_ids: params.issueIds } : {}),
...(params.isCreate ? { is_create: true } : {}),
...(params.assigneeType ? { assignee_type: params.assigneeType } : {}),
...(params.assigneeId ? { assignee_id: params.assigneeId } : {}),
...(params.status ? { status: params.status } : {}),
}),
});
return parseWithFallback(raw, IssueTriggerPreviewSchema, { triggers: [], total_count: 0 }, {
endpoint: "POST /api/issues/preview-trigger",
});
}
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
const raw = await this.fetch<unknown>(
`/api/issues/${issueId}/timeline`,
@@ -1140,61 +1098,6 @@ export class ApiClient {
});
}
// ---------------------------------------------------------------------
// Custom runtime profiles (MUL-3284). All workspace-scoped: the caller
// passes the workspace id the same way the runtimes list resolves it.
// ---------------------------------------------------------------------
async listRuntimeProfiles(workspaceId: string): Promise<RuntimeProfile[]> {
const res = await this.fetch<{ runtime_profiles?: RuntimeProfile[] }>(
`/api/workspaces/${workspaceId}/runtime-profiles`,
);
return res.runtime_profiles ?? [];
}
async getRuntimeProfile(
workspaceId: string,
profileId: string,
): Promise<RuntimeProfile> {
return this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
);
}
async createRuntimeProfile(
workspaceId: string,
body: CreateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
return this.fetch(`/api/workspaces/${workspaceId}/runtime-profiles`, {
method: "POST",
body: JSON.stringify(body),
});
}
async updateRuntimeProfile(
workspaceId: string,
profileId: string,
patch: UpdateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
return this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
{
method: "PATCH",
body: JSON.stringify(patch),
},
);
}
async deleteRuntimeProfile(
workspaceId: string,
profileId: string,
): Promise<void> {
await this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
{ method: "DELETE" },
);
}
async getRuntimeUsage(
runtimeId: string,
params?: { days?: number; tz?: string },

View File

@@ -91,24 +91,6 @@ 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

@@ -6,7 +6,6 @@ import {
DashboardUsageDailyListSchema,
DuplicateIssueErrorBodySchema,
EMPTY_USER,
IssueTriggerPreviewSchema,
ListIssuesResponseSchema,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
@@ -14,7 +13,6 @@ import {
RuntimeUsageListSchema,
SquadListSchema,
SquadSchema,
TimelineEntriesSchema,
UserSchema,
} from "./schemas";
import { parseWithFallback } from "./schema";
@@ -35,7 +33,6 @@ const baseIssue = {
parent_issue_id: null,
project_id: null,
position: 0,
stage: null,
start_date: null,
due_date: null,
metadata: {},
@@ -76,112 +73,6 @@ describe("IssueSchema (via ListIssuesResponseSchema)", () => {
};
expect(ListIssuesResponseSchema.safeParse(payload).success).toBe(false);
});
it("accepts a numeric stage", () => {
const payload = { issues: [{ ...baseIssue, stage: 2 }], total: 1 };
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.stage).toBe(2);
});
it("defaults stage to null when the server omits it (older backend)", () => {
const { stage: _omit, ...issueWithoutStage } = baseIssue;
const payload = { issues: [issueWithoutStage], total: 1 };
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.stage).toBeNull();
});
});
// POST /api/issues/preview-trigger feeds this schema through parseWithFallback
// in client.previewIssueTrigger with fallback { triggers: [], total_count: 0 }
// (MUL-3375). The four entry points read it to decide "will this start a run",
// so malformed / missing / null drift must degrade to "nothing will start"
// rather than throw into the picker/modal.
const PREVIEW_FALLBACK = { triggers: [], total_count: 0 };
const PREVIEW_ENDPOINT = { endpoint: "POST /api/issues/preview-trigger" };
describe("IssueTriggerPreviewSchema", () => {
it("parses a well-formed response", () => {
const parsed = IssueTriggerPreviewSchema.parse({
triggers: [
{ issue_id: "i1", agent_id: "a1", source: "assign", handoff_supported: true },
{ issue_id: "i2", agent_id: "a2", source: "status", handoff_supported: false },
],
total_count: 2,
});
expect(parsed.total_count).toBe(2);
expect(parsed.triggers).toHaveLength(2);
expect(parsed.triggers[0]).toMatchObject({ issue_id: "i1", agent_id: "a1", source: "assign", handoff_supported: true });
});
it("defaults missing top-level fields (empty / older backend)", () => {
const parsed = IssueTriggerPreviewSchema.parse({});
expect(parsed.triggers).toEqual([]);
expect(parsed.total_count).toBe(0);
});
it("defaults missing optional item fields, keeping required issue_id", () => {
const parsed = IssueTriggerPreviewSchema.parse({ triggers: [{ issue_id: "i1" }], total_count: 1 });
expect(parsed.triggers[0]).toEqual({
issue_id: "i1",
agent_id: "",
source: "",
handoff_supported: false,
});
});
it("parseWithFallback returns the fallback for a malformed shape (triggers not an array)", () => {
const parsed = parseWithFallback(
{ triggers: "nope", total_count: 1 },
IssueTriggerPreviewSchema,
PREVIEW_FALLBACK,
PREVIEW_ENDPOINT,
);
expect(parsed).toEqual(PREVIEW_FALLBACK);
});
it("parseWithFallback returns the fallback when an item drops the required issue_id", () => {
const parsed = parseWithFallback(
{ triggers: [{ agent_id: "a1", source: "assign" }], total_count: 1 },
IssueTriggerPreviewSchema,
PREVIEW_FALLBACK,
PREVIEW_ENDPOINT,
);
expect(parsed).toEqual(PREVIEW_FALLBACK);
});
it("parseWithFallback returns the fallback for a wrong-typed total_count", () => {
const parsed = parseWithFallback(
{ triggers: [], total_count: "5" },
IssueTriggerPreviewSchema,
PREVIEW_FALLBACK,
PREVIEW_ENDPOINT,
);
expect(parsed).toEqual(PREVIEW_FALLBACK);
});
it("parseWithFallback returns the fallback for null / non-object bodies", () => {
expect(parseWithFallback(null, IssueTriggerPreviewSchema, PREVIEW_FALLBACK, PREVIEW_ENDPOINT)).toEqual(PREVIEW_FALLBACK);
expect(parseWithFallback("oops", IssueTriggerPreviewSchema, PREVIEW_FALLBACK, PREVIEW_ENDPOINT)).toEqual(PREVIEW_FALLBACK);
});
});
describe("TimelineEntriesSchema", () => {
it("preserves source_task_id for agent failure comments", () => {
const parsed = TimelineEntriesSchema.parse([
{
type: "comment",
id: "comment-1",
actor_type: "agent",
actor_id: "agent-1",
created_at: "2026-01-01T00:00:00Z",
content: "API Error: 500 Internal server error",
comment_type: "system",
source_task_id: "task-1",
},
]);
expect(parsed[0]?.source_task_id).toBe("task-1");
});
});
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
@@ -368,20 +259,6 @@ describe("dashboard + runtime usage schema drift", () => {
expect(RuntimeUsageByHourListSchema.parse([{ hour: 9 }])[0]?.model).toBe("");
});
it("defaults a missing provider to \"\" so an older server's rows still price by bare model", () => {
// provider was added for cross-provider model disambiguation; a server
// predating it omits the field. The schema must fill "" (→ bare-model
// pricing lookup) rather than drop the row.
expect(
DashboardUsageDailyListSchema.parse([{ date: "2026-05-19", model: "claude-opus-4-7" }])[0]
?.provider,
).toBe("");
expect(
DashboardUsageByAgentListSchema.parse([{ model: "claude-opus-4-7" }])[0]?.provider,
).toBe("");
expect(RuntimeUsageByAgentListSchema.parse([{ model: "x" }])[0]?.provider).toBe("");
});
it("rejects a non-array body so parseWithFallback can return its fallback", () => {
expect(DashboardUsageDailyListSchema.safeParse(null).success).toBe(false);
expect(RuntimeUsageListSchema.safeParse({ rows: [] }).success).toBe(false);

View File

@@ -17,8 +17,6 @@ import type {
GroupedIssuesResponse,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
Squad,
TimelineEntry,
User,
@@ -148,7 +146,6 @@ const TimelineEntrySchema = z.object({
comment_type: z.string().optional(),
reactions: z.array(ReactionSchema).optional(),
attachments: z.array(AttachmentSchema).optional(),
source_task_id: z.string().nullable().optional(),
coalesced_count: z.number().optional(),
}).loose();
@@ -205,7 +202,6 @@ export const CommentSchema = z.object({
attachments: z.array(AttachmentSchema).default([]),
created_at: z.string(),
updated_at: z.string(),
source_task_id: z.string().nullable().optional(),
}).loose();
export const CommentsListSchema = z.array(CommentSchema);
@@ -222,18 +218,6 @@ export const CommentTriggerPreviewSchema = z.object({
agents: z.array(CommentTriggerPreviewAgentSchema).default([]),
}).loose();
const IssueTriggerPreviewItemSchema = z.object({
issue_id: z.string(),
agent_id: z.string().default(""),
source: z.string().default(""),
handoff_supported: z.boolean().default(false),
}).loose();
export const IssueTriggerPreviewSchema = z.object({
triggers: z.array(IssueTriggerPreviewItemSchema).default([]),
total_count: z.number().default(0),
}).loose();
// Metadata is primitive-only by API/DB contract. Stay lenient on shape:
// unknown keys land as `unknown` to a caller, but the field itself defaults
// to {} so consumers never need to nil-guard `issue.metadata`.
@@ -255,9 +239,6 @@ export const IssueSchema = z.object({
parent_issue_id: z.string().nullable(),
project_id: z.string().nullable(),
position: z.number(),
// Older backends predate `stage`; default to null so a missing field parses
// cleanly into the non-optional Issue.stage (number | null).
stage: z.number().nullable().default(null),
start_date: z.string().nullable(),
due_date: z.string().nullable(),
metadata: IssueMetadataSchema,
@@ -277,55 +258,6 @@ 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(),
@@ -405,7 +337,6 @@ export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = {
const DashboardUsageDailySchema = z.object({
date: z.string().default(""),
provider: z.string().default(""),
model: z.string().default(""),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
@@ -418,7 +349,6 @@ export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
const DashboardUsageByAgentSchema = z.object({
agent_id: z.string().default(""),
provider: z.string().default(""),
model: z.string().default(""),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
@@ -476,7 +406,6 @@ export const RuntimeHourlyActivityListSchema = z.array(RuntimeHourlyActivitySche
const RuntimeUsageByAgentSchema = z.object({
agent_id: z.string().default(""),
provider: z.string().default(""),
model: z.string().default(""),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
@@ -528,7 +457,6 @@ const AgentTaskResponseSchema = z.object({
attempt: z.number().optional(),
trigger_comment_id: z.string().optional(),
trigger_summary: z.string().optional(),
handoff_note: z.string().optional(),
kind: z.string().optional(),
work_dir: z.string().optional(),
relative_work_dir: z.string().optional(),
@@ -539,9 +467,6 @@ const CancelledChatMessageSchema = z.object({
message_id: z.string(),
content: z.string(),
restore_to_input: z.boolean().default(false),
// Attachments detached from the deleted message so a restored draft can
// re-bind them on re-send. Absent on servers that predate the field.
attachments: z.array(AttachmentSchema).optional(),
}).loose();
export const CancelTaskResponseSchema = AgentTaskResponseSchema.extend({

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WSClient } from "./ws-client";
import type { WSMessage } from "../types/events";
// Capture URL passed to WebSocket so we can assert the connect-time
// query string. We don't simulate the full WS lifecycle here — only the
@@ -129,57 +128,6 @@ describe("WSClient", () => {
);
});
it("drops frames without a string type without throwing, and keeps dispatching", () => {
// Regression for MUL-3418: a frame whose parsed JSON lacks a string `type`
// (an out-of-protocol frame, or a bare JSON primitive) used to throw an
// uncaught TypeError out of onmessage via `msg.type.split(...)` in a
// downstream onAny handler, flooding `$exception` telemetry.
const logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const ws = new WSClient("ws://example.test/ws", { logger });
// A downstream consumer that assumes a string type, exactly like the
// realtime sync's onAny dispatcher.
const anyHandler = vi.fn((msg: WSMessage) => msg.type.split(":")[0]);
ws.onAny(anyHandler);
const issueHandler = vi.fn();
ws.on("issue:updated", issueHandler);
ws.connect();
const badFrames = [
JSON.stringify({ payload: {} }), // object, no type
"42", // bare number
"true", // bare bool
"[]", // array
];
for (const data of badFrames) {
expect(() => {
FakeWebSocket.lastInstance!.onmessage?.({ data });
}).not.toThrow();
}
// Bad frames never reached any handler.
expect(anyHandler).not.toHaveBeenCalled();
expect(issueHandler).not.toHaveBeenCalled();
// A valid frame after the bad ones still dispatches normally.
FakeWebSocket.lastInstance!.onmessage?.({
data: JSON.stringify({ type: "issue:updated", payload: { id: "i-1" } }),
});
expect(issueHandler).toHaveBeenCalledWith({ id: "i-1" }, undefined, undefined);
expect(anyHandler).toHaveBeenCalledTimes(1);
// The drop is logged at most once per connection despite four bad frames.
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn.mock.calls[0]?.[0]).toBe(
"ws: dropping frame without a string type",
);
});
it("passes actor_id and actor_type to event handlers", () => {
const ws = new WSClient("ws://example.test/ws");
ws.setAuth("tok", "acme");

View File

@@ -34,10 +34,6 @@ export class WSClient {
private handlers = new Map<WSEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedBefore = false;
// One-shot per connection. A non-conforming frame can repeat hundreds of
// times per session, so we log the first drop and suppress the rest. Reset
// on each connect() so a fresh connection logs once again.
private badFrameLogged = false;
private onReconnectCallbacks = new Set<() => void>();
private anyHandlers = new Set<(msg: WSMessage) => void>();
private logger: Logger;
@@ -62,7 +58,6 @@ export class WSClient {
}
connect() {
this.badFrameLogged = false;
const url = new URL(this.baseUrl);
// Token is never sent as a URL query parameter — it would be logged by
// proxies, CDNs, and browser history. In cookie mode the HttpOnly cookie
@@ -101,25 +96,6 @@ export class WSClient {
);
return;
}
// Trust boundary: a frame must be an object carrying a string `type`.
// The server protocol guarantees this for every frame, but a
// non-conforming frame — an out-of-protocol frame injected by a proxy /
// browser extension, or a bare JSON primitive — must degrade to a no-op
// here. Without this guard every downstream consumer (the onAny
// dispatcher and every ws.on subscriber) runs against a bad shape;
// `msg.type.split(...)` in the realtime sync threw an uncaught TypeError
// out of onmessage and surfaced as a flood of global `$exception` events
// (MUL-3418). Validate once at the boundary, trust the shape downstream.
if (!msg || typeof (msg as { type?: unknown }).type !== "string") {
if (!this.badFrameLogged) {
this.badFrameLogged = true;
this.logger.warn(
"ws: dropping frame without a string type",
summarizeUnparseable(event.data),
);
}
return;
}
if ((msg as any).type === "auth_ack") {
this.onAuthenticated();
return;

View File

@@ -1,19 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TaskMessagePayload } from "../types/events";
import {
isTaskMessageTaskId,
mergeTaskMessagesBySeq,
taskMessagesOptions,
} from "./queries";
const msg = (seq: number): TaskMessagePayload => ({
task_id: "task-1",
issue_id: "issue-1",
seq,
type: "text",
content: `m${seq}`,
});
import { isTaskMessageTaskId, taskMessagesOptions } from "./queries";
describe("taskMessagesOptions", () => {
it("fetches task messages for persisted UUID task ids", () => {
@@ -30,32 +17,3 @@ describe("taskMessagesOptions", () => {
expect(taskMessagesOptions(taskId).enabled).toBe(false);
});
});
describe("mergeTaskMessagesBySeq", () => {
it("backfills missing seqs and keeps the list seq-ordered", () => {
const existing = [msg(1), msg(3)];
const merged = mergeTaskMessagesBySeq(existing, [msg(2), msg(4)]);
expect(merged.map((m) => m.seq)).toEqual([1, 2, 3, 4]);
});
it("drops duplicate seqs and lets the existing entry win", () => {
const existing = [{ ...msg(1), content: "ws" }];
const merged = mergeTaskMessagesBySeq(existing, [
{ ...msg(1), content: "refetch" },
msg(2),
]);
expect(merged.map((m) => m.seq)).toEqual([1, 2]);
expect(merged.find((m) => m.seq === 1)?.content).toBe("ws");
});
it("preserves the array reference when nothing new arrives", () => {
const existing = [msg(1), msg(2)];
// Empty incoming and fully-duplicate incoming must both no-op so React
// Query observers don't re-render on replayed events.
expect(mergeTaskMessagesBySeq(existing, [])).toBe(existing);
expect(mergeTaskMessagesBySeq(existing, [msg(1), msg(2)])).toBe(existing);
});
});

View File

@@ -1,6 +1,5 @@
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type { TaskMessagePayload } from "../types/events";
// NOTE on workspace scoping:
// `wsId` is used only as part of queryKey for cache isolation per workspace.
@@ -96,29 +95,6 @@ export function taskMessagesOptions(taskId: string) {
});
}
/**
* Merge task-message batches into one seq-ordered, seq-deduplicated list for
* the shared `["task-messages", taskId]` cache. Existing entries win on
* conflict, and the original array reference is preserved when nothing new
* arrives so React Query observers don't re-render on duplicate events.
*
* Both the realtime `task:message` handler (a single payload) and the
* transcript backfill (a full refetch) write this cache. Routing both through
* one helper keeps a forced backfill from blind-replacing a seq the WebSocket
* already delivered — and keeps a late WS event from being lost to an
* in-flight backfill.
*/
export function mergeTaskMessagesBySeq(
existing: readonly TaskMessagePayload[],
incoming: readonly TaskMessagePayload[],
): TaskMessagePayload[] {
if (incoming.length === 0) return existing as TaskMessagePayload[];
const knownSeqs = new Set(existing.map((m) => m.seq));
const fresh = incoming.filter((m) => !knownSeqs.has(m.seq));
if (fresh.length === 0) return existing as TaskMessagePayload[];
return [...existing, ...fresh].sort((a, b) => a.seq - b.seq);
}
/**
* Aggregate of in-flight chat tasks for the current user in this workspace.
* Drives the FAB "running" indicator while the chat window is minimised —

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createChatStore, newSessionDraftKey } from "./store";
import type { StorageAdapter } from "../types";
import type { Attachment } from "../types";
function memStorage(): StorageAdapter {
const m = new Map<string, string>();
@@ -16,26 +15,6 @@ function memStorage(): StorageAdapter {
};
}
function makeAttachment(id: string): Attachment {
return {
id,
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: `${id}.png`,
url: `/uploads/${id}.png`,
download_url: `/api/attachments/${id}/download`,
markdown_url: `/api/attachments/${id}/download`,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),
};
}
describe("newSessionDraftKey", () => {
it("derives a stable per-agent slot for an uncreated chat", () => {
expect(newSessionDraftKey("agent-1")).toBe("__new__:agent-1");
@@ -43,31 +22,38 @@ describe("newSessionDraftKey", () => {
});
});
describe("chat store — draft attachments", () => {
describe("chat store — migrateInputDraft", () => {
let store: ReturnType<typeof createChatStore>;
beforeEach(() => {
store = createChatStore({ storage: memStorage() });
});
it("deduplicates attachment drafts by id", () => {
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
store.getState().addInputDraftAttachment("draft-1", {
...makeAttachment("att-1"),
filename: "updated.png",
});
it("moves a draft to the new key and clears the source", () => {
const from = newSessionDraftKey("agent-1");
store.getState().setInputDraft(from, "!file[x.pdf]()");
expect(store.getState().inputDraftAttachments["draft-1"]).toHaveLength(1);
expect(store.getState().inputDraftAttachments["draft-1"]?.[0]?.filename).toBe("updated.png");
store.getState().migrateInputDraft(from, "session-1");
const drafts = store.getState().inputDrafts;
expect(drafts["session-1"]).toBe("!file[x.pdf]()");
// Source slot is cleared so it can't resurface in the next new chat.
expect(from in drafts).toBe(false);
});
it("clearInputDraft clears both text and attachment records", () => {
store.getState().setInputDraft("draft-1", "hello");
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
it("is a no-op when the source draft is absent", () => {
store.getState().setInputDraft("session-1", "keep me");
store.getState().clearInputDraft("draft-1");
store.getState().migrateInputDraft(newSessionDraftKey("agent-1"), "session-1");
expect(store.getState().inputDrafts["draft-1"]).toBeUndefined();
expect(store.getState().inputDraftAttachments["draft-1"]).toBeUndefined();
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
});
it("is a no-op when from === to", () => {
store.getState().setInputDraft("session-1", "keep me");
store.getState().migrateInputDraft("session-1", "session-1");
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
});
});

View File

@@ -1,6 +1,5 @@
import { create } from "zustand";
import type { StorageAdapter } from "../types";
import type { Attachment } from "../types/attachment";
import { getCurrentSlug, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { createLogger } from "../logger";
@@ -10,8 +9,6 @@ const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
const DRAFTS_KEY = "multica:chat:drafts";
/** Draft attachment records per workspace: { [sessionId]: Attachment[] }. */
const DRAFT_ATTACHMENTS_KEY = "multica:chat:draft-attachments";
/** Placeholder sessionId for a chat that hasn't been created yet. */
export const DRAFT_NEW_SESSION = "__new__";
@@ -60,49 +57,6 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
}
}
function isAttachmentDraft(value: unknown): value is Attachment {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { id?: unknown }).id === "string" &&
typeof (value as { filename?: unknown }).filename === "string"
);
}
function readDraftAttachments(storage: StorageAdapter, key: string): Record<string, Attachment[]> {
const raw = storage.getItem(key);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const out: Record<string, Attachment[]> = {};
for (const [draftKey, value] of Object.entries(parsed)) {
if (!Array.isArray(value)) continue;
const attachments = value.filter(isAttachmentDraft);
if (attachments.length > 0) out[draftKey] = attachments;
}
return out;
} catch {
return {};
}
}
function writeDraftAttachments(
storage: StorageAdapter,
key: string,
drafts: Record<string, Attachment[]>,
) {
const pruned: Record<string, Attachment[]> = {};
for (const [k, v] of Object.entries(drafts)) {
if (v.length > 0) pruned[k] = v;
}
if (Object.keys(pruned).length === 0) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(pruned));
}
}
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 380;
@@ -129,8 +83,6 @@ export interface ChatState {
selectedAgentId: string | null;
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/** Attachment rows referenced by each input draft. */
inputDraftAttachments: Record<string, Attachment[]>;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
@@ -141,9 +93,15 @@ export interface ChatState {
setSelectedAgentId: (id: string) => void;
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
setInputDraftAttachments: (sessionId: string, attachments: Attachment[]) => void;
addInputDraftAttachment: (sessionId: string, attachment: Attachment) => void;
clearInputDraft: (sessionId: string) => void;
/**
* Move a draft from one key to another, deleting the source. Used when a
* chat session is lazily created: the `__new__:agent` draft is migrated
* onto the real sessionId so it isn't stranded under the abandoned key
* (which would resurface as a stale draft the next time a new chat opens
* for that agent).
*/
migrateInputDraft: (from: string, to: string) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
@@ -172,7 +130,6 @@ export function createChatStore(options: ChatStoreOptions) {
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
inputDraftAttachments: readDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY)),
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
@@ -208,40 +165,30 @@ export function createChatStore(options: ChatStoreOptions) {
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setInputDraftAttachments: (sessionId, attachments) => {
logger.debug("setInputDraftAttachments", { sessionId, count: attachments.length });
const next = { ...get().inputDraftAttachments };
if (attachments.length > 0) next[sessionId] = attachments;
else delete next[sessionId];
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
addInputDraftAttachment: (sessionId, attachment) => {
if (!attachment.id) return;
const current = get().inputDraftAttachments;
const existing = current[sessionId] ?? [];
const nextForKey = existing.some((a) => a.id === attachment.id)
? existing.map((a) => (a.id === attachment.id ? attachment : a))
: [...existing, attachment];
const next = { ...current, [sessionId]: nextForKey };
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
clearInputDraft: (sessionId) => {
const currentDrafts = get().inputDrafts;
const currentAttachments = get().inputDraftAttachments;
if (!(sessionId in currentDrafts) && !(sessionId in currentAttachments)) {
const current = get().inputDrafts;
if (!(sessionId in current)) {
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
return;
}
logger.info("clearInputDraft", { sessionId });
const nextDrafts = { ...currentDrafts };
const nextAttachments = { ...currentAttachments };
delete nextDrafts[sessionId];
delete nextAttachments[sessionId];
writeDrafts(storage, wsKey(DRAFTS_KEY), nextDrafts);
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), nextAttachments);
set({ inputDrafts: nextDrafts, inputDraftAttachments: nextAttachments });
const next = { ...current };
delete next[sessionId];
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
migrateInputDraft: (from, to) => {
if (from === to) return;
const current = get().inputDrafts;
if (!(from in current)) {
logger.debug("migrateInputDraft skipped (no source draft)", { from, to });
return;
}
logger.info("migrateInputDraft", { from, to });
const next = { ...current, [to]: current[from]! };
delete next[from];
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setChatSize: (w, h) => {
logger.debug("setChatSize", { w, h });
@@ -266,20 +213,17 @@ export function createChatStore(options: ChatStoreOptions) {
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
const nextDraftAttachments = readDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY));
logger.info("workspace rehydration", {
prevSession: store.getState().activeSessionId,
nextSession,
prevAgent: store.getState().selectedAgentId,
nextAgent,
draftCount: Object.keys(nextDrafts).length,
draftAttachmentCount: Object.keys(nextDraftAttachments).length,
});
store.setState({
activeSessionId: nextSession,
selectedAgentId: nextAgent,
inputDrafts: nextDrafts,
inputDraftAttachments: nextDraftAttachments,
});
});

View File

@@ -45,7 +45,6 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
vi.useRealTimers();
});
describe("installFreezeWatchdog", () => {
@@ -97,38 +96,4 @@ describe("installFreezeWatchdog", () => {
expect(() => installFreezeWatchdog()).not.toThrow();
});
it("emits at most one client_unresponsive per 60s cooldown window", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
// A sustained freeze arrives as several long-task entries back to back.
fireLongTask(2500);
fireLongTask(2500);
fireLongTask(3000);
expect(captureEvent).toHaveBeenCalledTimes(1);
});
it("emits again only after the cooldown window elapses", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
fireLongTask(2500);
expect(captureEvent).toHaveBeenCalledTimes(1);
// Still inside the window → suppressed.
vi.advanceTimersByTime(59_999);
fireLongTask(2500);
expect(captureEvent).toHaveBeenCalledTimes(1);
// Window elapsed → emits again.
vi.advanceTimersByTime(1);
fireLongTask(2500);
expect(captureEvent).toHaveBeenCalledTimes(2);
});
});

View File

@@ -24,16 +24,6 @@ import { captureEvent } from "../analytics";
// felt a real stall" without flooding on routine heavy renders.
const FREEZE_THRESHOLD_MS = 2000;
// A single sustained freeze is delivered by the browser as several separate
// long-task entries, so emitting per entry makes client_unresponsive volume
// grow without bound with the freeze length (MUL-3331). A global cooldown caps
// it to at most one event per window. Module-level (page-lifetime) state is the
// right scope here — it matches the `installed` singleton and resets on a full
// reload, which is rare and itself a distinct signal. No route bucketing: a
// global window is the most direct cap on volume.
const COOLDOWN_MS = 60_000;
let lastEmitMs = 0;
let installed = false;
/**
@@ -51,11 +41,6 @@ export function installFreezeWatchdog(): void {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < FREEZE_THRESHOLD_MS) continue;
// Cooldown is checked only against qualifying freezes, so sub-threshold
// long tasks neither emit nor reset the window.
const now = Date.now();
if (now - lastEmitMs < COOLDOWN_MS) continue;
lastEmitMs = now;
captureEvent("client_unresponsive", {
source: "longtask",
duration_ms: Math.round(entry.duration),

View File

@@ -1,31 +0,0 @@
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

@@ -1,68 +0,0 @@
// @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

@@ -1,108 +0,0 @@
"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

@@ -1,72 +0,0 @@
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

@@ -1,76 +0,0 @@
/**
* 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

@@ -1,30 +0,0 @@
/**
* 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

@@ -1,69 +0,0 @@
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

@@ -1,84 +0,0 @@
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

@@ -1,108 +0,0 @@
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

@@ -1,117 +0,0 @@
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

@@ -1,114 +0,0 @@
/**
* 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,116 +100,3 @@ 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,20 +84,7 @@ export function useFileUpload(
api: ApiClient,
onError?: (error: Error) => void,
) {
// 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 [uploading, setUploading] = useState(false);
const upload = useCallback(
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
@@ -105,7 +92,7 @@ export function useFileUpload(
throw new Error("File exceeds 100 MB limit");
}
setInFlight((n) => n + 1);
setUploading(true);
try {
const att: Attachment = await api.uploadFile(file, {
issueId: ctx?.issueId,
@@ -114,7 +101,7 @@ export function useFileUpload(
});
return { ...att, link: att.url, markdownLink: pickMarkdownLink(att) };
} finally {
setInFlight((n) => n - 1);
setUploading(false);
}
},
[api],

View File

@@ -1,116 +0,0 @@
import { describe, it, expect } from "vitest";
import type { Issue } from "../types";
import { commonIssueFields } from "./batch";
function makeIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Issue 1",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 1,
stage: null,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("commonIssueFields", () => {
it("returns all-null for an empty selection (nothing to reflect)", () => {
expect(commonIssueFields([])).toEqual({
status: null,
priority: null,
assignee: null,
});
});
it("reflects a single issue's own fields", () => {
const common = commonIssueFields([
makeIssue({ status: "in_progress", priority: "high", assignee_type: "member", assignee_id: "u-1" }),
]);
expect(common.status).toBe("in_progress");
expect(common.priority).toBe("high");
expect(common.assignee).toEqual({ type: "member", id: "u-1" });
});
it("returns the shared status when every issue agrees, not a hardcoded default", () => {
// Regression for MUL-3510: the batch picker used to assert "todo"
// regardless of the selection.
const common = commonIssueFields([
makeIssue({ id: "a", status: "in_review" }),
makeIssue({ id: "b", status: "in_review" }),
]);
expect(common.status).toBe("in_review");
});
it("returns null status when the selection spans different statuses (mixed)", () => {
const common = commonIssueFields([
makeIssue({ id: "a", status: "todo" }),
makeIssue({ id: "b", status: "done" }),
]);
expect(common.status).toBeNull();
});
it("derives each field independently — shared status, mixed priority", () => {
const common = commonIssueFields([
makeIssue({ id: "a", status: "blocked", priority: "urgent" }),
makeIssue({ id: "b", status: "blocked", priority: "low" }),
]);
expect(common.status).toBe("blocked");
expect(common.priority).toBeNull();
});
it("treats an all-unassigned selection as a real shared value, not mixed", () => {
const common = commonIssueFields([
makeIssue({ id: "a", assignee_type: null, assignee_id: null }),
makeIssue({ id: "b", assignee_type: null, assignee_id: null }),
]);
expect(common.assignee).toEqual({ type: null, id: null });
});
it("returns the shared assignee when every issue points at the same actor", () => {
const common = commonIssueFields([
makeIssue({ id: "a", assignee_type: "agent", assignee_id: "agent-1" }),
makeIssue({ id: "b", assignee_type: "agent", assignee_id: "agent-1" }),
]);
expect(common.assignee).toEqual({ type: "agent", id: "agent-1" });
});
it("returns null assignee when actors differ", () => {
const common = commonIssueFields([
makeIssue({ id: "a", assignee_type: "member", assignee_id: "u-1" }),
makeIssue({ id: "b", assignee_type: "member", assignee_id: "u-2" }),
]);
expect(common.assignee).toBeNull();
});
it("returns null assignee when some are assigned and some are unassigned", () => {
const common = commonIssueFields([
makeIssue({ id: "a", assignee_type: "member", assignee_id: "u-1" }),
makeIssue({ id: "b", assignee_type: null, assignee_id: null }),
]);
expect(common.assignee).toBeNull();
});
it("distinguishes assignees of the same id but different type", () => {
const common = commonIssueFields([
makeIssue({ id: "a", assignee_type: "member", assignee_id: "x" }),
makeIssue({ id: "b", assignee_type: "agent", assignee_id: "x" }),
]);
expect(common.assignee).toBeNull();
});
});

View File

@@ -1,72 +0,0 @@
import type {
Issue,
IssueStatus,
IssuePriority,
IssueAssigneeType,
} from "../types";
/**
* Shared assignee across a selection. `{ type: null, id: null }` means every
* selected issue is unassigned — a real shared value, distinct from a mixed
* selection (which {@link commonIssueFields} reports as `assignee: null`).
*/
export interface CommonAssignee {
type: IssueAssigneeType | null;
id: string | null;
}
/**
* The status / priority / assignee shared by every issue in a batch selection.
* A field is `null` when the selection is empty or the issues disagree
* ("mixed"). Batch property pickers use this to reflect the real common value
* and fall back to an empty (no-checkmark) state when the values differ,
* instead of asserting a hardcoded default.
*/
export interface CommonIssueFields {
status: IssueStatus | null;
priority: IssuePriority | null;
assignee: CommonAssignee | null;
}
/**
* Returns the value shared by every item, or `null` when the list is empty or
* the items disagree. Comparison is by primitive equality, so callers pass a
* scalar key (collapse composite values to a string before calling).
*/
function sharedValue<T>(values: readonly T[]): T | null {
if (values.length === 0) return null;
const first = values[0]!;
return values.every((v) => v === first) ? first : null;
}
const ASSIGNEE_KEY_SEP = "\u0000";
/**
* Collapse a polymorphic assignee (type + id, either nullable) into a single
* comparable key so all-unassigned issues compare equal to each other and
* distinct from any assigned actor.
*/
function assigneeKey(type: IssueAssigneeType | null, id: string | null): string {
return `${type ?? ""}${ASSIGNEE_KEY_SEP}${id ?? ""}`;
}
/**
* Derive the common status / priority / assignee of the selected issues.
* Pass the already-filtered selection (the issues that are actually selected),
* mirroring how the skill list filters its rows by `selectedIds` before
* handing them to its batch toolbar.
*/
export function commonIssueFields(issues: readonly Issue[]): CommonIssueFields {
const status = sharedValue(issues.map((i) => i.status));
const priority = sharedValue(issues.map((i) => i.priority));
const sharedAssigneeKey = sharedValue(
issues.map((i) => assigneeKey(i.assignee_type, i.assignee_id)),
);
const assignee =
sharedAssigneeKey !== null && issues.length > 0
? { type: issues[0]!.assignee_type, id: issues[0]!.assignee_id }
: null;
return { status, priority, assignee };
}

View File

@@ -1,131 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Issue, ListIssuesCache } from "../types";
import { insertByPosition, patchIssueInBuckets } from "./cache-helpers";
const WS_ID = "ws-1";
function mk(id: string, status: Issue["status"], position: number): Issue {
return {
id,
workspace_id: WS_ID,
number: 1,
identifier: `MUL-${id}`,
title: id,
description: null,
status,
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position,
stage: null,
start_date: null,
due_date: null,
metadata: {},
labels: [],
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
}
function cache(byStatus: ListIssuesCache["byStatus"]): ListIssuesCache {
return { byStatus };
}
function ids(c: ListIssuesCache, status: Issue["status"]): string[] {
return (c.byStatus[status]?.issues ?? []).map((i) => i.id);
}
describe("insertByPosition", () => {
it("inserts at the position-sorted slot", () => {
const a = mk("a", "todo", 1);
const c = mk("c", "todo", 3);
const b = mk("b", "todo", 2);
expect(insertByPosition([a, c], b).map((i) => i.id)).toEqual([
"a",
"b",
"c",
]);
});
it("appends when the new position is the largest", () => {
const a = mk("a", "todo", 1);
const z = mk("z", "todo", 9);
expect(insertByPosition([a], z).map((i) => i.id)).toEqual(["a", "z"]);
});
it("prepends when the new position is the smallest", () => {
const b = mk("b", "todo", 2);
const a = mk("a", "todo", 1);
expect(insertByPosition([b], a).map((i) => i.id)).toEqual(["a", "b"]);
});
});
describe("patchIssueInBuckets — cross-status move", () => {
it("inserts the moved card at its position slot, not the end", () => {
const c0 = cache({
todo: { issues: [mk("moved", "todo", 5)], total: 1 },
in_progress: {
issues: [mk("x", "in_progress", 1), mk("y", "in_progress", 3)],
total: 2,
},
});
// Move "moved" into in_progress at position 2 (between x and y).
const next = patchIssueInBuckets(c0, "moved", {
status: "in_progress",
position: 2,
});
expect(ids(next, "in_progress")).toEqual(["x", "moved", "y"]);
expect(ids(next, "todo")).toEqual([]);
});
it("adjusts both bucket totals", () => {
const c0 = cache({
todo: { issues: [mk("moved", "todo", 5)], total: 1 },
in_progress: { issues: [mk("x", "in_progress", 1)], total: 1 },
});
const next = patchIssueInBuckets(c0, "moved", {
status: "in_progress",
position: 2,
});
expect(next.byStatus.todo?.total).toBe(0);
expect(next.byStatus.in_progress?.total).toBe(2);
});
});
describe("patchIssueInBuckets — same status", () => {
it("keeps the slot for a plain field update (no reorder)", () => {
const c0 = cache({
todo: {
issues: [mk("a", "todo", 1), mk("b", "todo", 2), mk("c", "todo", 3)],
total: 3,
},
});
// A remote label/title edit must not move the card.
const next = patchIssueInBuckets(c0, "b", { title: "renamed" });
expect(ids(next, "todo")).toEqual(["a", "b", "c"]);
expect(next.byStatus.todo?.issues[1]?.title).toBe("renamed");
});
it("re-sorts within the column when position changes", () => {
const c0 = cache({
todo: {
issues: [mk("a", "todo", 1), mk("b", "todo", 2), mk("c", "todo", 3)],
total: 3,
},
});
// Drag "a" below "b" (new position 2.5).
const next = patchIssueInBuckets(c0, "a", { position: 2.5 });
expect(ids(next, "todo")).toEqual(["b", "a", "c"]);
});
});
describe("patchIssueInBuckets — unknown issue", () => {
it("returns the cache unchanged when the id is absent", () => {
const c0 = cache({ todo: { issues: [mk("a", "todo", 1)], total: 1 } });
expect(patchIssueInBuckets(c0, "ghost", { position: 9 })).toBe(c0);
});
});

View File

@@ -63,28 +63,10 @@ export function removeIssueFromBuckets(
});
}
/**
* Insert `issue` into `issues` at the slot implied by `position ASC` — the same
* ordering the board renders (server `ORDER BY position ASC`). Returns a new
* array; the input is not mutated.
*
* Inserting at the right slot (instead of appending to the end) is what keeps an
* optimistic move from snapping: the card lands where it will be after the
* server confirms, so no later cache refresh teleports it to the column tail.
*/
export function insertByPosition(issues: Issue[], issue: Issue): Issue[] {
const idx = issues.findIndex((i) => i.position > issue.position);
if (idx === -1) return [...issues, issue];
return [...issues.slice(0, idx), issue, ...issues.slice(idx)];
}
/**
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
* current bucket, the issue moves to the new bucket and both buckets' totals
* are adjusted. The moved card — and a same-column card whose `position`
* changed — is re-inserted at its `position`-sorted slot rather than appended,
* so the cache order stays consistent with what the board renders. A plain
* field update (no status/position change) keeps the card in place.
* are adjusted.
*/
export function patchIssueInBuckets(
resp: ListIssuesCache,
@@ -98,23 +80,9 @@ export function patchIssueInBuckets(
if (nextStatus === loc.status) {
const bucket = getBucket(resp, loc.status);
const positionChanged =
patch.position !== undefined && patch.position !== loc.issue.position;
if (!positionChanged) {
// Plain field update (labels, metadata, title, …): keep the slot so a
// remote edit never reorders an otherwise-untouched column.
return setBucket(resp, loc.status, {
...bucket,
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
});
}
// Same-column reorder: lift the card out and re-insert at its new slot.
return setBucket(resp, loc.status, {
...bucket,
issues: insertByPosition(
bucket.issues.filter((i) => i.id !== id),
merged,
),
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
});
}
@@ -125,7 +93,7 @@ export function patchIssueInBuckets(
total: Math.max(0, fromBucket.total - 1),
});
next = setBucket(next, nextStatus, {
issues: insertByPosition(toBucket.issues, merged),
issues: [...toBucket.issues, merged],
total: toBucket.total + 1,
});
return next;

View File

@@ -2,19 +2,13 @@
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import {
useBatchUpdateIssues,
useLoadMoreByAssigneeGroup,
useLoadMoreByStatus,
useResolveComment,
useUpdateIssue,
} from "./mutations";
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus, useResolveComment } from "./mutations";
import {
issueKeys,
type IssueSortParam,
@@ -52,7 +46,6 @@ function makeIssue(idx: number, overrides: Partial<Issue> = {}): Issue {
parent_issue_id: null,
project_id: null,
position: idx,
stage: null,
start_date: null,
due_date: null,
labels: [],
@@ -321,270 +314,6 @@ describe("useLoadMoreByAssigneeGroup", () => {
});
});
describe("useUpdateIssue — optimistic move keeps every bucketed board in sync", () => {
const sort: IssueSortParam = { sort_by: "position", sort_direction: undefined };
const myScope = "assigned";
const myFilter = { assignee_id: "user-1" };
const wsKey = issueKeys.listSorted(WS_ID, sort);
// My-Issues AND the Project board both ride this myList cache; a move that
// only patched the workspace cache snaps back on those boards.
const myKey = issueKeys.myListSorted(WS_ID, myScope, myFilter, sort);
let qc: QueryClient;
let updateIssue: ReturnType<typeof vi.fn<(id: string, data: unknown) => Promise<Issue>>>;
function makeBucketed(): ListIssuesCache {
return {
byStatus: {
todo: { issues: [makeIssue(1)], total: 1 },
in_progress: { issues: [], total: 0 },
},
};
}
function bucketIds(
key: readonly unknown[],
status: "todo" | "in_progress",
): string[] {
const c = qc.getQueryData<ListIssuesCache>(key);
return (c?.byStatus[status]?.issues ?? []).map((i) => i.id);
}
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
updateIssue = vi.fn();
setApiInstance({ updateIssue } as unknown as ApiClient);
qc.setQueryData<ListIssuesCache>(wsKey, makeBucketed());
qc.setQueryData<ListIssuesCache>(myKey, makeBucketed());
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("optimistically moves the card in both the workspace and myList caches", 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", status: "in_progress", position: 5 });
});
// Optimistic state — the regression: myList must move too, not just ws.
for (const key of [wsKey, myKey]) {
expect(bucketIds(key, "todo")).toEqual([]);
expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]);
}
await act(async () => {
resolve(makeIssue(1, { status: "in_progress", position: 5 }));
});
// Authoritative settle keeps the card in place in both caches.
for (const key of [wsKey, myKey]) {
expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]);
}
});
it("rolls both caches back 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", status: "in_progress", position: 5 })
.catch(() => {});
});
for (const key of [wsKey, myKey]) {
expect(bucketIds(key, "todo")).toEqual(["issue-1"]);
expect(bucketIds(key, "in_progress")).toEqual([]);
}
});
it("does not invalidate the board list on settle (no refetch flicker)", async () => {
updateIssue.mockResolvedValue(makeIssue(1, { status: "in_progress", position: 5 }));
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
const { result } = renderHook(() => useUpdateIssue(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync({ id: "issue-1", status: "in_progress", position: 5 });
});
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
// The board list + myList are reconciled surgically, never refetched.
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("useBatchUpdateIssues — optimistic patch covers filtered boards too", () => {
const sort: IssueSortParam = { sort_by: "position", sort_direction: undefined };
const myScope = "assigned";
const myFilter = { assignee_id: "user-1" };
const wsKey = issueKeys.listSorted(WS_ID, sort);
const myKey = issueKeys.myListSorted(WS_ID, myScope, myFilter, sort);
let qc: QueryClient;
let batchUpdateIssues: ReturnType<
typeof vi.fn<(ids: string[], updates: unknown) => Promise<{ updated: number }>>
>;
function makeBucketed(): ListIssuesCache {
return {
byStatus: {
todo: { issues: [makeIssue(1)], total: 1 },
in_progress: { issues: [], total: 0 },
},
};
}
function bucketIds(key: readonly unknown[], status: "todo" | "in_progress"): string[] {
const c = qc.getQueryData<ListIssuesCache>(key);
return (c?.byStatus[status]?.issues ?? []).map((i) => i.id);
}
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
batchUpdateIssues = vi.fn();
setApiInstance({ batchUpdateIssues } as unknown as ApiClient);
qc.setQueryData<ListIssuesCache>(wsKey, makeBucketed());
qc.setQueryData<ListIssuesCache>(myKey, makeBucketed());
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("optimistically patches BOTH the workspace and myList caches (not just ws)", async () => {
let resolve!: (r: { updated: number }) => void;
batchUpdateIssues.mockReturnValue(
new Promise<{ updated: number }>((r) => {
resolve = r;
}),
);
const { result } = renderHook(() => useBatchUpdateIssues(), {
wrapper: createWrapper(qc),
});
act(() => {
result.current.mutate({ ids: ["issue-1"], updates: { status: "in_progress" } });
});
// The regression Howard flagged: batch must move the card on the myList
// board too, not only the workspace board. onMutate awaits cancelQueries,
// so the optimistic patch lands a microtask later — wait for it.
await waitFor(() => {
for (const key of [wsKey, myKey]) {
expect(bucketIds(key, "todo")).toEqual([]);
expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]);
}
});
await act(async () => {
resolve({ updated: 1 });
});
for (const key of [wsKey, myKey]) {
expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]);
}
});
it("rolls both caches back when the request fails", async () => {
batchUpdateIssues.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useBatchUpdateIssues(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current
.mutateAsync({ ids: ["issue-1"], updates: { status: "in_progress" } })
.catch(() => {});
});
for (const key of [wsKey, myKey]) {
expect(bucketIds(key, "todo")).toEqual(["issue-1"]);
expect(bucketIds(key, "in_progress")).toEqual([]);
}
});
it("does not invalidate the board list on settle (no refetch flicker)", async () => {
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: { status: "in_progress" } });
});
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", () => {
const ISSUE_ID = "issue-1";

View File

@@ -211,37 +211,16 @@ export function useCreateIssue() {
export function useUpdateIssue() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
// Every bucketed board cache an optimistic move must keep in sync: the
// workspace board (issueKeys.list*) AND the My-Issues / Project board
// (issueKeys.myList* under `my`), which share the ListIssuesCache shape.
// Filtering by `byStatus` skips the grouped (assignee) and flat
// (gantt/detail/children) caches that also live under those prefixes. The
// board reconciles local columns from its own feeding cache on settle, so a
// move that only patched the workspace cache would snap back on My-Issues /
// Project boards.
const readBucketedLists = () =>
[
...qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) }),
...qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.myAll(wsId) }),
].filter(
(entry): entry is [QueryKey, ListIssuesCache] => !!entry[1]?.byStatus,
);
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
api.updateIssue(id, data),
onMutate: ({ id, ...data }) => {
// suppress_run / handoff_note are write-time control fields, not Issue
// columns — they steer enqueue/injection on the server and must never be
// written into the query cache (MUL-3375). Strip them from the patch; the
// mutationFn above still sends the full payload to the API.
const { suppress_run: _suppressRun, handoff_note: _handoffNote, ...patch } = data;
// Fire-and-forget cancelQueries — keeps onMutate synchronous so the
// cache update happens in the same tick as mutate(). Awaiting would
// yield to the event loop, letting @dnd-kit reset its visual state
// before the optimistic update lands.
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) });
const prevLists = readBucketedLists();
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
const firstListData = prevLists[0]?.[1];
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
@@ -272,16 +251,16 @@ export function useUpdateIssue() {
: undefined;
for (const [key, cached] of prevLists) {
if (cached) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(cached, id, patch));
if (cached) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(cached, id, data));
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
old ? { ...old, ...patch } : old,
old ? { ...old, ...data } : old,
);
if (parentId) {
qc.setQueryData<Issue[]>(
issueKeys.children(wsId, parentId),
(old) =>
old?.map((c) => (c.id === id ? { ...c, ...patch } : c)),
old?.map((c) => (c.id === id ? { ...c, ...data } : c)),
);
}
return { prevLists, prevDetail, prevChildren, parentId, id };
@@ -301,29 +280,9 @@ export function useUpdateIssue() {
);
}
},
onSuccess: (serverIssue) => {
// Reconcile with the authoritative server entity by patching the one card
// in place — NOT by invalidating + refetching the list. The list refetch
// is what made a successful move flicker: the optimistic card was already
// in the right place, then the refetch replaced the whole column and the
// card re-landed. updateIssue returns the full issue and a position update
// touches only that row, so a surgical patch is the authoritative
// reconcile and is a visual no-op when the optimistic value matched.
for (const [key, cached] of readBucketedLists()) {
qc.setQueryData<ListIssuesCache>(
key,
patchIssueInBuckets(cached, serverIssue.id, serverIssue),
);
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, serverIssue.id), (old) =>
old ? { ...old, ...serverIssue } : old,
);
},
onSettled: (_data, _err, vars, ctx) => {
// The issue's own list + detail caches are reconciled surgically in
// onSuccess / onError, so they are deliberately NOT invalidated here — a
// full-list refetch on settle is what made drags flicker. Only aggregate
// caches that cannot be patched from a single issue are refreshed below.
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
@@ -333,15 +292,6 @@ 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
@@ -458,27 +408,12 @@ export function useBatchUpdateIssues() {
updates: UpdateIssueRequest;
}) => api.batchUpdateIssues(ids, updates),
onMutate: async ({ ids, updates }) => {
// Patch BOTH the workspace board (issueKeys.list) and the filtered
// My-Issues / Project / actor lists (issueKeys.myAll). The single-issue
// update already patches both; batch only touched issueKeys.list, so a
// batch edit on a My-Issues board had no optimistic effect and relied
// entirely on the settle refetch. Filter to bucketed (byStatus) caches so
// grouped/flat caches under the same prefix are skipped.
//
// Control fields steer the server; they are not Issue columns and must
// not enter the cache (MUL-3375). mutationFn still sends them.
const { suppress_run: _suppressRun, handoff_note: _handoffNote, ...patch } = updates;
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
await qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) });
const prevLists = [
...qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) }),
...qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.myAll(wsId) }),
].filter(
(entry): entry is [QueryKey, ListIssuesCache] => !!entry[1]?.byStatus,
);
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
for (const [key, cached] of prevLists) {
if (!cached) continue;
let next = cached;
for (const id of ids) next = patchIssueInBuckets(next, id, patch);
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
qc.setQueryData<ListIssuesCache>(key, next);
}
@@ -497,7 +432,7 @@ export function useBatchUpdateIssues() {
affectedParentIds.add(parentId);
prevChildren.set(parentId, data);
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (idSet.has(c.id) ? { ...c, ...patch } : c)),
old?.map((c) => (idSet.has(c.id) ? { ...c, ...updates } : c)),
);
}
@@ -516,13 +451,7 @@ export function useBatchUpdateIssues() {
}
},
onSettled: (_data, _err, _vars, ctx) => {
// Deliberately NOT invalidating issueKeys.list / myAll here: the onMutate
// patch above is a complete surgical reconcile for these bucketed boards
// (batch changes status / priority / project — never a server-computed
// value), so a full-board refetch on settle would only re-introduce the
// flicker the single-issue update already removed. Aggregate / grouped
// caches that cannot be recomputed from a single-issue patch are still
// refreshed below.
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
@@ -532,11 +461,6 @@ 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

@@ -33,7 +33,6 @@ function makeIssue(idx: number): Issue {
parent_issue_id: null,
project_id: PROJECT_ID,
position: idx,
stage: null,
start_date: "2026-05-01T00:00:00Z",
due_date: null,
labels: [],

View File

@@ -83,14 +83,6 @@ export const issueKeys = {
/** PREFIX for invalidation — the composer hook appends parent + content signature. */
commentTriggerPreview: (issueId: string) =>
[...issueKeys.commentTriggerPreviewAll(), issueId] as const,
/** Prefix across all issue-trigger previews (assign/status/create/batch).
* WS task lifecycle events invalidate here so the answer revalidates when an
* agent's queue state changes (the status source's pending dedup makes it
* queue-dependent, mirroring commentTriggerPreviewAll). */
issueTriggerPreviewAll: () => ["issues", "issue-trigger-preview"] as const,
/** PREFIX — the picker hook appends a signature of the prospective write. */
issueTriggerPreview: (signature: string) =>
[...issueKeys.issueTriggerPreviewAll(), signature] as const,
reactionsAll: () => ["issues", "reactions"] as const,
reactions: (issueId: string) =>
[...issueKeys.reactionsAll(), issueId] as const,

View File

@@ -72,7 +72,6 @@ const baseIssue: Issue = {
parent_issue_id: null,
project_id: null,
position: 0,
stage: null,
start_date: null,
due_date: null,
metadata: {},
@@ -263,184 +262,6 @@ describe("project progress invalidation", () => {
});
});
describe("onIssueUpdated — position move is surgical, not a list refetch", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
const issueA: Issue = { ...baseIssue, id: "issue-1", position: 0 };
const issueB: Issue = { ...baseIssue, id: "issue-2", position: 10 };
it("reorders the moved card in place and does NOT invalidate the workspace list", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), makeListCache(issueA, issueB));
// issue-1 moves below issue-2 (position 0 -> 20) — a remote/echoed drag.
onIssueUpdated(qc, WS_ID, { ...issueA, position: 20 });
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
// Surgically reordered into its new slot: proof the patch alone suffices.
expect(list?.byStatus.todo?.issues.map((i) => i.id)).toEqual(["issue-2", "issue-1"]);
// The old redundant `position -> invalidate(list)` is gone — no full-board
// refetch on top of the surgical patch (that was the flicker source).
expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false);
});
it("surgically patches the filtered myAll lists on a non-membership change (no refetch)", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), makeListCache(issueA, issueB));
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA, issueB));
// Pure position move: membership cannot change, so myAll is patched in place.
onIssueUpdated(qc, WS_ID, { ...issueA, position: 20 });
const my = qc.getQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID));
expect(my?.byStatus.todo?.issues.map((i) => i.id)).toEqual(["issue-2", "issue-1"]);
// Reconciled in place — no full-list refetch on My Issues (that was the
// remaining drag flicker on filtered boards).
expect(qc.getQueryState(issueKeys.myAll(WS_ID))?.isInvalidated).toBe(false);
});
it("invalidates myAll when the assignee changes (membership may shift)", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
onIssueUpdated(
qc,
WS_ID,
{ ...issueA, assignee_type: "member", assignee_id: "user-2" },
{ assigneeChanged: true },
);
expectInvalidated(qc, issueKeys.myAll(WS_ID));
});
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. 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", () => {
let qc: QueryClient;

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