mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 16:39:33 +02:00
Compare commits
5 Commits
fix/header
...
agent/j/ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
035a7f482b | ||
|
|
2aa40e9024 | ||
|
|
28f8574158 | ||
|
|
faa018fd70 | ||
|
|
c1b236f9cf |
35
.env.example
35
.env.example
@@ -21,16 +21,11 @@ APP_ENV=
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
PORT=8080
|
||||
# Docker Compose consumes flat port values. Set BACKEND_PORT directly to
|
||||
# override the backend host port.
|
||||
BACKEND_PORT=8080
|
||||
# Optional aliases for local/self-host backend port helpers outside compose.
|
||||
# Optional aliases for the local/self-host backend port. If one is set, it
|
||||
# takes precedence over PORT in compose, Makefile, and installer helpers.
|
||||
# BACKEND_PORT=8080
|
||||
# API_PORT=8080
|
||||
# SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when serving frontend on a different origin/domain.
|
||||
FRONTEND_ORIGIN=http://localhost:${FRONTEND_PORT}
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
@@ -40,9 +35,9 @@ JWT_SECRET=change-me-in-production
|
||||
# Derived by Makefile / local scripts from the backend port.
|
||||
# Set explicitly only when the daemon reaches the API through a different URL.
|
||||
# MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when the app's public URL differs from local frontend.
|
||||
MULTICA_APP_URL=${FRONTEND_ORIGIN}
|
||||
# MULTICA_APP_URL=http://localhost:3000
|
||||
# Public URL the API is reachable at from the open internet (no trailing
|
||||
# slash). Used to mint absolute webhook URLs for autopilot webhook
|
||||
# triggers and to show correct daemon setup commands in the web UI. Leave
|
||||
@@ -117,9 +112,9 @@ SMTP_EHLO_NAME=
|
||||
# rebuild is needed.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when your OAuth callback URL differs from local frontend.
|
||||
GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
|
||||
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
@@ -127,8 +122,6 @@ GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
|
||||
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
|
||||
S3_BUCKET=
|
||||
S3_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
# AWS_ENDPOINT_URL — optional S3-compatible endpoint (MinIO, RustFS, R2, etc.).
|
||||
# For internal Docker/VPC hosts such as http://rustfs:9000, leave
|
||||
# ATTACHMENT_DOWNLOAD_MODE=auto or set proxy explicitly so browsers/CLI do
|
||||
@@ -206,16 +199,6 @@ CORS_ALLOWED_ORIGINS=
|
||||
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
|
||||
GITHUB_APP_SLUG=
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
# Optional: GitHub App identity for App-authenticated REST calls. When set,
|
||||
# the setup callback enriches the installation row with the real account
|
||||
# login (org / user name) immediately after install. When unset, the row
|
||||
# is created with the "unknown" placeholder and the next `installation`
|
||||
# webhook from GitHub overwrites it — set both to skip that interim flash.
|
||||
# GITHUB_APP_ID is the numeric "App ID" shown on the App's settings page.
|
||||
# GITHUB_APP_PRIVATE_KEY is the full PEM block (including BEGIN/END lines)
|
||||
# generated under "Private keys" on that same page; preserve newlines.
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# Lark / Feishu bot integration (Settings → Integrations "Bind to Lark")
|
||||
# Off until MULTICA_LARK_SECRET_KEY is set — a base64-encoded 32-byte key
|
||||
@@ -235,6 +218,10 @@ MULTICA_LARK_HTTP_BASE_URL=
|
||||
MULTICA_LARK_CALLBACK_BASE_URL=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when serving frontend on a different origin/domain.
|
||||
# FRONTEND_ORIGIN=http://localhost:3000
|
||||
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
|
||||
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -98,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
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
32
AGENTS.md
32
AGENTS.md
@@ -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
507
CLAUDE.md
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -169,7 +168,7 @@ Daemon behavior is configured via flags or environment variables:
|
||||
|---------|------|--------------|---------|
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `0` (no cap; bounded by the watchdogs) |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
@@ -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 Qoder’s 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
|
||||
|
||||
@@ -714,79 +699,3 @@ Most commands support `--output` with two formats:
|
||||
multica issue list --output json
|
||||
multica daemon status --output json
|
||||
```
|
||||
|
||||
## Error Messages
|
||||
|
||||
The CLI funnels command errors returned to the top-level handler through a
|
||||
single user-facing translation layer (`server/internal/cli/errors.go`) so that
|
||||
what you see on the terminal is a short, actionable sentence rather than a raw
|
||||
Go error, an HTTP status line, or an internal `resolve issue: ...` chain. (A
|
||||
few commands print their own output or run deliberate fast probes — for example
|
||||
`setup`'s short `/health` reachability check — and don't go through this
|
||||
layer.) The underlying detail is still available on demand (see `--debug`).
|
||||
|
||||
### What you see
|
||||
|
||||
- **Friendly, single-line message.** Transport failures (timeout, DNS,
|
||||
connection refused, TLS) and HTTP status failures (401/403/404/409/400·422/
|
||||
429/5xx) are each rendered as one clear sentence with a next step — for
|
||||
example a timeout suggests checking the network or raising
|
||||
`MULTICA_HTTP_TIMEOUT`, and a 401 tells you to run `multica login`.
|
||||
- **Server-provided validation messages are preserved.** For a 400/422 that
|
||||
carries a message from the server, that message is shown verbatim
|
||||
(`Invalid request: <server message>`); only when there is none do you get the
|
||||
generic "check your values / run with --help" hint.
|
||||
- **No leaked internals by default.** Raw URLs, status lines, JSON bodies, and
|
||||
the internal verb chain are hidden unless you ask for them.
|
||||
|
||||
### Language
|
||||
|
||||
Messages default to **English**, matching the rest of the CLI's help output.
|
||||
If a Chinese locale is detected in `LC_ALL`, `LC_MESSAGES`, or `LANG` (in that
|
||||
precedence order), messages switch to **Chinese**. No flag is needed; set the
|
||||
locale as usual:
|
||||
|
||||
```bash
|
||||
LANG=zh_CN.UTF-8 multica issue get MUL-9999 # 错误信息显示为中文
|
||||
```
|
||||
|
||||
### Exit codes
|
||||
|
||||
The process exit code is tiered so scripts can branch on the failure class:
|
||||
|
||||
| Exit code | Meaning |
|
||||
| --- | --- |
|
||||
| `0` | success |
|
||||
| `1` | generic / unclassified error |
|
||||
| `2` | network error (timeout, DNS, connection refused, TLS, offline) |
|
||||
| `3` | authentication / authorization (HTTP 401, 403) |
|
||||
| `4` | not found (HTTP 404) |
|
||||
| `5` | validation (HTTP 400, 422) |
|
||||
|
||||
```bash
|
||||
multica issue get MUL-9999
|
||||
if [ $? -eq 4 ]; then echo "no such issue"; fi
|
||||
```
|
||||
|
||||
### Seeing the full detail (`--debug`)
|
||||
|
||||
Pass the global `--debug` flag (or set `MULTICA_DEBUG=1`) to print the complete
|
||||
original error chain — the internal verb chain, the request method/path/status,
|
||||
and the raw server body — underneath the friendly message. Use it when you need
|
||||
to file a bug or understand exactly what the server returned:
|
||||
|
||||
```bash
|
||||
multica issue list --debug
|
||||
MULTICA_DEBUG=1 multica issue update MUL-1234 --title "x"
|
||||
```
|
||||
|
||||
### Request timeout
|
||||
|
||||
API requests use a default timeout of 30 seconds. Override it with
|
||||
`MULTICA_HTTP_TIMEOUT` when you are on a slow network; it accepts a Go duration
|
||||
(`45s`, `2m`) or a plain number of seconds (`45`). Command-level deadlines are
|
||||
always at least this value, so raising it takes effect across all commands.
|
||||
|
||||
```bash
|
||||
MULTICA_HTTP_TIMEOUT=60s multica issue list
|
||||
```
|
||||
|
||||
@@ -15,12 +15,10 @@ COPY server/ ./server/
|
||||
# Build binaries
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
ARG DATE=unknown
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
|
||||
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 -X main.version=${VERSION} -X main.commit=${COMMIT}" -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 +31,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
|
||||
|
||||
23
Makefile
23
Makefile
@@ -58,17 +58,12 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
JWT=$$(openssl rand -hex 32); \
|
||||
PGPASS=$$(openssl rand -hex 24); \
|
||||
if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
|
||||
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
|
||||
else \
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
|
||||
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Pulling official Multica images..."
|
||||
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
|
||||
@@ -113,17 +108,12 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
JWT=$$(openssl rand -hex 32); \
|
||||
PGPASS=$$(openssl rand -hex 24); \
|
||||
if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
|
||||
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
|
||||
else \
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
|
||||
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Building Multica from the current checkout..."
|
||||
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
|
||||
@@ -296,7 +286,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 +307,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."
|
||||
|
||||
13
README.md
13
README.md
@@ -19,9 +19,8 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
[](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,选择 Provider(Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi、Kiro CLI 或 Qoder CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude 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 |
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// Capture every MenuItem the SUT constructs so each test can assert
|
||||
// on the menu that would appear at popup time without booting an
|
||||
// actual Electron window. State is created via `vi.hoisted` because
|
||||
// `vi.mock` factories are hoisted above all top-level statements; a
|
||||
// plain `const` would be a TDZ ReferenceError when the factory runs.
|
||||
type CapturedMenuItem = {
|
||||
label?: string;
|
||||
role?: string;
|
||||
type?: string;
|
||||
click?: () => void;
|
||||
};
|
||||
const ctx = vi.hoisted(() => ({
|
||||
capturedItems: [] as CapturedMenuItem[][],
|
||||
browserWindowFromWebContents: vi.fn(),
|
||||
popupSpy: vi.fn(),
|
||||
clipboardWriteText: vi.fn(),
|
||||
openExternalSpy: vi.fn().mockResolvedValue(undefined),
|
||||
preferredLanguagesRef: { current: ["en-US"] as string[] },
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => {
|
||||
class MockMenu {
|
||||
items: CapturedMenuItem[] = [];
|
||||
constructor() {
|
||||
ctx.capturedItems.push(this.items);
|
||||
}
|
||||
append(item: CapturedMenuItem) {
|
||||
this.items.push(item);
|
||||
}
|
||||
popup(opts: unknown) {
|
||||
ctx.popupSpy(opts);
|
||||
}
|
||||
}
|
||||
class MockMenuItem {
|
||||
label?: string;
|
||||
role?: string;
|
||||
type?: string;
|
||||
click?: () => void;
|
||||
constructor(opts: CapturedMenuItem) {
|
||||
Object.assign(this, opts);
|
||||
}
|
||||
}
|
||||
return {
|
||||
BrowserWindow: { fromWebContents: ctx.browserWindowFromWebContents },
|
||||
Menu: MockMenu,
|
||||
MenuItem: MockMenuItem,
|
||||
app: {
|
||||
getPreferredSystemLanguages: () => ctx.preferredLanguagesRef.current,
|
||||
},
|
||||
clipboard: { writeText: ctx.clipboardWriteText },
|
||||
shell: { openExternal: ctx.openExternalSpy },
|
||||
};
|
||||
});
|
||||
|
||||
import { installContextMenu } from "./context-menu";
|
||||
|
||||
type ContextMenuParams = {
|
||||
selectionText: string;
|
||||
isEditable: boolean;
|
||||
linkURL: string;
|
||||
editFlags: {
|
||||
canCut: boolean;
|
||||
canCopy: boolean;
|
||||
canPaste: boolean;
|
||||
canSelectAll: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type Listener = (event: unknown, params: ContextMenuParams) => void;
|
||||
|
||||
// Tiny WebContents stub — we only need the `.on("context-menu", ...)`
|
||||
// hook the SUT installs and a way to fire it back at our own listener
|
||||
// list. Everything else (clipboard, link opening, label resolution) is
|
||||
// mocked above.
|
||||
function makeWebContents() {
|
||||
const handlers: Listener[] = [];
|
||||
return {
|
||||
on(event: string, fn: Listener) {
|
||||
if (event === "context-menu") handlers.push(fn);
|
||||
},
|
||||
fire(params: ContextMenuParams) {
|
||||
for (const h of handlers) h({}, params);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseEditFlags = {
|
||||
canCut: false,
|
||||
canCopy: false,
|
||||
canPaste: false,
|
||||
canSelectAll: false,
|
||||
};
|
||||
|
||||
describe("installContextMenu — link items", () => {
|
||||
beforeEach(() => {
|
||||
ctx.capturedItems.length = 0;
|
||||
ctx.popupSpy.mockClear();
|
||||
ctx.clipboardWriteText.mockClear();
|
||||
ctx.openExternalSpy.mockClear();
|
||||
ctx.browserWindowFromWebContents.mockReset();
|
||||
ctx.preferredLanguagesRef.current = ["en-US"];
|
||||
});
|
||||
|
||||
it("adds 'Open Link in Browser' and 'Copy Link Address' when right-clicking an http(s) link", () => {
|
||||
// The link case is the one this test file is here to cover —
|
||||
// before MUL-3083 follow-up, right-clicking an <a> in the
|
||||
// renderer only surfaced 'copy' (when the user happened to have
|
||||
// text selected) and gave no way to open the URL externally.
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire({
|
||||
...baseSelection({ linkURL: "https://multica.ai/welcome" }),
|
||||
});
|
||||
|
||||
const labels = lastMenuLabels();
|
||||
expect(labels).toContain("Open Link in Browser");
|
||||
expect(labels).toContain("Copy Link Address");
|
||||
|
||||
// The two click handlers must route to the existing
|
||||
// openExternalSafely allowlist + clipboard.writeText.
|
||||
invokeByLabel("Open Link in Browser");
|
||||
expect(ctx.openExternalSpy).toHaveBeenCalledWith("https://multica.ai/welcome");
|
||||
|
||||
invokeByLabel("Copy Link Address");
|
||||
expect(ctx.clipboardWriteText).toHaveBeenCalledWith(
|
||||
"https://multica.ai/welcome",
|
||||
);
|
||||
expect(ctx.popupSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT add link items when the cursor is over a non-http(s) URL", () => {
|
||||
// Only http(s) links are surfaced — we don't promise anything for
|
||||
// mailto:, javascript:, custom app schemes, etc. Surfacing them
|
||||
// would shell out via openExternalSafely (which would block the
|
||||
// call anyway) or write a non-URL string to the clipboard, both
|
||||
// of which violate user expectations for a "link" item.
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire(baseSelection({ linkURL: "javascript:alert(1)" }));
|
||||
const labels = lastMenuLabelsOrEmpty();
|
||||
expect(labels).not.toContain("Open Link in Browser");
|
||||
expect(labels).not.toContain("Copy Link Address");
|
||||
});
|
||||
|
||||
it("does NOT add link items when there is no link under the cursor", () => {
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire({
|
||||
selectionText: "hello",
|
||||
isEditable: false,
|
||||
linkURL: "",
|
||||
editFlags: { ...baseEditFlags, canCopy: true },
|
||||
});
|
||||
const labels = lastMenuLabelsOrEmpty();
|
||||
expect(labels).not.toContain("Open Link in Browser");
|
||||
// Selection-only context still surfaces copy as before — guards
|
||||
// against a regression where adding the link branch broke the
|
||||
// base path.
|
||||
expect(menuItemRoles()).toContain("copy");
|
||||
});
|
||||
|
||||
it("uses zh-Hans labels when the OS preferred language is Chinese", () => {
|
||||
// Locale fallback is intentionally permissive: every zh-* variant
|
||||
// routes to zh-Hans so users on zh-CN / zh-TW / zh-HK still see
|
||||
// Chinese rather than dropping to English. The renderer ships only
|
||||
// zh-Hans translations, so this matches the rest of the app.
|
||||
ctx.preferredLanguagesRef.current = ["zh-CN"];
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
|
||||
expect(lastMenuLabels()).toContain("在浏览器中打开链接");
|
||||
expect(lastMenuLabels()).toContain("复制链接地址");
|
||||
});
|
||||
|
||||
it("falls back to English when the OS preferred language is something we don't ship", () => {
|
||||
ctx.preferredLanguagesRef.current = ["fr-FR"];
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
|
||||
expect(lastMenuLabels()).toContain("Open Link in Browser");
|
||||
});
|
||||
});
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
function baseSelection(over: Partial<ContextMenuParams>): ContextMenuParams {
|
||||
return {
|
||||
selectionText: "",
|
||||
isEditable: false,
|
||||
linkURL: "",
|
||||
editFlags: { ...baseEditFlags },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function lastMenu(): CapturedMenuItem[] {
|
||||
const last = ctx.capturedItems[ctx.capturedItems.length - 1];
|
||||
if (!last) throw new Error("no menu was constructed");
|
||||
return last;
|
||||
}
|
||||
|
||||
function lastMenuLabelsOrEmpty(): string[] {
|
||||
const last = ctx.capturedItems[ctx.capturedItems.length - 1] ?? [];
|
||||
return last.map((i) => i.label ?? "");
|
||||
}
|
||||
|
||||
function lastMenuLabels(): string[] {
|
||||
return lastMenu().map((i) => i.label ?? "");
|
||||
}
|
||||
|
||||
function menuItemRoles(): string[] {
|
||||
return lastMenu().map((i) => i.role ?? "");
|
||||
}
|
||||
|
||||
function invokeByLabel(label: string): void {
|
||||
const item = lastMenu().find((i) => i.label === label);
|
||||
if (!item) throw new Error(`menu item not found: ${label}`);
|
||||
item.click?.();
|
||||
}
|
||||
@@ -1,38 +1,12 @@
|
||||
import {
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
MenuItem,
|
||||
app,
|
||||
clipboard,
|
||||
type WebContents,
|
||||
} from "electron";
|
||||
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
|
||||
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
|
||||
|
||||
// Electron ships with no default right-click menu, so a user selecting text
|
||||
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
|
||||
// menu using `roles`, which keeps i18n + accelerator handling native.
|
||||
//
|
||||
// Custom (non-role) link items below are NOT auto-localized by Electron —
|
||||
// roles like "copy" pull labels from the OS, but a custom MenuItem only
|
||||
// shows the `label` you give it. We translate by OS-preferred language so
|
||||
// the link items at least track Chinese / Japanese / Korean speakers
|
||||
// alongside the English default; everything else falls through to English,
|
||||
// which matches Chrome's behavior on those locales without app-level
|
||||
// translation files.
|
||||
export function installContextMenu(webContents: WebContents): void {
|
||||
webContents.on("context-menu", (_event, params) => {
|
||||
const { editFlags, selectionText, isEditable, linkURL } = params;
|
||||
const { editFlags, selectionText, isEditable } = params;
|
||||
const hasSelection = selectionText.trim().length > 0;
|
||||
// params.linkURL is the resolved absolute URL of the anchor under the
|
||||
// cursor; Electron normalizes relative hrefs against the page URL for
|
||||
// us, so we only need to gate on the http(s) scheme allowlist
|
||||
// (mirrors openExternalSafely + the renderer's <a> usage). Empty for
|
||||
// non-link right-clicks; other schemes (mailto:, javascript:, custom
|
||||
// app schemes) are intentionally not surfaced — opening them via
|
||||
// shell.openExternal would route through the OS handler and is
|
||||
// outside what this menu promises.
|
||||
const linkIsHttpUrl = !!linkURL && isSafeExternalHttpUrl(linkURL);
|
||||
const labels = pickLabels();
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
@@ -52,87 +26,8 @@ export function installContextMenu(webContents: WebContents): void {
|
||||
menu.append(new MenuItem({ role: "selectAll" }));
|
||||
}
|
||||
|
||||
// Link items — only when the cursor is over an actual http(s) <a>.
|
||||
// Without these the renderer's <a target="_blank"> gives users no
|
||||
// standard right-click affordance ("Open in new window", "Copy link
|
||||
// address"); the default click handler does forward to
|
||||
// setWindowOpenHandler → openExternalSafely, but discoverability via
|
||||
// the keyboard / mouse context menu was missing.
|
||||
if (linkIsHttpUrl) {
|
||||
if (menu.items.length > 0) {
|
||||
menu.append(new MenuItem({ type: "separator" }));
|
||||
}
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: labels.openLink,
|
||||
click: () => {
|
||||
// openExternalSafely re-validates the scheme — defense in
|
||||
// depth in case Electron ever surfaces a non-http linkURL
|
||||
// we forgot to filter at this layer.
|
||||
void openExternalSafely(linkURL);
|
||||
},
|
||||
}),
|
||||
);
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: labels.copyLinkAddress,
|
||||
click: () => {
|
||||
clipboard.writeText(linkURL);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (menu.items.length === 0) return;
|
||||
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
|
||||
menu.popup({ window });
|
||||
});
|
||||
}
|
||||
|
||||
// Labels for the two link-related menu items in the user's OS-preferred
|
||||
// language, with English as the fallback. Kept inline because the main
|
||||
// process has no shared i18n loader (the renderer's i18next is per-window
|
||||
// and not reachable from here), and pulling one in for two strings would
|
||||
// be more rope than payload. Matches the four locales the renderer ships.
|
||||
type ContextMenuLabels = {
|
||||
openLink: string;
|
||||
copyLinkAddress: string;
|
||||
};
|
||||
|
||||
const labelsByLocale: Record<string, ContextMenuLabels> = {
|
||||
en: {
|
||||
openLink: "Open Link in Browser",
|
||||
copyLinkAddress: "Copy Link Address",
|
||||
},
|
||||
"zh-Hans": {
|
||||
openLink: "在浏览器中打开链接",
|
||||
copyLinkAddress: "复制链接地址",
|
||||
},
|
||||
ja: {
|
||||
openLink: "ブラウザでリンクを開く",
|
||||
copyLinkAddress: "リンクのアドレスをコピー",
|
||||
},
|
||||
ko: {
|
||||
openLink: "브라우저에서 링크 열기",
|
||||
copyLinkAddress: "링크 주소 복사",
|
||||
},
|
||||
};
|
||||
|
||||
// pickLabels resolves the OS-preferred language to one of the four
|
||||
// locales we ship copy for. We say "Open Link in Browser" rather than
|
||||
// "Open Link in New Window" because the link is opened via
|
||||
// shell.openExternal — it lands in the user's default browser, not in
|
||||
// another Multica window — so the wording matches what actually
|
||||
// happens.
|
||||
function pickLabels(): ContextMenuLabels {
|
||||
const preferred = app.getPreferredSystemLanguages()[0]?.toLowerCase() ?? "";
|
||||
if (preferred.startsWith("zh")) {
|
||||
// All Chinese variants get the Simplified copy — Multica only
|
||||
// ships zh-Hans, and zh-Hant users falling through to en would be
|
||||
// worse than reading Simplified Chinese.
|
||||
return labelsByLocale["zh-Hans"];
|
||||
}
|
||||
if (preferred.startsWith("ja")) return labelsByLocale.ja;
|
||||
if (preferred.startsWith("ko")) return labelsByLocale.ko;
|
||||
return labelsByLocale.en;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,8 @@ import {
|
||||
import { join } from "path";
|
||||
import { homedir, hostname } from "os";
|
||||
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
|
||||
import { daemonStatusAlive } from "../shared/daemon-types";
|
||||
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
|
||||
import { decideVersionAction } from "./version-decision";
|
||||
import {
|
||||
daemonLifecycleUnreachable,
|
||||
isDaemonExternallyManaged,
|
||||
normalizeHostOS,
|
||||
} from "./daemon-os";
|
||||
import {
|
||||
classifyAuthProbe,
|
||||
isAuthStatusError,
|
||||
@@ -41,13 +35,6 @@ const LOG_TAIL_MAX_RETRIES = 5;
|
||||
// take a while (it renews the PAT and lists workspaces before serving /health), so we
|
||||
// wait past the common case to avoid probing healthy-but-slow starts.
|
||||
const AUTH_PROBE_GRACE_MS = 10_000;
|
||||
// `multica daemon start` blocks until the daemon reports ready, polling /health
|
||||
// for up to its own startup timeout (45s in server/cmd/multica/cmd_daemon.go) to
|
||||
// cover cold-start agent-version detection. This execFile timeout MUST stay
|
||||
// above that — otherwise Electron kills the CLI supervisor mid-startup and a
|
||||
// healthy-but-slow start is misreported as a failure (the detached daemon child
|
||||
// keeps running, so the UI flashes "stopped" then "running").
|
||||
const DAEMON_START_EXEC_TIMEOUT_MS = 60_000;
|
||||
|
||||
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
|
||||
|
||||
@@ -166,8 +153,6 @@ function sendStatus(status: DaemonStatus): void {
|
||||
interface HealthPayload {
|
||||
status?: string;
|
||||
pid?: number;
|
||||
/** Daemon's runtime.GOOS. Absent on daemons older than the #3916 fix. */
|
||||
os?: string;
|
||||
uptime?: string;
|
||||
daemon_id?: string;
|
||||
device_name?: string;
|
||||
@@ -336,13 +321,6 @@ async function fetchHealth(): Promise<DaemonStatus> {
|
||||
if (authExpired) {
|
||||
return { state: "auth_expired", profile: active.name };
|
||||
}
|
||||
// The daemon binds /health before preflight finishes and self-reports
|
||||
// "starting" until it's ready. Trust that over our own currentState, so a
|
||||
// daemon booting on its own — or started via the CLI — surfaces as
|
||||
// "starting" instead of "stopped".
|
||||
if (data?.status === "starting") {
|
||||
return { state: "starting", profile: active.name };
|
||||
}
|
||||
return {
|
||||
state: currentState === "starting" ? "starting" : "stopped",
|
||||
profile: active.name,
|
||||
@@ -354,16 +332,6 @@ async function fetchHealth(): Promise<DaemonStatus> {
|
||||
authExpired = false;
|
||||
startingSince = null;
|
||||
|
||||
// A running daemon whose OS differs from this host's is one we can't drive
|
||||
// via the native lifecycle CLI (e.g. Linux-in-WSL2 behind a Windows desktop,
|
||||
// reachable only over localhost forwarding). Surface it so the UI disables
|
||||
// the auto-start/auto-stop toggles instead of letting them silently no-op,
|
||||
// and so before-quit skips a stop that would never land. See #3916.
|
||||
const externallyManaged = isDaemonExternallyManaged(
|
||||
data.os,
|
||||
normalizeHostOS(process.platform),
|
||||
);
|
||||
|
||||
// Safety: if we have a target URL and the daemon on our port reports a
|
||||
// different server_url, it's not "our" daemon — drop it and re-resolve.
|
||||
if (
|
||||
@@ -387,7 +355,6 @@ async function fetchHealth(): Promise<DaemonStatus> {
|
||||
: 0,
|
||||
profile: active.name,
|
||||
serverUrl: data.server_url,
|
||||
externallyManaged,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -574,15 +541,6 @@ async function ensureRunningDaemonVersionMatches(): Promise<
|
||||
> {
|
||||
const active = await ensureActiveProfile();
|
||||
const running = await fetchHealthAtPort(active.port);
|
||||
|
||||
// Don't try to version-match a daemon we can't restart (e.g. WSL2). Treat it
|
||||
// as up-to-date — restartDaemon would no-op anyway, and skipping here avoids
|
||||
// a misleading "restarting daemon" log on every auto-start. #3916.
|
||||
if (isDaemonExternallyManaged(running?.os, normalizeHostOS(process.platform))) {
|
||||
pendingVersionRestart = false;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
const bundled = await getCliBinaryVersion();
|
||||
const action = decideVersionAction(bundled, running);
|
||||
|
||||
@@ -705,10 +663,7 @@ async function syncToken(
|
||||
if (userChanged) {
|
||||
try {
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (daemonStatusAlive(existing?.status)) {
|
||||
// Restart whether it's "running" or still "starting" — a booting daemon
|
||||
// already loaded the old token at startup, so it must be restarted to
|
||||
// pick up the rotated credentials.
|
||||
if (existing?.status === "running") {
|
||||
console.log(
|
||||
"[daemon] user switched — restarting daemon with new credentials",
|
||||
);
|
||||
@@ -825,10 +780,7 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (daemonStatusAlive(existing?.status)) {
|
||||
// A daemon is already up ("running") or booting ("starting") on this port —
|
||||
// don't spawn a second one (the CLI rejects that as "already running").
|
||||
// Let polling track it through to "running".
|
||||
if (existing?.status === "running") {
|
||||
pollOnce();
|
||||
return { success: true };
|
||||
}
|
||||
@@ -846,7 +798,7 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
execFile(
|
||||
bin,
|
||||
args,
|
||||
{ timeout: DAEMON_START_EXEC_TIMEOUT_MS, env: desktopSpawnEnv() },
|
||||
{ timeout: 20_000, env: desktopSpawnEnv() },
|
||||
(err) => {
|
||||
if (err) {
|
||||
currentState = "stopped";
|
||||
@@ -864,32 +816,7 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh boundary preflight for stop/restart: read the active profile's CURRENT
|
||||
* /health and decide whether the daemon runs somewhere the app can't drive
|
||||
* (WSL2 etc.). Done per call rather than off the poll cache, so a lifecycle op
|
||||
* never shells out to a CLI that can't reach the daemon's process — even on
|
||||
* paths that didn't just poll (e.g. restart-on-user-switch in syncToken, which
|
||||
* calls restartDaemon directly). See #3916.
|
||||
*/
|
||||
async function lifecycleBlockedByForeignDaemon(): Promise<boolean> {
|
||||
const active = await ensureActiveProfile();
|
||||
return daemonLifecycleUnreachable(
|
||||
async () => (await fetchHealthAtPort(active.port))?.os,
|
||||
normalizeHostOS(process.platform),
|
||||
);
|
||||
}
|
||||
|
||||
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
// Central lifecycle guard: a daemon running in an environment we can't drive
|
||||
// (e.g. Linux in WSL2 behind a Windows desktop) can't be stopped by the
|
||||
// native CLI — it would act on the host process namespace and no-op, while
|
||||
// still flipping our state to "stopped". Bail as a successful no-op so every
|
||||
// caller (logout, quit, restart, the Runtime card) is covered in one place
|
||||
// rather than each remembering to check. Preflighted against live /health so
|
||||
// it holds even when no poll ran first. #3916.
|
||||
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
|
||||
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return { success: false, error: "multica CLI is not installed" };
|
||||
|
||||
@@ -916,11 +843,6 @@ async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
// Same central, live-preflighted guard as stopDaemon: we can neither stop nor
|
||||
// start a daemon we don't manage, so don't try (user-switch, reauth,
|
||||
// first-workspace, and any future restart caller all route through here).
|
||||
// #3916.
|
||||
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
|
||||
const stopResult = await stopDaemon();
|
||||
if (!stopResult.success) return stopResult;
|
||||
return startDaemon();
|
||||
@@ -1168,8 +1090,6 @@ export function setupDaemonManager(
|
||||
isQuitting = true;
|
||||
event.preventDefault();
|
||||
try {
|
||||
// stopDaemon no-ops for an externally-managed daemon (WSL2 etc.), so
|
||||
// this is safe and instant in that case — the guard lives there. #3916
|
||||
await stopDaemon();
|
||||
} catch {
|
||||
// Best-effort stop on quit
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
daemonLifecycleUnreachable,
|
||||
isDaemonExternallyManaged,
|
||||
normalizeHostOS,
|
||||
} from "./daemon-os";
|
||||
|
||||
describe("normalizeHostOS", () => {
|
||||
it("maps win32 to the GOOS spelling 'windows'", () => {
|
||||
expect(normalizeHostOS("win32")).toBe("windows");
|
||||
});
|
||||
|
||||
it("passes darwin and linux through unchanged (already GOOS spellings)", () => {
|
||||
expect(normalizeHostOS("darwin")).toBe("darwin");
|
||||
expect(normalizeHostOS("linux")).toBe("linux");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDaemonExternallyManaged", () => {
|
||||
it("flags a Linux (WSL2) daemon behind a Windows desktop — the #3916 case", () => {
|
||||
expect(isDaemonExternallyManaged("linux", normalizeHostOS("win32"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// These three are the "不误伤" guarantees: a native daemon on each platform
|
||||
// must keep its auto-start/auto-stop toggles.
|
||||
it("does NOT flag a native Windows daemon under a Windows desktop", () => {
|
||||
expect(isDaemonExternallyManaged("windows", normalizeHostOS("win32"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT flag a native macOS daemon under a macOS desktop", () => {
|
||||
expect(isDaemonExternallyManaged("darwin", normalizeHostOS("darwin"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT flag a native Linux daemon under a Linux desktop", () => {
|
||||
expect(isDaemonExternallyManaged("linux", normalizeHostOS("linux"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Fail safe: an older daemon that predates the `os` field reports nothing.
|
||||
// Hiding a toggle on a guess would 误伤, so unknown OS = treat as manageable.
|
||||
it("fails safe to false when the daemon reports no OS", () => {
|
||||
expect(isDaemonExternallyManaged(undefined, "windows")).toBe(false);
|
||||
expect(isDaemonExternallyManaged("", "windows")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The stop/restart lifecycle boundary funnels through this. It must read the
|
||||
// daemon's LIVE OS (not a cached poll value), so a restart on a path that
|
||||
// didn't just poll — e.g. user-switch — still can't shell out at a WSL2 daemon.
|
||||
describe("daemonLifecycleUnreachable", () => {
|
||||
it("consults the live OS reader and blocks a foreign-OS (WSL2) daemon", async () => {
|
||||
const readDaemonOS = vi.fn().mockResolvedValue("linux");
|
||||
expect(await daemonLifecycleUnreachable(readDaemonOS, "windows")).toBe(true);
|
||||
// Proves the decision came from a fresh read, not a stale cache.
|
||||
expect(readDaemonOS).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows a native daemon whose live OS matches the host", async () => {
|
||||
expect(
|
||||
await daemonLifecycleUnreachable(async () => "windows", "windows"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await daemonLifecycleUnreachable(async () => "darwin", "darwin"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails safe to false when the live OS is unknown (older daemon / none running)", async () => {
|
||||
expect(
|
||||
await daemonLifecycleUnreachable(async () => undefined, "windows"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Detecting a daemon the desktop app can't manage.
|
||||
*
|
||||
* The app reads daemon liveness over HTTP at 127.0.0.1:{port}/health, but it
|
||||
* starts/stops the daemon by shelling out to the bundled native CLI, which acts
|
||||
* on the *host* OS process namespace. On Windows with the daemon running inside
|
||||
* WSL2, /health is reachable via localhost forwarding (so status looks fine) but
|
||||
* the daemon's process lives in a separate Linux namespace the Windows CLI can't
|
||||
* touch — so auto-start / auto-stop silently do nothing (#3916).
|
||||
*
|
||||
* The reliable, low-false-positive signal is the daemon's own OS (reported as
|
||||
* `os` on /health, = runtime.GOOS) vs the desktop host OS. They only disagree
|
||||
* when the daemon runs in a foreign environment we can't drive. This module is
|
||||
* the single source of truth for that comparison so it stays unit-tested — the
|
||||
* cost of a false positive is hiding a working toggle from a native user, so the
|
||||
* logic must fail safe (treat unknown / matching as manageable).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a Node `process.platform` value to the daemon's `runtime.GOOS`
|
||||
* vocabulary so the two are directly comparable. Only `win32` -> `windows`
|
||||
* actually differs across the platforms we ship (darwin/linux already match);
|
||||
* any other value passes through unchanged.
|
||||
*/
|
||||
export function normalizeHostOS(platform: NodeJS.Platform): string {
|
||||
return platform === "win32" ? "windows" : platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a running daemon is in an environment the desktop app can't control.
|
||||
*
|
||||
* Returns true ONLY when the daemon reports a concrete OS that differs from the
|
||||
* host's. Fails safe to false when:
|
||||
* - `daemonOS` is missing/empty (older daemon that predates the `os` field, or
|
||||
* a malformed response) — we can't prove it's foreign, so keep toggles live.
|
||||
* - the OSes match — a normally-managed native daemon.
|
||||
*
|
||||
* Callers must only invoke this for a daemon that is actually running; a stopped
|
||||
* daemon has no OS to compare and its toggles must stay enabled.
|
||||
*/
|
||||
export function isDaemonExternallyManaged(
|
||||
daemonOS: string | undefined,
|
||||
hostOS: string,
|
||||
): boolean {
|
||||
if (typeof daemonOS !== "string" || daemonOS.length === 0) return false;
|
||||
return daemonOS !== hostOS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary preflight for daemon lifecycle ops (stop / restart): resolve the
|
||||
* daemon's CURRENT OS via `readDaemonOS` and return true when it's running
|
||||
* somewhere the app can't drive.
|
||||
*
|
||||
* `readDaemonOS` is a live `/health` read performed at the call site — never a
|
||||
* cached poll value. That is the whole point: a stale "manageable" cache would
|
||||
* let a lifecycle op shell out to a native CLI that can't reach a WSL2 daemon
|
||||
* (the PID lives in another namespace), which is exactly the bug. Taking the
|
||||
* reader as a parameter keeps this unit-testable without the electron-coupled
|
||||
* daemon-manager module, and lets the test prove the live value — not a cache —
|
||||
* drives the decision. See #3916.
|
||||
*/
|
||||
export async function daemonLifecycleUnreachable(
|
||||
readDaemonOS: () => Promise<string | undefined>,
|
||||
hostOS: string,
|
||||
): Promise<boolean> {
|
||||
return isDaemonExternallyManaged(await readDaemonOS(), hostOS);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
type FreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Each test gets its own temp dir so the on-disk breadcrumb is isolated.
|
||||
const dirs: string[] = [];
|
||||
function tempFile(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "freeze-breadcrumb-"));
|
||||
dirs.push(dir);
|
||||
return join(dir, "last-client-failure.json");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const sample: FreezeBreadcrumb = {
|
||||
kind: "unresponsive",
|
||||
context: { desktopRoute: { path: "/acme/issues" } },
|
||||
ts: 1_700_000_000_000,
|
||||
version: "0.3.1",
|
||||
};
|
||||
|
||||
describe("freeze breadcrumb round-trip", () => {
|
||||
it("writes then reads back the breadcrumb", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
});
|
||||
|
||||
it("read clears the file so a failure reports exactly once", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearFreezeBreadcrumb removes a pending breadcrumb (hang recovered)", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
clearFreezeBreadcrumb(file);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The breadcrumb crosses a process boundary (main writes, renderer flushes via
|
||||
// IPC) and lives across app versions — a future write shape or a corrupt file
|
||||
// must never throw into boot. CLAUDE.md "API Response Compatibility".
|
||||
describe("freeze breadcrumb defends against malformed input", () => {
|
||||
it("returns null when no file exists", () => {
|
||||
expect(readAndClearFreezeBreadcrumb(tempFile())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on corrupt JSON", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "{ not valid json", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is missing", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ ts: 1, version: "x" }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is the wrong type", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ kind: 42, context: {} }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on a JSON null payload", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "null", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing a non-existent file is a no-op, never throws", () => {
|
||||
expect(() => clearFreezeBreadcrumb(tempFile())).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
// When the renderer truly hangs or its process dies, it can't send telemetry
|
||||
// itself — the thread is blocked or gone. The main process (always alive) is
|
||||
// the only watcher that can react, but during the hang it can't reach the
|
||||
// renderer's posthog-js either. So it writes a breadcrumb to disk; the next
|
||||
// time a renderer boots, it reads + clears the file and reports the event.
|
||||
// This survives even a force-quit, which is the whole point.
|
||||
|
||||
export type { FreezeBreadcrumb };
|
||||
|
||||
/**
|
||||
* Best-effort write. A breadcrumb we can't persist is lost, never fatal.
|
||||
*
|
||||
* Known limitation: this is a single slot — last write wins. Multiple failures
|
||||
* within one session collapse to the last one, so per-session failure counts
|
||||
* are undercounted. Acceptable for now: telemetry aggregates presence and
|
||||
* frequency across users, not exhaustive per-session sequences. Upgrade to an
|
||||
* append/ring buffer if per-session failure chains become a question.
|
||||
*/
|
||||
export function writeFreezeBreadcrumb(filePath: string, breadcrumb: FreezeBreadcrumb): void {
|
||||
try {
|
||||
writeFileSync(filePath, JSON.stringify(breadcrumb), "utf8");
|
||||
} catch {
|
||||
// Disk full / permissions — drop silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a persisted breadcrumb. Called when the renderer recovers from a hang
|
||||
* (a `responsive` event after `unresponsive`): the breadcrumb was written
|
||||
* pre-emptively while the thread was stuck, but since it came back, the
|
||||
* in-thread long-task watchdog already reports it — keeping the breadcrumb
|
||||
* would double-count it AND mislabel a recovered window as `recovered: false`.
|
||||
* Best-effort; a stale breadcrumb only costs one duplicate report.
|
||||
*/
|
||||
export function clearFreezeBreadcrumb(filePath: string): void {
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// Nothing to clear / permissions — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the breadcrumb and delete it in the same call, so a failure is reported
|
||||
* exactly once. Returns null when there's no breadcrumb (the normal case) or
|
||||
* when the file is unreadable / corrupt.
|
||||
*/
|
||||
export function readAndClearFreezeBreadcrumb(filePath: string): FreezeBreadcrumb | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// If we can't delete it we'd re-report next launch; acceptable over throwing.
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
typeof (parsed as FreezeBreadcrumb).kind === "string"
|
||||
) {
|
||||
return parsed as FreezeBreadcrumb;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt JSON — drop.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -13,21 +13,11 @@ import { installNavigationGestures } from "./navigation-gestures";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
sanitizeRendererRouteContext,
|
||||
type RendererRouteContext,
|
||||
} from "../shared/renderer-route-context";
|
||||
import {
|
||||
createElectronReloadPrompt,
|
||||
installRendererRecoveryHandlers,
|
||||
type RendererRecoveryWindow,
|
||||
} from "./renderer-recovery";
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
@@ -71,15 +61,7 @@ if (process.platform !== "win32") {
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
// Where the main process parks a freeze/crash breadcrumb until the next
|
||||
// renderer boot flushes it to telemetry. Lives in userData so it survives a
|
||||
// force-quit. Resolved lazily — app.getPath is only valid after `ready`.
|
||||
function freezeBreadcrumbPath(): string {
|
||||
return join(app.getPath("userData"), "last-client-failure.json");
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let latestRendererRouteContext: RendererRouteContext | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
@@ -144,7 +126,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.
|
||||
@@ -183,19 +165,10 @@ function createWindow(): void {
|
||||
additionalArguments: [`--multica-locale=${systemLocale}`],
|
||||
},
|
||||
});
|
||||
const window = mainWindow;
|
||||
latestRendererRouteContext = null;
|
||||
|
||||
window.on("closed", () => {
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
latestRendererRouteContext = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Strip Origin header from WebSocket upgrade requests so the server's
|
||||
// origin whitelist doesn't reject connections from localhost dev origins.
|
||||
window.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ["wss://*/*", "ws://*/*"] },
|
||||
(details, callback) => {
|
||||
delete details.requestHeaders["Origin"];
|
||||
@@ -203,8 +176,8 @@ function createWindow(): void {
|
||||
},
|
||||
);
|
||||
|
||||
window.on("ready-to-show", () => {
|
||||
window.show();
|
||||
mainWindow.on("ready-to-show", () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Detect OS language changes while the app is running. Electron has no
|
||||
@@ -212,28 +185,24 @@ function createWindow(): void {
|
||||
// catches the common case where users switch System Settings → Language
|
||||
// and bring the app back. The renderer decides whether to act (it ignores
|
||||
// the signal when the user has an explicit Settings choice).
|
||||
window.on("focus", () => {
|
||||
mainWindow.on("focus", () => {
|
||||
const current = getSystemLocale();
|
||||
if (current === lastKnownSystemLocale) return;
|
||||
lastKnownSystemLocale = current;
|
||||
window.webContents.send("locale:system-changed", current);
|
||||
mainWindow?.webContents.send("locale:system-changed", current);
|
||||
});
|
||||
|
||||
window.webContents.setWindowOpenHandler((details) => {
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
openExternalSafely(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Window-level keyboard shortcuts. Calling preventDefault here prevents
|
||||
// both the renderer keydown AND the application menu accelerator, so
|
||||
// anything we own here (reload-block, zoom, tab-close) is the sole handler
|
||||
// for that combination — no double-fire with the macOS default View menu.
|
||||
window.webContents.on("before-input-event", (event, input) => {
|
||||
const result = handleAppShortcut(input, window.webContents);
|
||||
if (result === "close-tab") {
|
||||
event.preventDefault();
|
||||
window.webContents.send("tab:close-active");
|
||||
} else if (result) {
|
||||
// anything we own here (reload-block, zoom) is the sole handler for
|
||||
// that combination — no double-fire with the macOS default View menu.
|
||||
mainWindow.webContents.on("before-input-event", (event, input) => {
|
||||
if (handleAppShortcut(input, mainWindow!.webContents)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
@@ -255,7 +224,7 @@ function createWindow(): void {
|
||||
// Forward every renderer-side console.* call. The detail object also
|
||||
// carries source URL + line — included so a thrown stack trace from
|
||||
// window.onerror is traceable back to a file.
|
||||
window.webContents.on("console-message", (details) => {
|
||||
mainWindow.webContents.on("console-message", (details) => {
|
||||
const { level, message, sourceId, lineNumber } = details;
|
||||
log(level, `${message} (${sourceId}:${lineNumber})`);
|
||||
});
|
||||
@@ -263,7 +232,7 @@ function createWindow(): void {
|
||||
// Fires when loadURL / loadFile can't reach its target (dev server
|
||||
// not up yet, network blip, file missing). errorCode is a Chromium
|
||||
// net error number; -3 = ABORTED is normal during HMR and skipped.
|
||||
window.webContents.on(
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (errorCode === -3) return;
|
||||
@@ -276,41 +245,20 @@ function createWindow(): void {
|
||||
|
||||
}
|
||||
|
||||
installRendererRecoveryHandlers(window as unknown as RendererRecoveryWindow, {
|
||||
installRendererRecoveryHandlers(mainWindow as unknown as RendererRecoveryWindow, {
|
||||
isDev: is.dev,
|
||||
showReloadPrompt: createElectronReloadPrompt((options) =>
|
||||
dialog.showMessageBox(window, options),
|
||||
dialog.showMessageBox(mainWindow!, options),
|
||||
),
|
||||
getDiagnosticContext: () => ({
|
||||
windowUrl: window.webContents.getURL(),
|
||||
...(latestRendererRouteContext
|
||||
? { desktopRoute: latestRendererRouteContext }
|
||||
: {}),
|
||||
}),
|
||||
// Only persist in production: a true hang/crash can't report itself, so we
|
||||
// write a breadcrumb and the next renderer boot flushes it to PostHog. Dev
|
||||
// is excluded to keep field telemetry clean.
|
||||
persistBreadcrumb: is.dev
|
||||
? undefined
|
||||
: (payload) =>
|
||||
writeFreezeBreadcrumb(freezeBreadcrumbPath(), {
|
||||
kind: payload.kind,
|
||||
context: payload.context,
|
||||
ts: Date.now(),
|
||||
version: getAppVersion(),
|
||||
}),
|
||||
clearBreadcrumb: is.dev
|
||||
? undefined
|
||||
: () => clearFreezeBreadcrumb(freezeBreadcrumbPath()),
|
||||
});
|
||||
|
||||
installContextMenu(window.webContents);
|
||||
installNavigationGestures(window);
|
||||
installContextMenu(mainWindow.webContents);
|
||||
installNavigationGestures(mainWindow);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
window.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
} else {
|
||||
window.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,11 +365,6 @@ if (!gotTheLock) {
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
// Renderer requests window close (e.g. Cmd+W on last tab).
|
||||
ipcMain.on("window:close", () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle("file:download-url", (_event, url: string) => {
|
||||
if (!mainWindow) {
|
||||
console.warn("[download] ignored file:download-url — mainWindow torn down");
|
||||
@@ -440,14 +383,6 @@ if (!gotTheLock) {
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: read + clear any freeze/crash breadcrumb left by a previous
|
||||
// session. The renderer flushes it to telemetry on boot (it couldn't be
|
||||
// reported when it happened — the renderer was hung or gone). Read-and-
|
||||
// clear so a failure reports exactly once.
|
||||
ipcMain.on("freeze:get-last", (event) => {
|
||||
event.returnValue = readAndClearFreezeBreadcrumb(freezeBreadcrumbPath());
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
@@ -455,13 +390,6 @@ if (!gotTheLock) {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
});
|
||||
|
||||
ipcMain.on(RENDERER_ROUTE_CONTEXT_CHANNEL, (event, context: unknown) => {
|
||||
if (!mainWindow || event.sender !== mainWindow.webContents) return;
|
||||
const sanitized = sanitizeRendererRouteContext(context);
|
||||
if (!sanitized) return;
|
||||
latestRendererRouteContext = sanitized;
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
@@ -14,14 +14,13 @@ function makeWc(initialLevel = 0) {
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta" | "shift">> = {},
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
|
||||
): ShortcutInput {
|
||||
return {
|
||||
type: "keyDown",
|
||||
key: k,
|
||||
control: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
...mods,
|
||||
};
|
||||
}
|
||||
@@ -151,36 +150,3 @@ describe("handleAppShortcut — unrelated keys pass through", () => {
|
||||
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — close tab (Cmd/Ctrl+W)", () => {
|
||||
it('returns "close-tab" on Cmd+W (macOS)', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w", { meta: true }), wc, "darwin")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it('returns "close-tab" on Cmd+W uppercase', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { meta: true }), wc, "darwin")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it('returns "close-tab" on Ctrl+W (Linux/Windows)', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w", { control: true }), wc, "linux")).toBe("close-tab");
|
||||
expect(handleAppShortcut(key("w", { control: true }), wc, "win32")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it("does not trigger without Cmd/Ctrl modifier", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w"), wc, "darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trigger on Cmd+Shift+W (reserved for close-window)", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { meta: true, shift: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trigger on Ctrl+Shift+W (reserved for close-window)", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { control: true, shift: true }), wc, "linux")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ export type ShortcutInput = {
|
||||
key: string;
|
||||
control: boolean;
|
||||
meta: boolean;
|
||||
shift: boolean;
|
||||
};
|
||||
|
||||
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
|
||||
@@ -35,19 +34,11 @@ const ZOOM_MAX = 4.5;
|
||||
* Handling the shortcuts here gives identical behavior on every platform
|
||||
* and every layout.
|
||||
*/
|
||||
/**
|
||||
* Result of handleAppShortcut:
|
||||
* - `false`: not handled, let Electron continue
|
||||
* - `true`: handled (preventDefault), no further action
|
||||
* - `"close-tab"`: Cmd/Ctrl+W intercepted — caller should send IPC to renderer
|
||||
*/
|
||||
export type ShortcutResult = boolean | "close-tab";
|
||||
|
||||
export function handleAppShortcut(
|
||||
input: ShortcutInput,
|
||||
webContents: ZoomTarget,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): ShortcutResult {
|
||||
): boolean {
|
||||
if (input.type !== "keyDown") return false;
|
||||
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
|
||||
|
||||
@@ -79,12 +70,5 @@ export function handleAppShortcut(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + W → close active tab (or window if last tab).
|
||||
// Cmd/Ctrl + Shift + W is reserved for "close window" — do not intercept.
|
||||
// Return a signal so the caller can send IPC to the renderer.
|
||||
if (input.key.toLowerCase() === "w" && !input.shift) {
|
||||
return "close-tab";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createElectronReloadPrompt, installRendererRecoveryHandlers } from "./renderer-recovery";
|
||||
import { installRendererRecoveryHandlers } from "./renderer-recovery";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
@@ -83,50 +83,10 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
|
||||
const desktopRoute = {
|
||||
surface: "tab",
|
||||
path: "/acme/issues/MUL-3239",
|
||||
workspaceSlug: "acme",
|
||||
tabId: "tab-1",
|
||||
reportedAt: "2026-06-15T00:00:00.000Z",
|
||||
};
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext: () => ({
|
||||
windowUrl:
|
||||
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
|
||||
desktopRoute,
|
||||
}),
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledWith({
|
||||
kind: "unresponsive",
|
||||
context: {
|
||||
windowUrl:
|
||||
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
|
||||
desktopRoute,
|
||||
},
|
||||
});
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps prompting when diagnostic context collection fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext: () => {
|
||||
throw new Error("diagnostics unavailable");
|
||||
},
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
@@ -134,6 +94,7 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} });
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps dev diagnostics non-prompting", async () => {
|
||||
@@ -148,124 +109,4 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
expect(showReloadPrompt).not.toHaveBeenCalled();
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows actionable recovery guidance before diagnostic details", async () => {
|
||||
let detail = "";
|
||||
const showMessageBox = vi.fn(
|
||||
async (options: { title: string; message: string; detail: string }) => {
|
||||
detail = options.detail;
|
||||
return { response: 1 };
|
||||
},
|
||||
);
|
||||
const showReloadPrompt = createElectronReloadPrompt(showMessageBox);
|
||||
|
||||
await showReloadPrompt({ kind: "unresponsive", context: {} });
|
||||
|
||||
expect(showMessageBox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Multica needs to reload",
|
||||
message: "The desktop window has been stuck for a few seconds.",
|
||||
detail: expect.stringContaining(
|
||||
"Click Reload to refresh this window and keep using Multica.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(detail).toContain("what you were doing right before this message appeared");
|
||||
expect(detail).toContain("Activity Monitor sample");
|
||||
expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("freeze/crash breadcrumb state machine", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function install(fixture: ReturnType<typeof makeWindow>) {
|
||||
const persistBreadcrumb = vi.fn();
|
||||
const clearBreadcrumb = vi.fn();
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt: vi.fn(async () => "dismiss" as const),
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
return { persistBreadcrumb, clearBreadcrumb };
|
||||
}
|
||||
|
||||
it("a sustained hang writes exactly one unresponsive breadcrumb", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "unresponsive" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovering after a written breadcrumb clears it (no double-count, no false recovered:false)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
expect(clearBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recovering before the delay never writes a breadcrumb, so nothing to clear", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a hang that never recovers (force-quit) keeps its breadcrumb for next-boot reporting", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// No "responsive" ever fires — the breadcrumb must survive uncleared.
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a recoverable crash writes a breadcrumb and never clears it (a dead process never recovers)", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "render-process-gone" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a clean (non-crash) renderer exit writes no breadcrumb", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,22 +17,6 @@ type ReloadPromptResult = "reload" | "dismiss";
|
||||
type RendererRecoveryOptions = {
|
||||
isDev: boolean;
|
||||
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
|
||||
getDiagnosticContext?: () => Record<string, unknown>;
|
||||
/**
|
||||
* Persist a freeze/crash breadcrumb to disk. The renderer can't report a
|
||||
* true hang or process death itself (blocked / gone), so the main process
|
||||
* writes it here and the next renderer boot flushes it to telemetry. Omit
|
||||
* in dev to keep field telemetry clean.
|
||||
*/
|
||||
persistBreadcrumb?: (payload: ReloadPromptPayload) => void;
|
||||
/**
|
||||
* Delete a previously-persisted unresponsive breadcrumb. Called when the
|
||||
* renderer recovers (`responsive` after `unresponsive`): the window came
|
||||
* back, so the in-thread watchdog reports the freeze and the breadcrumb
|
||||
* would only double-count it. Crash breadcrumbs are never cleared — a dead
|
||||
* process never recovers.
|
||||
*/
|
||||
clearBreadcrumb?: () => void;
|
||||
log?: (tag: string, ...args: unknown[]) => void;
|
||||
unresponsivePromptDelayMs?: number;
|
||||
};
|
||||
@@ -42,21 +26,11 @@ export function installRendererRecoveryHandlers(
|
||||
{
|
||||
isDev,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext,
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
log = defaultDevLog,
|
||||
unresponsivePromptDelayMs = 1500,
|
||||
}: RendererRecoveryOptions,
|
||||
) {
|
||||
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// True once a breadcrumb has been written for the current hang. A later
|
||||
// `responsive` clears it; only a hang that never returns survives to report.
|
||||
let unresponsiveBreadcrumbWritten = false;
|
||||
const mergeDiagnosticContext = (context: Record<string, unknown>) => ({
|
||||
...readDiagnosticContext(getDiagnosticContext),
|
||||
...context,
|
||||
});
|
||||
const maybePromptReload = (payload: ReloadPromptPayload) => {
|
||||
if (isDev) return;
|
||||
void showReloadPrompt(payload).then((result) => {
|
||||
@@ -69,23 +43,14 @@ export function installRendererRecoveryHandlers(
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (isDev) log("process-gone", JSON.stringify(details));
|
||||
if (!isRecoverableRendererExit(details)) return;
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "render-process-gone",
|
||||
context: mergeDiagnosticContext({ details }),
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
maybePromptReload(payload);
|
||||
maybePromptReload({ kind: "render-process-gone", context: { details } });
|
||||
});
|
||||
|
||||
// preload-error intentionally does NOT persist a breadcrumb: it's a startup
|
||||
// failure of the preload script itself, and the breadcrumb-flush path depends
|
||||
// on that same preload exposing `getLastFreeze` — if preload is broken, the
|
||||
// next boot couldn't read it back anyway. We only prompt for reload here.
|
||||
window.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
|
||||
maybePromptReload({
|
||||
kind: "preload-error",
|
||||
context: mergeDiagnosticContext({ preloadPath, error: formatError(error) }),
|
||||
context: { preloadPath, error: formatError(error) },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,27 +58,14 @@ export function installRendererRecoveryHandlers(
|
||||
if (isDev || unresponsivePromptTimer) return;
|
||||
unresponsivePromptTimer = setTimeout(() => {
|
||||
unresponsivePromptTimer = null;
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "unresponsive",
|
||||
context: mergeDiagnosticContext({}),
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
unresponsiveBreadcrumbWritten = true;
|
||||
maybePromptReload(payload);
|
||||
maybePromptReload({ kind: "unresponsive", context: {} });
|
||||
}, unresponsivePromptDelayMs);
|
||||
});
|
||||
|
||||
window.on("responsive", () => {
|
||||
if (unresponsivePromptTimer) {
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
}
|
||||
// The window came back: drop any breadcrumb written during this hang so it
|
||||
// isn't re-reported (and mislabeled `recovered: false`) on next boot.
|
||||
if (unresponsiveBreadcrumbWritten) {
|
||||
clearBreadcrumb?.();
|
||||
unresponsiveBreadcrumbWritten = false;
|
||||
}
|
||||
if (!unresponsivePromptTimer) return;
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,30 +109,18 @@ function isRecoverableRendererExit(details: unknown) {
|
||||
function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) {
|
||||
switch (kind) {
|
||||
case "render-process-gone":
|
||||
return "The desktop window stopped unexpectedly.";
|
||||
return "The desktop renderer process stopped responding or crashed.";
|
||||
case "preload-error":
|
||||
return "The desktop window could not finish starting.";
|
||||
return "The desktop preload script failed before the app could start.";
|
||||
case "unresponsive":
|
||||
return "The desktop window has been stuck for a few seconds.";
|
||||
return "The desktop window is not responding.";
|
||||
}
|
||||
}
|
||||
|
||||
function rendererRecoveryDetail(payload: ReloadPromptPayload) {
|
||||
const guidance = [
|
||||
"Click Reload to refresh this window and keep using Multica.",
|
||||
"If this keeps happening, please tell us what you were doing right before this message appeared and whether Reload recovered the window.",
|
||||
];
|
||||
|
||||
if (payload.kind === "unresponsive") {
|
||||
guidance.push(
|
||||
"For macOS reports, an Activity Monitor sample of the Multica Helper (Renderer) process helps us find what blocked the app.",
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
...guidance,
|
||||
"Reloading is the safest recovery path for this window.",
|
||||
"",
|
||||
"Diagnostic details:",
|
||||
`kind: ${payload.kind}`,
|
||||
`context: ${JSON.stringify(payload.context)}`,
|
||||
].join("\n");
|
||||
@@ -190,17 +130,6 @@ function defaultDevLog(tag: string, ...args: unknown[]) {
|
||||
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
|
||||
}
|
||||
|
||||
function readDiagnosticContext(
|
||||
getDiagnosticContext: (() => Record<string, unknown>) | undefined,
|
||||
) {
|
||||
if (!getDiagnosticContext) return {};
|
||||
try {
|
||||
return getDiagnosticContext();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown) {
|
||||
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BrowserWindow, WebContents } from "electron";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
const ctx = vi.hoisted(() => ({
|
||||
handlers: new Map<string, Handler[]>(),
|
||||
ipcHandle: vi.fn(),
|
||||
checkForUpdates: vi.fn(async () => ({
|
||||
updateInfo: { version: "0.3.18" },
|
||||
isUpdateAvailable: false,
|
||||
})),
|
||||
downloadUpdate: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
getVersion: vi.fn(() => "0.3.17"),
|
||||
}));
|
||||
|
||||
vi.mock("electron-updater", () => {
|
||||
const autoUpdater = {
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
channel: undefined as string | undefined,
|
||||
on: vi.fn((event: string, handler: Handler) => {
|
||||
const handlers = ctx.handlers.get(event) ?? [];
|
||||
handlers.push(handler);
|
||||
ctx.handlers.set(event, handlers);
|
||||
return autoUpdater;
|
||||
}),
|
||||
checkForUpdates: ctx.checkForUpdates,
|
||||
downloadUpdate: ctx.downloadUpdate,
|
||||
quitAndInstall: ctx.quitAndInstall,
|
||||
};
|
||||
return { autoUpdater };
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getVersion: ctx.getVersion,
|
||||
},
|
||||
BrowserWindow: class BrowserWindow {},
|
||||
ipcMain: {
|
||||
handle: ctx.ipcHandle,
|
||||
},
|
||||
}));
|
||||
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
|
||||
function emitUpdater(event: string, ...args: unknown[]) {
|
||||
for (const handler of ctx.handlers.get(event) ?? []) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
|
||||
function makeWindow() {
|
||||
const send = vi.fn();
|
||||
return {
|
||||
win: {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
send,
|
||||
},
|
||||
} as unknown as BrowserWindow,
|
||||
send,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDestroyedWindow() {
|
||||
return {
|
||||
isDestroyed: () => true,
|
||||
get webContents(): WebContents {
|
||||
throw new TypeError("Object has been destroyed");
|
||||
},
|
||||
} as unknown as BrowserWindow;
|
||||
}
|
||||
|
||||
function makeWindowWithDestroyedWebContents() {
|
||||
const send = vi.fn(() => {
|
||||
throw new TypeError("Object has been destroyed");
|
||||
});
|
||||
return {
|
||||
win: {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => true,
|
||||
send,
|
||||
},
|
||||
} as unknown as BrowserWindow,
|
||||
send,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWindowWithThrowingSend(error: Error) {
|
||||
const send = vi.fn(() => {
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
win: {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
send,
|
||||
},
|
||||
} as unknown as BrowserWindow,
|
||||
send,
|
||||
};
|
||||
}
|
||||
|
||||
describe("setupAutoUpdater", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
ctx.handlers.clear();
|
||||
ctx.ipcHandle.mockClear();
|
||||
ctx.checkForUpdates.mockClear();
|
||||
ctx.downloadUpdate.mockClear();
|
||||
ctx.quitAndInstall.mockClear();
|
||||
ctx.getVersion.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("forwards update progress to a live renderer", () => {
|
||||
const { win, send } = makeWindow();
|
||||
setupAutoUpdater(() => win);
|
||||
|
||||
emitUpdater("download-progress", { percent: 42 });
|
||||
|
||||
expect(send).toHaveBeenCalledWith("updater:download-progress", {
|
||||
percent: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips update progress when the BrowserWindow has already been destroyed", () => {
|
||||
setupAutoUpdater(() => makeDestroyedWindow());
|
||||
|
||||
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips update progress when the BrowserWindow webContents has already been destroyed", () => {
|
||||
const { win, send } = makeWindowWithDestroyedWebContents();
|
||||
setupAutoUpdater(() => win);
|
||||
|
||||
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips update progress when webContents.send loses a destroy race", () => {
|
||||
const { win, send } = makeWindowWithThrowingSend(
|
||||
new TypeError("Object has been destroyed"),
|
||||
);
|
||||
setupAutoUpdater(() => win);
|
||||
|
||||
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
|
||||
expect(send).toHaveBeenCalledWith("updater:download-progress", {
|
||||
percent: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("rethrows non-destroy errors from webContents.send", () => {
|
||||
const { win } = makeWindowWithThrowingSend(new Error("boom"));
|
||||
setupAutoUpdater(() => win);
|
||||
|
||||
expect(() => emitUpdater("download-progress", { percent: 42 })).toThrow(
|
||||
"boom",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { autoUpdater, type UpdateDownloadedEvent } from "electron-updater";
|
||||
import { app, type BrowserWindow, ipcMain } from "electron";
|
||||
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
// Silent background updates: electron-updater downloads on its own as soon
|
||||
// as `update-available` fires; we only surface UI when the package is fully
|
||||
@@ -29,32 +29,6 @@ export type ManualUpdateCheckResult =
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
type RendererChannel =
|
||||
| "updater:update-available"
|
||||
| "updater:download-progress"
|
||||
| "updater:update-downloaded";
|
||||
|
||||
function isDestroyedObjectError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message.includes("Object has been destroyed");
|
||||
}
|
||||
|
||||
function sendToLiveRenderer(
|
||||
win: BrowserWindow | null,
|
||||
channel: RendererChannel,
|
||||
payload: unknown,
|
||||
): void {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
|
||||
try {
|
||||
const { webContents } = win;
|
||||
if (webContents.isDestroyed()) return;
|
||||
webContents.send(channel, payload);
|
||||
} catch (err) {
|
||||
if (isDestroyedObjectError(err)) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-flight guard around checkForUpdates(). With autoDownload=true the
|
||||
// startup, periodic, and manual triggers can all kick off downloads, and
|
||||
// overlapping calls have caused duplicate download warnings in the past
|
||||
@@ -88,20 +62,23 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
// Forwarded for renderer-side state tracking only; the notification UI
|
||||
// does not render an "available" affordance with autoDownload=true.
|
||||
sendToLiveRenderer(getMainWindow(), "updater:update-available", {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
sendToLiveRenderer(getMainWindow(), "updater:download-progress", {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:download-progress", {
|
||||
percent: progress.percent,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
|
||||
sendToLiveRenderer(getMainWindow(), "updater:update-downloaded", {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
|
||||
12
apps/desktop/src/preload/index.d.ts
vendored
12
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,8 +1,6 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { NavigationGesture } from "../shared/navigation-gestures";
|
||||
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -16,9 +14,6 @@ interface DesktopAPI {
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** Read + clear any freeze/crash breadcrumb from a previous session, so the
|
||||
* renderer can flush it to telemetry on boot. Null when nothing's pending. */
|
||||
getLastFreeze: () => FreezeBreadcrumb | null;
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
@@ -50,8 +45,6 @@ interface DesktopAPI {
|
||||
) => () => void;
|
||||
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
|
||||
/** Report the renderer's memory-router path for recovery diagnostics. */
|
||||
setRendererRouteContext: (context: RendererRouteContextInput) => void;
|
||||
/** Open the OS folder picker and return the chosen absolute path.
|
||||
* Used by the Project settings "Add local directory" flow. */
|
||||
pickDirectory: (
|
||||
@@ -78,11 +71,6 @@ interface DesktopAPI {
|
||||
| "error";
|
||||
error?: string;
|
||||
}>;
|
||||
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
|
||||
* Returns an unsubscribe function. */
|
||||
onCloseActiveTab: (callback: () => void) => () => void;
|
||||
/** Ask the main process to close the window. */
|
||||
closeWindow: () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
type RendererRouteContextInput,
|
||||
} from "../shared/renderer-route-context";
|
||||
import {
|
||||
isNavigationGesture,
|
||||
NAVIGATION_GESTURE_CHANNEL,
|
||||
@@ -79,16 +74,6 @@ const desktopAPI = {
|
||||
},
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Read + clear any freeze/crash breadcrumb left by a previous session, so
|
||||
* the renderer can flush it to telemetry on boot. Returns null when there's
|
||||
* nothing pending (the normal case). */
|
||||
getLastFreeze: (): FreezeBreadcrumb | null => {
|
||||
try {
|
||||
return ipcRenderer.sendSync("freeze:get-last") as FreezeBreadcrumb | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
@@ -171,27 +156,12 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
/** Report the renderer's memory-router path for recovery diagnostics. */
|
||||
setRendererRouteContext: (context: RendererRouteContextInput) =>
|
||||
ipcRenderer.send(RENDERER_ROUTE_CONTEXT_CHANNEL, context),
|
||||
/** Open the OS folder picker and return the chosen absolute path. */
|
||||
pickDirectory: (defaultPath?: string) =>
|
||||
ipcRenderer.invoke("local-directory:pick", defaultPath),
|
||||
/** Validate that a path is an existing readable+writable directory. */
|
||||
validateLocalDirectory: (path: string) =>
|
||||
ipcRenderer.invoke("local-directory:validate", path),
|
||||
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
|
||||
* The renderer should close the active tab; if it was the last tab,
|
||||
* call `closeWindow()` to dismiss the window. Returns an unsubscribe fn. */
|
||||
onCloseActiveTab: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("tab:close-active", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("tab:close-active", handler);
|
||||
};
|
||||
},
|
||||
/** Ask the main process to close the window (used after closing the last tab). */
|
||||
closeWindow: () => ipcRenderer.send("window:close"),
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
|
||||
import { captureEvent } from "@multica/core/analytics";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
|
||||
// BCP-47 region tags for the <html lang> attribute, mirroring
|
||||
@@ -35,42 +34,10 @@ const HTML_LANG: Record<SupportedLocale, string> = {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl+W: close the active tab. When the last real tab is closed
|
||||
* (or no tabs/workspace exist — e.g. login page), close the window.
|
||||
*
|
||||
* Mounted at the App root so every renderer state — including login,
|
||||
* loading, onboarding, and runtime-config errors — has a working Cmd+W
|
||||
* handler. Without this, states outside the tab shell would swallow the
|
||||
* shortcut and do nothing.
|
||||
*/
|
||||
function useCmdWCloseTab() {
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onCloseActiveTab(() => {
|
||||
const store = useTabStore.getState();
|
||||
const { activeWorkspaceSlug, byWorkspace } = store;
|
||||
if (!activeWorkspaceSlug) {
|
||||
// No workspace — nothing to close, dismiss the window.
|
||||
window.desktopAPI.closeWindow();
|
||||
return;
|
||||
}
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group || group.tabs.length <= 1) {
|
||||
// Last tab (or no tabs) — close the window.
|
||||
window.desktopAPI.closeWindow();
|
||||
return;
|
||||
}
|
||||
// Multiple tabs — close the active one.
|
||||
store.closeActiveTab();
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
|
||||
// setQueryData sequentially. loginWithToken sets user+isLoading=false
|
||||
// as soon as getMe resolves, which would cause DesktopShell to mount
|
||||
@@ -331,28 +298,6 @@ export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const systemLocale = window.desktopAPI.systemLocale;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
useCmdWCloseTab();
|
||||
|
||||
// Flush a freeze/crash breadcrumb the main process parked from a previous
|
||||
// session. A true hang or process death can't report itself when it happens
|
||||
// (the renderer is blocked or gone), so the main process persists it and we
|
||||
// emit it here on the next boot. The in-thread, recoverable freeze tier is
|
||||
// handled separately by the shared watchdog in CoreProvider.
|
||||
useEffect(() => {
|
||||
const last = window.desktopAPI.getLastFreeze();
|
||||
if (!last) return;
|
||||
const crashed = last.kind === "render-process-gone";
|
||||
captureEvent(crashed ? "client_crash" : "client_unresponsive", {
|
||||
// Spread context FIRST so our explicit fields below always win — a
|
||||
// future context key (e.g. its own `source`) must not silently override.
|
||||
...last.context,
|
||||
source: crashed ? "render-process-gone" : "main-unresponsive",
|
||||
recovered: false,
|
||||
breadcrumb_ts: last.ts,
|
||||
crashed_version: last.version,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -195,12 +194,15 @@ export function DaemonPanel({
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = filtered.map((l) => l.raw).join("\n");
|
||||
if (await copyText(text)) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} else {
|
||||
toast.error("Failed to copy");
|
||||
} catch (err) {
|
||||
toast.error("Failed to copy", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, [filtered]);
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
// The component only needs these to render; stub them so the test focuses on
|
||||
// the externally-managed branching, not data fetching.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
vi.mock("@multica/core/runtimes", () => ({
|
||||
runtimeListOptions: () => ({ queryKey: ["runtimes"] }),
|
||||
}));
|
||||
vi.mock("@multica/core/agents", () => ({
|
||||
agentTaskSnapshotOptions: () => ({ queryKey: ["snapshot"] }),
|
||||
}));
|
||||
vi.mock("./daemon-panel", () => ({ DaemonPanel: () => null }));
|
||||
vi.mock("../platform/daemon-reauth", () => ({
|
||||
reauthenticateDaemon: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import { DaemonRuntimeActions } from "./daemon-runtime-card";
|
||||
|
||||
function stubDaemonAPI(status: DaemonStatus) {
|
||||
Object.defineProperty(window, "daemonAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getStatus: vi.fn().mockResolvedValue(status),
|
||||
onStatusChange: vi.fn(() => () => {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("DaemonRuntimeActions — externally managed daemon (#3916)", () => {
|
||||
it("hides Stop/Restart and shows the managed-outside hint for a daemon the app can't control", async () => {
|
||||
stubDaemonAPI({ state: "running", daemonId: "d1", externallyManaged: true });
|
||||
render(<DaemonRuntimeActions />);
|
||||
|
||||
// View logs still renders, confirming the running branch mounted.
|
||||
expect(await screen.findByText("View logs")).toBeInTheDocument();
|
||||
expect(screen.getByText("Managed outside the app")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Restart")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Stop")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Stop/Restart for a normally-managed running daemon (no 误伤)", async () => {
|
||||
stubDaemonAPI({
|
||||
state: "running",
|
||||
daemonId: "d1",
|
||||
externallyManaged: false,
|
||||
});
|
||||
render(<DaemonRuntimeActions />);
|
||||
|
||||
expect(await screen.findByText("Restart")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Managed outside the app"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Activity,
|
||||
ScrollText,
|
||||
LogIn,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -127,12 +126,6 @@ export function DaemonRuntimeActions() {
|
||||
}, []);
|
||||
|
||||
const isRunning = status.state === "running";
|
||||
// The daemon runs somewhere the app can't drive (e.g. inside WSL2): the
|
||||
// lifecycle CLI acts on the host process namespace and can't reach it. Hide
|
||||
// Stop/Restart so they don't silently no-op, mirroring the Settings tab. The
|
||||
// real guard is in the main process (stopDaemon/restartDaemon); this is the
|
||||
// matching UX. See #3916.
|
||||
const externallyManaged = status.externallyManaged === true;
|
||||
const isStopped = status.state === "stopped";
|
||||
const isCliMissing = status.state === "cli_not_found";
|
||||
const isAuthExpired = status.state === "auth_expired";
|
||||
@@ -149,33 +142,24 @@ export function DaemonRuntimeActions() {
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
{externallyManaged ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Info className="size-3.5 shrink-0" />
|
||||
Managed outside the app
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { AlertCircle, Info, LogIn } from "lucide-react";
|
||||
import { AlertCircle, LogIn } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
@@ -88,12 +88,6 @@ export function DaemonSettingsTab() {
|
||||
[],
|
||||
);
|
||||
|
||||
// The daemon runs somewhere the app can't drive (e.g. inside WSL2 behind a
|
||||
// Windows desktop): /health is reachable but the lifecycle CLI can't reach
|
||||
// its process. Auto-start/auto-stop can't work, so disable them and say why
|
||||
// rather than letting the toggles silently no-op. See #3916.
|
||||
const externallyManaged = status.externallyManaged === true;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Daemon</h2>
|
||||
@@ -125,19 +119,6 @@ export function DaemonSettingsTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{externallyManaged && (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-lg border bg-muted/30 px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="min-w-0 text-sm text-muted-foreground">
|
||||
This device's daemon runs outside the app — for example inside
|
||||
WSL2 — so the app can't start or stop it. Start or stop it from
|
||||
that environment with{" "}
|
||||
<code className="font-mono text-xs">multica daemon start</code> /{" "}
|
||||
<code className="font-mono text-xs">multica daemon stop</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<SettingRow
|
||||
label="Auto-start on launch"
|
||||
@@ -146,7 +127,7 @@ export function DaemonSettingsTab() {
|
||||
<Switch
|
||||
checked={prefs.autoStart}
|
||||
onCheckedChange={(checked) => updatePref("autoStart", checked)}
|
||||
disabled={saving || externallyManaged}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -157,7 +138,7 @@ export function DaemonSettingsTab() {
|
||||
<Switch
|
||||
checked={prefs.autoStop}
|
||||
onCheckedChange={(checked) => updatePref("autoStop", checked)}
|
||||
disabled={saving || externallyManaged}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useEffect, 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";
|
||||
@@ -15,7 +14,6 @@ import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { useDesktopUnreadBadge } from "@multica/views/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
@@ -23,69 +21,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 +71,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,30 +127,18 @@ function useInternalLinkHandler() {
|
||||
* inbox even if the user has since switched to workspace B. Marking
|
||||
* the row read is handled by InboxPage's selected-item effect, which
|
||||
* covers both click-to-select and URL-param-select paths.
|
||||
*
|
||||
* The click routes through `useNavigation().push` — NOT the
|
||||
* `multica:navigate` event, whose handler `openTab`s into the ACTIVE
|
||||
* workspace's tab group. The navigation adapter detects a cross-workspace
|
||||
* path and translates it into `switchWorkspace(slug, path)`, so clicking a
|
||||
* workspace-A notification while B is active performs a real workspace
|
||||
* switch instead of mounting A's inbox inside B's tab group (#3766).
|
||||
*/
|
||||
function DesktopInboxBridge() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
useDesktopUnreadBadge(workspace?.id ?? null);
|
||||
const { push } = useNavigation();
|
||||
// The adapter identity changes with the active tab's location; the ref
|
||||
// keeps the main-process subscription stable across navigations.
|
||||
const pushRef = useRef(push);
|
||||
useEffect(() => {
|
||||
pushRef.current = push;
|
||||
}, [push]);
|
||||
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
|
||||
if (!slug) return;
|
||||
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
|
||||
pushRef.current(inboxPath);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -213,10 +170,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 +180,7 @@ export function DesktopShell() {
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useTabStore,
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
import type { RendererRouteContextInput } from "../../../shared/renderer-route-context";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes,
|
||||
@@ -91,16 +90,6 @@ export function PageviewTracker() {
|
||||
const last = lastSurfaceRef.current;
|
||||
const next = { kind, key, path };
|
||||
|
||||
const routeContext: RendererRouteContextInput = {
|
||||
surface: kind,
|
||||
path,
|
||||
};
|
||||
if (kind === "tab") {
|
||||
routeContext.workspaceSlug = activeWorkspaceSlug ?? undefined;
|
||||
routeContext.tabId = activeTabId ?? undefined;
|
||||
}
|
||||
reportRendererRouteContext(routeContext);
|
||||
|
||||
if (kind === "tab" && key !== null) {
|
||||
const knownPath = observed.get(key);
|
||||
const isReactivation =
|
||||
@@ -123,13 +112,6 @@ export function PageviewTracker() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function reportRendererRouteContext(context: RendererRouteContextInput) {
|
||||
const desktopAPI = window.desktopAPI as
|
||||
| { setRendererRouteContext?: (context: RendererRouteContextInput) => void }
|
||||
| undefined;
|
||||
desktopAPI?.setRendererRouteContext?.(context);
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { daemonStatusAlive } from "./daemon-types";
|
||||
|
||||
describe("daemonStatusAlive", () => {
|
||||
it("treats a ready daemon as alive", () => {
|
||||
expect(daemonStatusAlive("running")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a still-booting daemon as alive", () => {
|
||||
// /health binds before preflight and reports "starting" until ready; the
|
||||
// Desktop must not spawn a second daemon over it (the CLI rejects that as
|
||||
// "already running").
|
||||
expect(daemonStatusAlive("starting")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats stopped / unknown / missing as not alive", () => {
|
||||
expect(daemonStatusAlive("stopped")).toBe(false);
|
||||
expect(daemonStatusAlive("bogus")).toBe(false);
|
||||
expect(daemonStatusAlive("")).toBe(false);
|
||||
expect(daemonStatusAlive(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -22,16 +22,6 @@ export interface DaemonStatus {
|
||||
profile?: string;
|
||||
/** Backend URL the daemon connects to. */
|
||||
serverUrl?: string;
|
||||
/**
|
||||
* True when a daemon is running but in an environment the app can't control
|
||||
* — its reported OS differs from the desktop host's (e.g. a Linux daemon
|
||||
* inside WSL2 behind a Windows desktop, reachable only via localhost
|
||||
* forwarding). The app's start/stop CLI acts on the host process namespace,
|
||||
* so auto-start/auto-stop can't reach it; the UI disables those toggles
|
||||
* instead of silently no-op'ing. Only ever set on a running daemon, so it
|
||||
* never disables the toggles for a normally-managed native daemon. See #3916.
|
||||
*/
|
||||
externallyManaged?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonPrefs {
|
||||
@@ -68,19 +58,6 @@ export function formatUptime(uptime?: string): string {
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a raw daemon `/health` `status` value means a live daemon is on the
|
||||
* port — either fully "running" (ready) or still "starting" (port bound,
|
||||
* preflight in progress). Mirrors the Go `daemonAlive()` in
|
||||
* server/cmd/multica/cmd_daemon.go so the Desktop lifecycle agrees with the
|
||||
* CLI: a "starting" daemon is already there and must not be spawned over (the
|
||||
* CLI rejects that as "already running"). This is liveness, not readiness —
|
||||
* version-restart decisions still gate on the stricter "running".
|
||||
*/
|
||||
export function daemonStatusAlive(status: string | undefined): boolean {
|
||||
return status === "running" || status === "starting";
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing description for the local daemon's current state. Replaces the
|
||||
* raw state label ("Running" / "Stopped") with a sentence that answers
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* A freeze/crash breadcrumb persisted by the main process and flushed to
|
||||
* telemetry by the next renderer boot. Shared across main, preload, and
|
||||
* renderer because all three touch it. See main/freeze-breadcrumb.ts for the
|
||||
* read/write logic and the rationale.
|
||||
*/
|
||||
export interface FreezeBreadcrumb {
|
||||
/** "unresponsive" (hang) or "render-process-gone" (crash). */
|
||||
kind: string;
|
||||
/** Diagnostic context captured at failure time (route, window url, …). */
|
||||
context: Record<string, unknown>;
|
||||
/** Epoch ms when the failure was recorded. */
|
||||
ts: number;
|
||||
/** App version at failure time. */
|
||||
version: string;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
export const RENDERER_ROUTE_CONTEXT_CHANNEL = "renderer:route-context";
|
||||
|
||||
export type RendererRouteSurface = "login" | "overlay" | "tab";
|
||||
|
||||
export type RendererRouteContextInput = {
|
||||
surface: RendererRouteSurface;
|
||||
path: string;
|
||||
workspaceSlug?: string;
|
||||
tabId?: string;
|
||||
};
|
||||
|
||||
export type RendererRouteContext = RendererRouteContextInput & {
|
||||
reportedAt: string;
|
||||
};
|
||||
|
||||
const MAX_ROUTE_CONTEXT_STRING_LENGTH = 512;
|
||||
|
||||
export function sanitizeRendererRouteContext(
|
||||
value: unknown,
|
||||
reportedAt = new Date(),
|
||||
): RendererRouteContext | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
if (!isRendererRouteSurface(input.surface)) return null;
|
||||
|
||||
const path = sanitizeString(input.path);
|
||||
if (!path) return null;
|
||||
|
||||
const workspaceSlug = sanitizeString(input.workspaceSlug);
|
||||
const tabId = sanitizeString(input.tabId);
|
||||
|
||||
return {
|
||||
surface: input.surface,
|
||||
path,
|
||||
...(workspaceSlug ? { workspaceSlug } : {}),
|
||||
...(tabId ? { tabId } : {}),
|
||||
reportedAt: reportedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isRendererRouteSurface(value: unknown): value is RendererRouteSurface {
|
||||
return value === "login" || value === "overlay" || value === "tab";
|
||||
}
|
||||
|
||||
function sanitizeString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.slice(0, MAX_ROUTE_CONTEXT_STRING_LENGTH);
|
||||
}
|
||||
@@ -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,26 +74,11 @@ 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、またはローカルマシンからスキルをインポート |
|
||||
| `multica skill files ...` | ネスト: スキルのファイルを管理 |
|
||||
|
||||
### スキルインポートの競合
|
||||
|
||||
`multica skill import --url <url>` の既定値は `--on-conflict fail` です。同じ名前のスキルがすでに存在する場合、コマンドは構造化された `conflict` 結果で終了し、ワークスペースは変更されません。
|
||||
|
||||
既存スキルの作成者で、スキル ID とエージェントの紐付けを維持したまま内容を置き換える場合は `--on-conflict overwrite` を使います。既存スキルを残してコピーを取り込む場合は `--on-conflict rename` を使うと、`-2` のような接尾辞が自動で付きます。同名の項目を単に飛ばす場合は `--on-conflict skip` を使います。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## スクワッド
|
||||
|
||||
| コマンド | 用途 |
|
||||
@@ -128,10 +104,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 +117,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> ...` | ランタイムの構成を更新 |
|
||||
|
||||
## その他
|
||||
|
||||
|
||||
@@ -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,26 +74,11 @@ 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, 또는 로컬 기기에서 스킬 가져오기 |
|
||||
| `multica skill files ...` | 중첩: 스킬의 파일 관리 |
|
||||
|
||||
### 스킬 가져오기 충돌
|
||||
|
||||
`multica skill import --url <url>`의 기본값은 `--on-conflict fail`입니다. 같은 이름의 스킬이 이미 있으면 명령은 구조화된 `conflict` 결과로 종료되며 워크스페이스를 변경하지 않습니다.
|
||||
|
||||
기존 스킬을 만든 사용자이고, 스킬 ID와 에이전트 연결은 유지한 채 내용을 바꾸려면 `--on-conflict overwrite`를 사용하세요. 기존 스킬을 그대로 두고 복사본을 가져오려면 `--on-conflict rename`을 사용하면 `-2` 같은 접미사가 자동으로 붙습니다. 같은 이름의 항목을 그냥 건너뛰려면 `--on-conflict skip`을 사용하세요.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 스쿼드
|
||||
|
||||
| 명령어 | 용도 |
|
||||
@@ -128,10 +104,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 +117,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> ...` | 런타임의 구성 업데이트 |
|
||||
|
||||
## 기타
|
||||
|
||||
|
||||
@@ -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,32 +74,11 @@ 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 |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
### Skill import conflicts
|
||||
|
||||
`multica skill import --url <url>` defaults to `--on-conflict fail`. If a skill
|
||||
with the same name already exists, the command exits with a structured
|
||||
`conflict` result and does not change the workspace.
|
||||
|
||||
Use `--on-conflict overwrite` when you created the existing skill and want to
|
||||
replace its content while preserving its ID and agent bindings. Use
|
||||
`--on-conflict rename` to import a copy with an automatic suffix such as `-2`.
|
||||
Use `--on-conflict skip` to leave the existing skill untouched and report
|
||||
`skipped`.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## Squads
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -134,12 +104,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 +117,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
|
||||
|
||||
|
||||
@@ -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,26 +74,11 @@ 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 |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
### Skill 导入冲突
|
||||
|
||||
`multica skill import --url <url>` 默认等同于 `--on-conflict fail`。如果工作区里已经有同名 Skill,命令会返回结构化 `conflict` 结果并退出,不会修改工作区。
|
||||
|
||||
如果你是已有 Skill 的 creator,并且想用新导入内容覆盖它,同时保留原 Skill 的 ID 和 agent 绑定,用 `--on-conflict overwrite`。如果想保留已有 Skill、另存一份,用 `--on-conflict rename`,系统会自动加 `-2` 这类后缀。如果只是批量导入时遇到同名项就跳过,用 `--on-conflict skip`。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 小队
|
||||
|
||||
| 命令 | 用途 |
|
||||
@@ -128,10 +104,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 +117,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 配置 |
|
||||
|
||||
## 杂项
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ Daemon behavior is configured via flags or environment variables:
|
||||
|---------|------|--------------|---------|
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `0`(不限制,由看门狗兜底)|
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## イシューを参照する
|
||||
|
||||
別のイシューをリンクするには、コメントの mention ピッカーからそのイシューを選択してください。Multica はイシューリンクを明示的な `[MUL-123](mention://issue/<uuid>)` mention リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
|
||||
別のイシューをリンクするには、`MUL-123` のようにそのイシューキーを入力してください。Multica はコメント内で実在するイシューキーを解決し、内部的に `mention://issue/<uuid>` リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
|
||||
|
||||
`MUL-123` のような裸のイシューキーを入力しても、通常のテキストのまま残ります。そのため、`feature/MUL-123` のようなコメント内のブランチ名やパスも書き換えられません。
|
||||
通常は `[MUL-123](mention://issue/<uuid>)` を手で書く必要はありません。その形式は、Multica がキーを解決した後に使う標準的な内部表現です。
|
||||
|
||||
<Callout type="info">
|
||||
Markdown の強調は CommonMark のルールに従います。太字テキストが句読点や閉じ引用符で終わり、その直後に韓国語の助詞が続く場合、閉じの `**` が認識されないことがあります。
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 이슈 참조하기
|
||||
|
||||
다른 이슈를 링크하려면 댓글 mention 선택기에서 해당 이슈를 선택하세요. Multica는 이슈 링크를 명시적인 `[MUL-123](mention://issue/<uuid>)` mention 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
|
||||
다른 이슈를 링크하려면 `MUL-123`처럼 이슈 키를 입력하세요. Multica는 댓글에서 실제 존재하는 이슈 키를 해석하여 내부적으로 `mention://issue/<uuid>` 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
|
||||
|
||||
`MUL-123` 같은 bare 이슈 키를 입력하면 일반 텍스트로 유지됩니다. 따라서 `feature/MUL-123` 같은 댓글 안의 브랜치 이름과 경로도 다시 작성되지 않습니다.
|
||||
보통은 `[MUL-123](mention://issue/<uuid>)`을 직접 손으로 작성할 필요가 없습니다. 그 형식은 Multica가 키를 해석한 뒤에 사용하는 표준 내부 표현입니다.
|
||||
|
||||
<Callout type="info">
|
||||
Markdown 강조는 CommonMark 규칙을 따릅니다. 굵은 텍스트가 문장 부호나 닫는 따옴표로 끝나고 그 뒤에 한국어 조사가 바로 이어지면, 닫는 `**`가 인식되지 않을 수 있습니다.
|
||||
|
||||
@@ -39,9 +39,9 @@ Mentioning the same person multiple times in one comment still produces **only o
|
||||
|
||||
## Referencing issues
|
||||
|
||||
To link another issue, choose it from the comment mention picker. Multica stores issue links as an explicit `[MUL-123](mention://issue/<uuid>)` mention link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
|
||||
To link another issue, type its issue key, such as `MUL-123`. Multica resolves real issue keys in comments and stores them as an internal `mention://issue/<uuid>` link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
|
||||
|
||||
Typing a bare issue key, such as `MUL-123`, keeps it as plain text. This also keeps branch names and paths, such as `feature/MUL-123`, from being rewritten inside comments.
|
||||
You normally do not need to write `[MUL-123](mention://issue/<uuid>)` by hand. That format is the canonical internal representation after Multica has resolved the key.
|
||||
|
||||
<Callout type="info">
|
||||
Markdown emphasis follows CommonMark rules. When bold text ends with punctuation or a closing quote and is immediately followed by a Korean particle, the closing `**` may not be recognized.
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 引用 issue
|
||||
|
||||
要链接另一个 issue,请在评论的 mention 选择器里选择它。Multica 会把 issue 链接存成显式的 `[MUL-123](mention://issue/<uuid>)` mention 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
|
||||
要链接另一个 issue,直接输入它的 issue key,例如 `MUL-123`。Multica 会在评论中解析真实存在的 issue key,并把它存成内部的 `mention://issue/<uuid>` 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
|
||||
|
||||
直接输入裸 issue key,例如 `MUL-123`,会保持为普通文本。这样评论里的分支名和路径,例如 `feature/MUL-123`,也不会被改写。
|
||||
通常不需要手写 `[MUL-123](mention://issue/<uuid>)`。这是 Multica 解析 key 之后使用的内部规范格式。
|
||||
|
||||
<Callout type="info">
|
||||
Markdown 加粗遵循 CommonMark 规则。当加粗文本以标点或闭引号结尾,并且后面紧跟韩语助词时,结尾的 `**` 可能不会被识别。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -198,24 +198,18 @@ S3 の前段に CloudFront を置く場合、3 つの変数が適用されます
|
||||
|
||||
## GitHub 連携
|
||||
|
||||
[GitHub PR ↔ イシュー連携](/github-integration)には 2 つの必須変数が必要です。設定で Connect GitHub を有効にし、受信 webhook を受け付けるには両方を設定してください。さらに 2 つの任意変数を設定すると、インストール時点で連携先のアカウント名を取得できます。
|
||||
[GitHub PR ↔ イシュー連携](/github-integration)には 2 つの変数が必要です。設定で Connect GitHub を有効にし、受信 webhook を受け付けるには両方を設定してください。
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 空 | GitHub App の slug(`https://github.com/apps/<slug>` の末尾部分)。設定 → GitHub のインストールボタン URL を構成します |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 空 | GitHub App に設定した Webhook secret。すべての `pull_request` / `installation` delivery の HMAC-SHA256 検証に使われ、setup コールバックの state token の HMAC キーとしても使われます |
|
||||
| `GITHUB_APP_ID` | 空 | 任意。App 設定ページに表示される数値の App ID。`GITHUB_APP_PRIVATE_KEY` と併せて設定すると、setup コールバックがインストール時点で GitHub から連携先のアカウント名を取得できます |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | 空 | 任意。App の RSA 秘密鍵の完全な PEM ブロック(`-----BEGIN/END-----` 行を含み、改行を保ったまま)。GitHub の App 認証 REST 呼び出しに必要な短命 JWT の発行に使われます |
|
||||
|
||||
**いずれかの必須変数が設定されていない場合の動作:**
|
||||
**どちらかが設定されていない場合の動作:**
|
||||
|
||||
- 設定 → GitHub の `Connect GitHub` が**無効**になり、admin に「not configured」というヒントを表示します。
|
||||
- `/api/webhooks/github` エンドポイントは **`503 github webhooks not configured`** を返します — Multica はすべての署名を有効として扱うのではなく、secret なしではイベント処理を拒否します。
|
||||
|
||||
**任意の `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` が設定されていない場合の動作:**
|
||||
|
||||
- インストール直後、接続カードには一時的に `Connected to unknown` と表示されます。GitHub から `installation.created` webhook が届くと(通常は数秒以内)、Multica は行を実際の組織名/ユーザー名に更新し、リアルタイムブロードキャストを発行するため、開いている Settings → GitHub タブは手動更新なしで反映されます。
|
||||
|
||||
**注:** `GITHUB_WEBHOOK_SECRET` はインストールフローの state token の署名キーとして再利用されるため、運用者は secret を 1 つだけ管理すればよいです。これは GitHub App の *Client* secret では**ありません** — Client secret は OAuth 関連であり、この連携では使われません。完全な手順は [GitHub 連携 → セルフホストのセットアップ](/github-integration#self-host-setup)を参照してください。
|
||||
|
||||
## 使用量分析
|
||||
|
||||
@@ -198,24 +198,18 @@ S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLO
|
||||
|
||||
## GitHub 연동
|
||||
|
||||
[GitHub PR ↔ 이슈 연동](/github-integration)에는 두 개의 필수 변수가 필요합니다. 설정에서 Connect GitHub를 활성화하고 들어오는 webhook을 수락하려면 둘 다 설정하세요. 추가로 두 개의 선택 변수를 설정하면 설치 시점에 연결된 계정 이름을 즉시 가져올 수 있습니다.
|
||||
[GitHub PR ↔ 이슈 연동](/github-integration)에는 두 개의 변수가 필요합니다. 설정에서 Connect GitHub를 활성화하고 들어오는 webhook을 수락하려면 둘 다 설정하세요.
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 비어 있음 | GitHub App의 slug (`https://github.com/apps/<slug>`의 끝부분). 설정 → GitHub 설치 버튼 URL을 구성합니다 |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 비어 있음 | GitHub App에 설정한 Webhook secret. 모든 `pull_request` / `installation` delivery의 HMAC-SHA256 검증에 사용되며, setup 콜백 state token의 HMAC 키로도 사용됩니다 |
|
||||
| `GITHUB_APP_ID` | 비어 있음 | 선택. App 설정 페이지에 표시되는 숫자 App ID. `GITHUB_APP_PRIVATE_KEY`와 함께 설정하면 setup 콜백이 설치 시점에 GitHub에서 연결된 계정 이름을 가져올 수 있습니다 |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | 비어 있음 | 선택. App RSA 비공개 키의 전체 PEM 블록 (`-----BEGIN/END-----` 줄 포함, 줄바꿈 유지). GitHub의 App 인증 REST 호출에 필요한 단명 JWT를 발급하는 데 사용됩니다 |
|
||||
|
||||
**필수 변수 중 하나라도 설정하지 않았을 때의 동작:**
|
||||
**둘 중 하나라도 설정하지 않았을 때의 동작:**
|
||||
|
||||
- 설정 → GitHub의 `Connect GitHub`가 **비활성화**되고 admin에게 "not configured" 힌트를 표시합니다.
|
||||
- `/api/webhooks/github` 엔드포인트는 **`503 github webhooks not configured`**를 반환합니다 — Multica는 모든 서명을 유효한 것으로 취급하기보다, secret 없이는 이벤트 처리를 거부합니다.
|
||||
|
||||
**선택 `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY`가 설정되지 않았을 때의 동작:**
|
||||
|
||||
- 설치 직후 연결 카드에 잠시 `Connected to unknown`이 표시됩니다. GitHub의 `installation.created` 웹훅이 도착하면(보통 몇 초 이내) Multica가 행을 실제 조직/사용자 이름으로 갱신하고 실시간 브로드캐스트를 보내, 열려 있는 Settings → GitHub 탭이 수동 새로고침 없이 업데이트됩니다.
|
||||
|
||||
**참고:** `GITHUB_WEBHOOK_SECRET`은 설치 흐름 state token의 서명 키로 재사용되므로, 운영자는 secret 하나만 관리하면 됩니다. 이것은 GitHub App의 *Client* secret이 **아닙니다** — Client secret은 OAuth 관련이며 이 연동에서는 사용되지 않습니다. 전체 안내는 [GitHub 연동 → 자체 호스팅 설정](/github-integration#self-host-setup)을 참고하세요.
|
||||
|
||||
## 사용량 분석
|
||||
|
||||
@@ -200,24 +200,18 @@ For a full explanation of how each parameter affects daemon behavior, see [Daemo
|
||||
|
||||
## GitHub integration
|
||||
|
||||
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks. Two additional variables are optional but populate the connected account name on install.
|
||||
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → GitHub install button URL |
|
||||
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
|
||||
| `GITHUB_APP_ID` | empty | Optional. Numeric App ID from the App's settings page. Combined with `GITHUB_APP_PRIVATE_KEY`, lets the setup callback fetch the connected account name from GitHub immediately on install |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | empty | Optional. Full PEM block of the App's RSA private key (including `-----BEGIN/END-----` lines, newlines preserved). Used to mint the short-lived JWT GitHub requires for App-authenticated REST calls |
|
||||
|
||||
**Behavior when either of the required variables is unset:**
|
||||
**Behavior when either is unset:**
|
||||
|
||||
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
|
||||
|
||||
**Behavior when the optional `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` are unset:**
|
||||
|
||||
- The connection card briefly shows `Connected to unknown` after install. Multica refreshes the row to the real org/user name as soon as GitHub delivers the `installation.created` webhook (typically within a few seconds), and broadcasts a realtime update so any open Settings → GitHub tab reflects the change without a manual refresh.
|
||||
|
||||
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
|
||||
|
||||
## Usage analytics
|
||||
|
||||
@@ -179,9 +179,6 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | 心跳频率 |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | 任务轮询频率 |
|
||||
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 并发任务上限 |
|
||||
| `MULTICA_AGENT_TIMEOUT` | `0` | 单次任务的绝对墙钟上限;`0` = 不设上限,任务只受看门狗约束(活跃任务不会因为跑得久被杀)。想要硬性成本/资源天花板时再设一个正值 |
|
||||
| `MULTICA_AGENT_IDLE_WATCHDOG` | `30m` | 空闲看门狗:backend 持续静默(无消息、消息队列为空、且没有工具在途)这么久就 force-stop。`0` = 关闭整套看门狗 |
|
||||
| `MULTICA_AGENT_TOOL_WATCHDOG` | `2h` | 工具在途时的静默上限:某个工具调用发出后长时间无任何输出(疑似卡死的子进程)这么久就 force-stop。`0` = 关闭该兜底(在途工具永不被停)|
|
||||
| `MULTICA_<PROVIDER>_PATH` | 对应 CLI 名 | 各 AI 编程工具的可执行文件路径(如 `MULTICA_CLAUDE_PATH`)|
|
||||
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI 编程工具的默认模型 |
|
||||
|
||||
@@ -203,24 +200,18 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap
|
||||
|
||||
## GitHub 集成
|
||||
|
||||
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个必填环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。另外两个可选变量用于在安装时直接拿到关联账号名。
|
||||
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
|
||||
| `GITHUB_APP_ID` | 空 | 可选。App 设置页上的数字 App ID。配合 `GITHUB_APP_PRIVATE_KEY` 使用,让 setup 回调在安装那一刻直接从 GitHub 取到关联账号名 |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | 空 | 可选。App RSA 私钥的完整 PEM 块(包含 `-----BEGIN/END-----` 两行,保留换行)。用于签发 GitHub App 鉴权 REST 调用所需的短效 JWT |
|
||||
|
||||
**任一必填变量未设时:**
|
||||
**任一变量未设时:**
|
||||
|
||||
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
|
||||
|
||||
**可选 `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` 未设时:**
|
||||
|
||||
- 安装完成后,连接卡片会先短暂显示 `已连接到 unknown`。等 GitHub 的 `installation.created` webhook 到达(通常几秒内),Multica 会把 row 刷成真实的组织/用户名,并通过 realtime 推送让正在打开的 Settings → GitHub 页面无需手动刷新即可更新。
|
||||
|
||||
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
|
||||
|
||||
## 用量统计
|
||||
|
||||
@@ -51,7 +51,7 @@ cd multica
|
||||
make selfhost
|
||||
```
|
||||
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET` and Postgres password, and starts all services via Docker Compose.
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
|
||||
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
|
||||
@@ -63,7 +63,7 @@ Once ready:
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
<Callout>
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, `POSTGRES_PASSWORD`, and the password segment in `DATABASE_URL`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
</Callout>
|
||||
|
||||
### Step 2 — Log In
|
||||
@@ -226,8 +226,7 @@ All configuration is done via environment variables. Copy `.env.example` as a st
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string. Keep the password segment in sync with `POSTGRES_PASSWORD`. | `postgres://multica:<postgres-password>@localhost:5432/multica?sslmode=disable` |
|
||||
| `POSTGRES_PASSWORD` | **Must change from default.** Password used by the bundled Postgres container. Keep it in sync with `DATABASE_URL`. | `openssl rand -hex 24` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
|
||||
@@ -114,20 +114,13 @@ API サーバーで:
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
|
||||
|
||||
# 任意(推奨)— インストール時点で接続済みアカウント名を取得できるため、
|
||||
# 最初の webhook が届くまで待たなくて済みます:
|
||||
GITHUB_APP_ID=<App 設定ページに表示される数値の App ID>
|
||||
GITHUB_APP_PRIVATE_KEY=<BEGIN/END 行を含む完全な PEM ブロック>
|
||||
```
|
||||
|
||||
`GITHUB_APP_SLUG` と `GITHUB_WEBHOOK_SECRET` は必須です。どちらかが欠けていると:
|
||||
両方の変数が必須です。どちらかが欠けていると:
|
||||
|
||||
- Settings の `Connect GitHub` が**無効**になり、「not configured」のヒントが表示されます。
|
||||
- `/api/webhooks/github` エンドポイントが **`503 github webhooks not configured`** を返します — Multica は secret なしでイベントを処理することを拒否し、すべての署名を黙って有効として扱うことはありません。
|
||||
|
||||
`GITHUB_APP_ID` と `GITHUB_APP_PRIVATE_KEY` は**任意**です。これらを設定すると、setup コールバックが GitHub の App 認証された `/app/installations/{id}` エンドポイントを呼び出して、インストール時点で実際の組織名やユーザー名を取得できます。設定しない場合、接続カードには一時的に `Connected to unknown` と表示され、GitHub から `installation.created` webhook が届くと(通常は数秒以内に)Multica が行を更新し、リアルタイムブロードキャストを発行するため、開いている Settings タブは手動更新なしで反映されます。秘密鍵は App 設定ページの **Private keys → Generate a private key** で生成し、PEM ブロック全体(`-----BEGIN/END RSA PRIVATE KEY-----` の行を含む)を改行を保ったまま env 変数に貼り付けてください。
|
||||
|
||||
`FRONTEND_ORIGIN` も設定されている必要があります(どのプロダクションのセルフホストでもすでに設定されています)。インストール後、setup コールバックがユーザーを `<FRONTEND_ORIGIN>/settings?tab=github` に戻します。
|
||||
|
||||
env 変数を設定した後は API を再起動してください。
|
||||
|
||||
@@ -114,20 +114,13 @@ API 서버에서:
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
|
||||
|
||||
# 선택(권장) — 설치 직후 연결된 계정 이름을 바로 확보합니다.
|
||||
# 설정하지 않으면 첫 webhook이 도착할 때까지 대기해야 합니다:
|
||||
GITHUB_APP_ID=<App 설정 페이지에 표시되는 숫자 App ID>
|
||||
GITHUB_APP_PRIVATE_KEY=<BEGIN/END 줄을 포함한 전체 PEM 블록>
|
||||
```
|
||||
|
||||
`GITHUB_APP_SLUG`와 `GITHUB_WEBHOOK_SECRET`은 필수입니다. 둘 중 하나라도 누락되면:
|
||||
두 변수 모두 필수입니다. 둘 중 하나라도 누락되면:
|
||||
|
||||
- Settings의 `Connect GitHub`이 **비활성화**되고 "not configured" 힌트가 표시됩니다.
|
||||
- `/api/webhooks/github` 엔드포인트가 **`503 github webhooks not configured`**를 반환합니다 — Multica는 secret 없이 이벤트를 처리하기를 거부하며, 모든 서명을 조용히 유효한 것으로 취급하지 않습니다.
|
||||
|
||||
`GITHUB_APP_ID`와 `GITHUB_APP_PRIVATE_KEY`는 **선택 사항**입니다. 설정하면 setup 콜백이 GitHub의 App 인증 `/app/installations/{id}` 엔드포인트를 호출해 설치 직후에 실제 조직명/사용자명을 가져옵니다. 설정하지 않으면 연결 카드에 잠시 `Connected to unknown`이 표시되며, GitHub의 `installation.created` 웹훅이 도착하면(보통 몇 초 이내) Multica가 행을 갱신하고 실시간 브로드캐스트를 보내므로 열려 있는 Settings 탭이 수동 새로고침 없이 업데이트됩니다. 비공개 키는 App 설정 페이지의 **Private keys → Generate a private key**에서 생성한 뒤, PEM 블록 전체(`-----BEGIN/END RSA PRIVATE KEY-----` 줄 포함)를 줄바꿈을 유지한 채 env 값에 붙여넣으세요.
|
||||
|
||||
`FRONTEND_ORIGIN`도 설정되어 있어야 합니다(어떤 프로덕션 자체 호스팅이든 이미 설정되어 있습니다). 설치 후 setup 콜백이 사용자를 `<FRONTEND_ORIGIN>/settings?tab=github`으로 다시 돌려보냅니다.
|
||||
|
||||
env 변수를 설정한 후 API를 재시작하세요.
|
||||
|
||||
@@ -114,20 +114,13 @@ On the API server:
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
|
||||
|
||||
# Optional but recommended — populates the connected account name on
|
||||
# install instead of waiting for the first webhook to refresh it:
|
||||
GITHUB_APP_ID=<numeric App ID from the App's settings page>
|
||||
GITHUB_APP_PRIVATE_KEY=<full PEM block, including BEGIN/END lines>
|
||||
```
|
||||
|
||||
`GITHUB_APP_SLUG` and `GITHUB_WEBHOOK_SECRET` are required. If either is missing:
|
||||
Both variables are required. If either is missing:
|
||||
|
||||
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
|
||||
|
||||
`GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` are **optional**. They let the setup callback call GitHub's App-authenticated `/app/installations/{id}` endpoint to fetch the real organization or user name during install. Without them, the connection card briefly shows `Connected to unknown` until GitHub delivers the `installation.created` webhook (typically within a few seconds), at which point Multica refreshes the row and broadcasts a realtime update so any open Settings tab updates without a manual refresh. Generate the private key under **Private keys → Generate a private key** on the App's settings page; paste the full PEM block (including the `-----BEGIN/END RSA PRIVATE KEY-----` lines) into the env var, preserving newlines.
|
||||
|
||||
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings?tab=github` after install.
|
||||
|
||||
Restart the API after setting the env vars.
|
||||
|
||||
@@ -114,19 +114,13 @@ API server 上:
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
|
||||
|
||||
# 可选但建议配置——安装完成时直接拿到关联账号名,而不是等第一次 webhook 才刷新:
|
||||
GITHUB_APP_ID=<App 设置页上的数字 App ID>
|
||||
GITHUB_APP_PRIVATE_KEY=<完整 PEM 块,包含 BEGIN/END 行>
|
||||
```
|
||||
|
||||
`GITHUB_APP_SLUG` 和 `GITHUB_WEBHOOK_SECRET` 必填。任何一个缺失:
|
||||
两个都必填。任何一个缺失:
|
||||
|
||||
- Settings 里 `Connect GitHub` 按钮会被 **disable**,并显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
|
||||
|
||||
`GITHUB_APP_ID` 和 `GITHUB_APP_PRIVATE_KEY` **可选**。配上之后,setup 回调可以用 App JWT 鉴权调用 GitHub `/app/installations/{id}`,安装完成那一刻就拿到真实的组织名/用户名。不配的话,连接卡片会先显示 `已连接到 unknown`,等 GitHub 的 `installation.created` webhook 到达(通常几秒内),Multica 会刷新 row 并通过 realtime 推送让 Settings 页面无需手动刷新即可更新。私钥从 App 设置页 **Private keys → Generate a private key** 生成,把整段 PEM(含 `-----BEGIN/END RSA PRIVATE KEY-----` 两行)粘到 env 里,保留换行。
|
||||
|
||||
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings?tab=github`。
|
||||
|
||||
设完 env 重启 API。
|
||||
|
||||
@@ -48,7 +48,7 @@ multica daemon restart
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
よりきめ細かい承認ゲートを備えた JSON-RPC 2.0 のトランスポートです。**セッション再開は動作します** — Multica は Codex app-server の `thread/resume` で再開し、古いまたは存在しない thread では新しい thread にフォールバックします。
|
||||
よりきめ細かい承認ゲートを備えた JSON-RPC 2.0 のトランスポートです。**セッション再開のコードは存在しますが、現在は到達できません** — 再開が必要な場合は Claude Code か ACP 系列のいずれかを選んでください。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ multica daemon restart
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
Cursor エディタに対応する CLI です。**セッション再開は動作します** — 現在の Cursor Agent は stream-json イベントで `session_id` を返し、Multica は次回実行時に `--resume <id>` でそれを渡します。
|
||||
Cursor エディタに対応する CLI です。**セッション再開は動作しません** — Cursor の CLI がセッション id を返さないため、再開時に渡す値は常に無効です。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -48,7 +48,7 @@ multica daemon restart
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
더 세분화된 승인 게이트를 갖춘 JSON-RPC 2.0 전송 방식입니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개하며, 오래되었거나 없는 thread는 새 thread로 폴백합니다.
|
||||
더 세분화된 승인 게이트를 갖춘 JSON-RPC 2.0 전송 방식입니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개 코드는 존재하지만 현재 도달할 수 없습니다** — 재개가 필요하다면 Claude Code 또는 ACP 계열 중 하나를 선택하세요.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ multica daemon restart
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent는 stream-json 이벤트에서 `session_id`를 반환하고, Multica는 다음 실행 때 이를 `--resume <id>`로 전달합니다.
|
||||
Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 작동하지 않습니다** — Cursor의 CLI가 세션 id를 반환하지 않으므로 재개 시 전달하는 값은 항상 유효하지 않습니다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -48,7 +48,7 @@ The most complete integration. Session resumption works, MCP works, and it consu
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written into the per-task `$CODEX_HOME/config.toml`. **Session resumption works** through Codex app-server `thread/resume`; stale or missing threads fall back to a fresh thread.
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written into the per-task `$CODEX_HOME/config.toml`. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
The CLI counterpart to the Cursor editor. **Session resumption works** with current Cursor Agent releases: Multica reads `session_id` from the stream-json events and passes it back with `--resume <id>`.
|
||||
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -48,7 +48,7 @@ multica daemon restart
|
||||
|
||||
### Codex(OpenAI)
|
||||
|
||||
JSON-RPC 2.0 传输,审批粒度更细。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话续接可用**——Multica 通过 Codex app-server 的 `thread/resume` 续接;thread 过期或不存在时会回退到新 thread。
|
||||
JSON-RPC 2.0 传输,审批粒度更细。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ JSON-RPC 2.0 传输,审批粒度更细。MCP 配置会写入单次任务的 `$
|
||||
|
||||
### Cursor(Anysphere)
|
||||
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接可用**——当前 Cursor Agent 会在 stream-json 事件里返回 `session_id`,Multica 会在下一次运行时用 `--resume <id>` 传回去。
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id,你传过去的续接 id 永远无效。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -13,32 +13,32 @@ Multica は **12 個の AI コーディングツール**を標準でサポート
|
||||
|
||||
| ツール | ベンダー | セッション再開 | MCP | スキル注入パス | モデル選択 |
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 動的探索(`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静的 + flag |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静的 |
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Antigravity CLI 自体の内部で管理 |
|
||||
| **Claude Code** | Anthropic | ✅ | **✅(実際に使用する唯一のツール)** | `.claude/skills/` | 静的 + flag |
|
||||
| **Codex** | OpenAI | ⚠️ コードは存在するが到達不可 | ❌ | `$CODEX_HOME/skills/` | 静的 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静的(アカウントの権限で決定) |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/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/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/`(フォールバック) | 動的探索 |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 動的探索 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 動的探索 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | 動的探索 |
|
||||
| **OpenClaw** | オープンソース | ✅ | ❌ | `.agent_context/skills/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
|
||||
| **Pi** | Inflection AI | ✅(セッションがファイルパス) | ❌ | `.pi/skills/` | 動的探索 |
|
||||
|
||||
## 各ツールの用途
|
||||
|
||||
### Antigravity
|
||||
|
||||
Google が提供します。CLI バイナリ名は `agy` です。Google の Antigravity サービスと連携し、Gemini ベースのデフォルトモデルが付属しています。**セッション再開が動作します** — `--conversation <id>` を通じて行われ、stdout が構造化されたイベントストリームではなくプレーンテキストであるため、デーモンが CLI のログファイルから conversation UUID をキャプチャします。**モデル選択が動作します** — `--model` flag(agy 1.0.6 で追加)を通じて行われ、デーモンが `agy models` でカタログを列挙し、選択された値をそのまま渡します。これらは `provider/model` slug ではなく `Claude Opus 4.6 (Thinking)` のような人間が読める表示名である点に注意してください。また agy は認識できない値を渡すと黙って空実行するため、手入力ではなく検出されたリストから選ぶことをおすすめします。スキルは `.agents/skills/` に配置されます(CLI が Gemini CLI のワークスペーススキルレイアウトをそのまま継承します — [Antigravity 移行ドキュメント](https://antigravity.google/docs/gcli-migration)を参照)。
|
||||
Google が提供します。CLI バイナリ名は `agy` です。Google の Antigravity サービスと連携し、Gemini ベースのデフォルトモデルが付属しています。**セッション再開が動作します** — `--conversation <id>` を通じて行われ、stdout が構造化されたイベントストリームではなくプレーンテキストであるため、デーモンが CLI のログファイルから conversation UUID をキャプチャします。`--model` flag はありません — モデル選択は Antigravity CLI の設定内にあるため、Multica はこのプロバイダーに対してエージェントごとのモデルピッカーを無効にします。スキルは `.agents/skills/` に配置されます(CLI が Gemini CLI のワークスペーススキルレイアウトをそのまま継承します — [Antigravity 移行ドキュメント](https://antigravity.google/docs/gcli-migration)を参照)。
|
||||
|
||||
### Claude Code
|
||||
|
||||
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、MCP 構成を読み取り、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
|
||||
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、**11 個の中で MCP 構成を本当に読み取る唯一のツール**であり、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI が提供します。JSON-RPC 2.0 を使用し、ステートフルな能力がより強く、よりきめ細かい承認メカニズム(`exec_command` および `patch_apply` に対する手動承認)を備えています。MCP 構成はタスクごとの `$CODEX_HOME/config.toml` に書き込まれます。**セッション再開は動作します** — Multica は Codex app-server の `thread/resume` で再開します。保存済み thread が見つからない、または古い場合は、新しい thread にフォールバックしてタスクを続行します。
|
||||
OpenAI が提供します。JSON-RPC 2.0 を使用し、ステートフルな能力がより強く、よりきめ細かい承認メカニズム(`exec_command` および `patch_apply` に対する手動承認)を備えています。**セッション再開のコードは存在しますが、現在は到達できません** — 再開が必要なら、Claude Code または ACP 系のいずれかを選んでください。
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ GitHub が提供します。モデルルーティングは GitHub アカウン
|
||||
|
||||
### Cursor
|
||||
|
||||
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 に依存しません。
|
||||
Anysphere が提供し、Cursor エディターに対応する CLI です。**セッション再開のコードは存在しますが、実際には動作しません** — Cursor CLI のイベントストリームがセッション ID を返さないため、渡す再開値は常に無効です。再開が必要なら、別のものを選んでください。
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -54,23 +54,23 @@ Google が提供し、Gemini 2.5 および 3 シリーズをサポートしま
|
||||
|
||||
### Hermes
|
||||
|
||||
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作し、MCP 構成は ACP `mcpServers` として渡されます。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**(`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
|
||||
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作します。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**(`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
|
||||
|
||||
### Kimi
|
||||
|
||||
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有し、MCP 構成も ACP `mcpServers` として渡されますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
|
||||
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有しますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
|
||||
|
||||
### Kiro CLI
|
||||
|
||||
Amazon が提供します。`kiro-cli acp` を通じて stdio 上で ACP を使用します。セッション再開は ACP `session/load` で動作し、MCP 構成は ACP `mcpServers` として渡され、モデル選択は `session/set_model` で動作し、スキルはプロジェクトレベルのネイティブ探索のために `.kiro/skills/` にコピーされます。
|
||||
Amazon が提供します。`kiro-cli acp` を通じて stdio 上で ACP を使用します。セッション再開は ACP `session/load` で動作し、モデル選択は `session/set_model` で動作し、スキルはプロジェクトレベルのネイティブ探索のために `.kiro/skills/` にコピーされます。
|
||||
|
||||
### OpenCode
|
||||
|
||||
SST が提供するオープンソースです。利用可能なモデルと model variant を動的に探索します(CLI の構成ファイルをスキャン)。セッション再開が動作し、エージェントの `mcp_config` フィールドを消費します。Multica は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン注入するため、エージェントの MCP server はタスク workdir の `opencode.json`(エージェントまたはユーザーが所有するファイル)を書き換えずに OpenCode に届きます。モデルが variant を公開している場合、Multica はそれをエージェントの thinking selector として表示し、選択値を `opencode run --variant` で OpenCode に渡します。**自分のモデルカタログをカスタマイズしたい、いじるのが好きなユーザーに適しています。**
|
||||
SST が提供するオープンソースです。利用可能なモデルを動的に探索します(CLI の構成ファイルをスキャン)。セッション再開が動作します。**自分のモデルカタログをカスタマイズしたい、いじるのが好きなユーザーに適しています。**
|
||||
|
||||
### OpenClaw
|
||||
|
||||
オープンソースプロジェクトであり、CLI エージェントオーケストレーターです。MCP 構成は Multica のタスクごとの config wrapper 経由で書き込まれます。**モデルはエージェント層にバインドされます**(`openclaw agents add --model`) — タスクごとに上書きできません。構成は厳格に制御されます: ユーザーは `--model` や `--system-prompt` を渡せず、エージェント登録時の構成が決定します。
|
||||
オープンソースプロジェクトであり、CLI エージェントオーケストレーターです。**モデルはエージェント層にバインドされます**(`openclaw agents add --model`) — タスクごとに上書きできません。構成は厳格に制御されます: ユーザーは `--model` や `--system-prompt` を渡せず、エージェント登録時の構成が決定します。
|
||||
|
||||
### Pi
|
||||
|
||||
@@ -82,19 +82,18 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
|
||||
|
||||
| 状態 | ツール | 意味 |
|
||||
|---|---|---|
|
||||
| ✅ 実際に動作 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 再開 id を渡すと以前のコンテキストから続行します |
|
||||
| ✅ 実際に動作 | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 再開 id を渡すと以前のコンテキストから続行します |
|
||||
| ⚠️ コードは存在するが到達不可 | Codex, Cursor | コードに再開パスがありますが、実際には到達しません(Codex は静かにフォールバックし、Cursor はセッション id を返しません) — **未サポートとみなしてください** |
|
||||
| ❌ なし | Gemini | CLI に再開メカニズムがありません |
|
||||
|
||||
**意思決定のために**: ワークフローでエージェントがタスク間でコンテキストを保持する必要がある場合(失敗時のリトライ、手動の再実行、対話的な反復)、✅ の行にあるツールだけを選んでください。
|
||||
|
||||
## MCP 構成: ツールごとの対応
|
||||
## MCP 構成: Claude Code だけが実際に読み取る
|
||||
|
||||
**12 個のツールのうち、`mcp_config` を実際に消費するのは 8 個です: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。残りの 4 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
|
||||
|
||||
接続方式はツールごとに異なります: 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` を書き換えません。
|
||||
**12 個のツールのうち、`mcp_config` を実際に消費するのは Claude Code だけです**。残りの 11 個はこのフィールドを受け取りますが、**完全に無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
|
||||
|
||||
<Callout type="warning">
|
||||
エージェント構成で `mcp_config` を設定しても、MCP 列に ✅ がないツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。MCP 連携はツールごとに実装されています。
|
||||
エージェント構成で `mcp_config` を設定しても、Claude Code 以外のツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。現在、MCP 連携は Claude Code のみをカバーしています。
|
||||
</Callout>
|
||||
|
||||
## スキルファイルが置かれる場所
|
||||
@@ -118,8 +117,6 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
|
||||
|
||||
フォールバックパスを使うツールが実際にこのディレクトリを読み取るかどうかは、そのツール自体のドキュメントによって異なり、保証されません。Gemini / Hermes / OpenClaw でスキルが適用されない場合は、まずこの点を確認してください。
|
||||
|
||||
ネイティブなプロジェクトレベルのパスでは、リポジトリスコープの探索は想定された挙動です。チェックアウトされたリポジトリが対応するディレクトリをすでに含んでいる場合、基盤となるツールはそのコミット済みスキルを自分で検出できます。そのリポジトリで使うためだけに、これらの repo skills を Multica へインポートする必要はありません。Multica はそれらのリポジトリファイルをそのまま保持します。ワークスペーススキルの自然なディレクトリ名が同じ場合、デーモンは `review-helper-multica` のような衝突しない兄弟ディレクトリへワークスペースコピーを書き込みます。
|
||||
|
||||
スキルの作成と使用については、[スキル](/skills)を参照してください。
|
||||
|
||||
## 次へ
|
||||
|
||||
@@ -13,11 +13,11 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
|
||||
|
||||
| 도구 | 공급사 | 세션 재개 | MCP | 스킬 주입 경로 | 모델 선택 |
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 동적 탐색(`agy models`) |
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Antigravity CLI 자체 내부에서 관리 |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 정적 + flag |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 정적 |
|
||||
| **Codex** | OpenAI | ⚠️ 코드는 존재하지만 도달 불가 | ✅ | `$CODEX_HOME/skills/` | 정적 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 정적 (계정 권한으로 결정) |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 동적 탐색 |
|
||||
| **Cursor** | Anysphere | ⚠️ 코드는 존재하지만 사용 불가 | ❌ | `.cursor/skills/` | 동적 탐색 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 정적 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | 동적 탐색 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 동적 탐색 |
|
||||
@@ -30,7 +30,7 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
|
||||
|
||||
### Antigravity
|
||||
|
||||
Google에서 제공합니다. CLI 바이너리 이름은 `agy`입니다. Google의 Antigravity 서비스와 연동되며 Gemini 기반의 기본 모델을 함께 제공합니다. **세션 재개가 동작합니다** — `--conversation <id>`를 통해서이며, stdout이 구조화된 이벤트 스트림이 아니라 일반 텍스트이기 때문에 데몬이 CLI의 로그 파일에서 conversation UUID를 캡처합니다. **모델 선택이 동작합니다** — `--model` flag(agy 1.0.6에서 추가)를 통해서이며, 데몬이 `agy models`로 카탈로그를 열거하고 선택된 값을 그대로 전달합니다. 이 값들은 `provider/model` slug가 아니라 `Claude Opus 4.6 (Thinking)` 같은 사람이 읽는 표시 이름이라는 점에 유의하세요. 또한 agy는 인식할 수 없는 값을 받으면 조용히 빈 실행을 하므로, 직접 입력하기보다 발견된 목록에서 선택하는 것을 권장합니다. 스킬은 `.agents/skills/`에 들어갑니다(CLI가 Gemini CLI의 워크스페이스 스킬 레이아웃을 그대로 따릅니다 — [Antigravity 마이그레이션 문서](https://antigravity.google/docs/gcli-migration) 참고).
|
||||
Google에서 제공합니다. CLI 바이너리 이름은 `agy`입니다. Google의 Antigravity 서비스와 연동되며 Gemini 기반의 기본 모델을 함께 제공합니다. **세션 재개가 동작합니다** — `--conversation <id>`를 통해서이며, stdout이 구조화된 이벤트 스트림이 아니라 일반 텍스트이기 때문에 데몬이 CLI의 로그 파일에서 conversation UUID를 캡처합니다. `--model` flag는 없습니다 — 모델 선택은 Antigravity CLI 설정 안에 있으므로, Multica는 이 제공자에 대해 에이전트별 모델 선택기를 비활성화합니다. 스킬은 `.agents/skills/`에 들어갑니다(CLI가 Gemini CLI의 워크스페이스 스킬 레이아웃을 그대로 따릅니다 — [Antigravity 마이그레이션 문서](https://antigravity.google/docs/gcli-migration) 참고).
|
||||
|
||||
### Claude Code
|
||||
|
||||
@@ -38,7 +38,7 @@ Anthropic에서 제공합니다. **신규 사용자에게 첫 번째 선택지**
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개합니다. 저장된 thread가 없거나 오래된 경우에는 새 thread로 폴백해 작업을 계속 실행합니다.
|
||||
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개 코드는 존재하지만 현재 도달할 수 없습니다** — 재개가 필요하다면 Claude Code나 ACP 계열 중 하나를 선택하세요.
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ GitHub에서 제공합니다. 모델 라우팅은 GitHub 계정 권한을 거칩
|
||||
|
||||
### Cursor
|
||||
|
||||
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent의 stream-json 이벤트에는 `session_id`가 포함되며, Multica는 다음 실행 때 이를 `--resume <id>`로 다시 전달합니다. MCP 구성은 작업 워크스페이스의 `.cursor/mcp.json`에 기록되고, Cursor의 프로젝트 approval 파일은 작업별 `CURSOR_DATA_DIR` 아래에 기록되므로, 관리되는 MCP 서버는 사용자의 전역 Cursor approval에 의존하지 않습니다.
|
||||
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개 코드는 존재하지만 실제로는 동작하지 않습니다** — Cursor CLI 이벤트 스트림이 세션 ID를 반환하지 않으므로, 전달하는 재개 값은 항상 무효입니다. 재개가 필요하다면 다른 것을 선택하세요.
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -82,16 +82,17 @@ Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이
|
||||
|
||||
| 상태 | 도구 | 의미 |
|
||||
|---|---|---|
|
||||
| ✅ 실제로 동작 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
|
||||
| ✅ 실제로 동작 | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
|
||||
| ⚠️ 코드는 존재하지만 도달 불가 | Codex, Cursor | 코드에 재개 경로가 있지만 실제로는 도달하지 않습니다(Codex는 조용히 폴백하고, Cursor는 세션 id를 반환하지 않습니다) — **미지원으로 간주하세요** |
|
||||
| ❌ 없음 | Gemini | CLI에 재개 메커니즘이 없습니다 |
|
||||
|
||||
**의사결정을 위해**: 워크플로에서 에이전트가 작업 간에 컨텍스트를 유지해야 한다면(실패 재시도, 수동 재실행, 대화형 반복), ✅ 행에 있는 도구만 선택하세요.
|
||||
|
||||
## MCP 구성: 도구별 지원
|
||||
|
||||
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 8개입니다: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 4개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
|
||||
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 7개입니다: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 5개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
|
||||
|
||||
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Cursor는 `.cursor/mcp.json`과 작업별 `CURSOR_DATA_DIR` 아래의 프로젝트 approval을 기록합니다. Hermes/Kimi/Kiro CLI는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
|
||||
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, 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 연동은 도구별로 구현됩니다.
|
||||
@@ -118,8 +119,6 @@ Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이
|
||||
|
||||
fallback 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는 해당 도구 자체의 문서에 따라 달라지며 — 보장되지 않습니다. Gemini / Hermes / OpenClaw에서 스킬이 적용되지 않는다면, 먼저 이 점을 확인하세요.
|
||||
|
||||
기본 프로젝트 수준 경로에서는 저장소 범위 탐색이 의도된 동작입니다. 체크아웃된 저장소가 이미 해당 디렉터리를 포함하고 있으면, 기반 도구가 커밋된 스킬을 자체적으로 탐색할 수 있습니다. 해당 저장소에서 사용하기 위해 이러한 repo skills를 Multica로 먼저 가져올 필요는 없습니다. Multica는 이러한 저장소 파일을 그대로 둡니다. 워크스페이스 스킬의 자연 디렉터리 이름이 같으면 데몬은 `review-helper-multica` 같은 충돌 없는 형제 디렉터리에 워크스페이스 사본을 씁니다.
|
||||
|
||||
스킬의 생성과 사용은 [스킬](/skills)을 참고하세요.
|
||||
|
||||
## 다음
|
||||
|
||||
@@ -13,11 +13,11 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
|
||||
| Tool | Vendor | Session resumption | MCP | Skill injection path | Model selection |
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Dynamic discovery (`agy models`) |
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Managed inside the Antigravity CLI itself |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | Static + flag |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | Static |
|
||||
| **Codex** | OpenAI | ⚠️ Code exists but unreachable | ✅ | `$CODEX_HOME/skills/` | Static |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | Dynamic discovery |
|
||||
| **Cursor** | Anysphere | ⚠️ Code exists but unusable | ❌ | `.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 |
|
||||
@@ -30,7 +30,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
|
||||
### Antigravity
|
||||
|
||||
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
|
||||
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. There is no `--model` flag — model selection lives inside the Antigravity CLI settings, so Multica disables the per-agent model picker for this provider. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
|
||||
|
||||
### Claude Code
|
||||
|
||||
@@ -38,7 +38,7 @@ From Anthropic. **First choice for new users** — the most complete feature set
|
||||
|
||||
### 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.
|
||||
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 code exists but is currently unreachable** — if you need resume, pick Claude Code or one of the ACP family.
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ From GitHub. Model routing goes through your GitHub account entitlement — the
|
||||
|
||||
### Cursor
|
||||
|
||||
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.
|
||||
From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption code exists but doesn't actually work** — the Cursor CLI event stream doesn't return a session ID, so any resume value you pass is always invalid. If you need resume, pick something else.
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -82,16 +82,17 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
|
||||
|
||||
| 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 |
|
||||
| ✅ Really works | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** |
|
||||
| ❌ 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 12 tools, eight consume `mcp_config`: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other four accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
**Of the 12 tools, seven consume `mcp_config`: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other five accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
|
||||
The runtime paths are provider-specific: Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, and Kiro CLI receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
|
||||
The runtime paths are provider-specific: Claude Code 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`; 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.
|
||||
@@ -118,8 +119,6 @@ Each tool uses **its own** skill discovery path. Before a task runs, the 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 native project-level paths, repo-scoped discovery is expected: if the checked-out repository already contains a matching directory, the underlying tool can discover those committed skills on its own. You do not need to import those repo skills into Multica just to use them in that repo. Multica keeps the repo files intact. If a workspace skill has the same natural directory name, the daemon writes the workspace copy to a collision-free sibling such as `review-helper-multica`.
|
||||
|
||||
For creating and using skills, see [Skills](/skills).
|
||||
|
||||
## Next
|
||||
|
||||
@@ -13,11 +13,11 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
|
||||
|
||||
| 工具 | 厂商 | 会话恢复 | MCP | Skill 注入路径 | 模型选择 |
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅(`--conversation <id>`)| ❌ | `.agents/skills/` | 动态发现(`agy models`)|
|
||||
| **Antigravity** | Google | ✅(`--conversation <id>`)| ❌ | `.agents/skills/` | 由 Antigravity CLI 自己管理 |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静态 + flag |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静态 |
|
||||
| **Codex** | OpenAI | ⚠️ 代码存在但不可达 | ✅ | `$CODEX_HOME/skills/` | 静态 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 动态发现 |
|
||||
| **Cursor** | Anysphere | ⚠️ 代码存在但不可用 | ❌ | `.cursor/skills/` | 动态发现 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静态 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback)| 动态发现 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 动态发现 |
|
||||
@@ -30,7 +30,7 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
|
||||
|
||||
### Antigravity
|
||||
|
||||
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flag(agy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug;而且 agy 遇到无法识别的值会静默空跑,所以优先从发现列表里挑选,不要手填。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
|
||||
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。CLI 没有 `--model` flag——模型选择保存在 Antigravity 自己的设置里,因此 Multica 禁用了这款工具的模型选择控件。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
|
||||
|
||||
### Claude Code
|
||||
|
||||
@@ -38,7 +38,7 @@ Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI 出品。使用 JSON-RPC 2.0 协议,状态化更强,approve 机制更细(手动批准 `exec_command` 和 `patch_apply`)。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话恢复可用**——Multica 通过 Codex app-server 的 `thread/resume` 续接;如果已保存的 thread 不存在或过期,会回退到新 thread,让任务继续执行。
|
||||
OpenAI 出品。使用 JSON-RPC 2.0 协议,状态化更强,approve 机制更细(手动批准 `exec_command` 和 `patch_apply`)。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话恢复代码存在但当前不可达**——如果你需要 resume,选 Claude Code 或 ACP 系列。
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ GitHub 出品。模型路由走你的 GitHub 账号权益——工具自己不
|
||||
|
||||
### Cursor
|
||||
|
||||
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。
|
||||
Anysphere 出品,Cursor 编辑器的 CLI 对应物。**会话恢复代码存在但实际不工作**——Cursor CLI 的事件流里不回传 session ID,所以你传的 resume 值永远无效。如果要 resume,选别的。
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -82,16 +82,17 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
|
||||
| 状态 | 工具 | 含义 |
|
||||
|---|---|---|
|
||||
| ✅ 真用 | Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ✅ 真用 | Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到(Codex 静默回落、Cursor session id 不回传)—— **当作不支持** |
|
||||
| ❌ 无 | Gemini | CLI 无 resume 机制 |
|
||||
|
||||
**对你的决策**:如果工作流需要智能体在多次任务之间保持上下文(失败重试、手动重跑、对话式迭代),只选 ✅ 那一行的工具。
|
||||
|
||||
## MCP 配置:按工具不同
|
||||
|
||||
**12 款工具里有 8 款实际消费 `mcp_config`:Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 4 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
|
||||
**12 款工具里有 7 款实际消费 `mcp_config`:Claude Code、Codex、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 5 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
|
||||
|
||||
各工具的接入方式不同:Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收;Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`;Cursor 会写入 `.cursor/mcp.json`,并把项目 approval 写到单次任务的 `CURSOR_DATA_DIR`;Hermes、Kimi、Kiro CLI 通过 ACP `mcpServers` 接收;OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收;OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
|
||||
各工具的接入方式不同:Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收;Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`;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 集成是按工具实现的。
|
||||
@@ -118,8 +119,6 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
|
||||
fallback 路径对应的工具是否真的读取这个目录,取决于工具本身的文档——没保证。如果你的 skill 对 Gemini / Hermes / OpenClaw 没起效,先查这个问题。
|
||||
|
||||
对原生项目级路径来说,repo-scoped discovery 是预期行为:如果检出的仓库已经包含对应目录,底层工具可以自己发现这些提交在仓库里的 Skill。你不需要为了在这个仓库里使用这些 repo skills 而先把它们导入 Multica。Multica 会保持这些仓库文件不变。如果某个工作区 Skill 的自然目录名相同,守护进程会把工作区副本写到类似 `review-helper-multica` 的无冲突 sibling 目录。
|
||||
|
||||
skill 的创建和使用详见 [技能](/skills)。
|
||||
|
||||
## 下一步
|
||||
|
||||
@@ -30,7 +30,7 @@ make selfhost
|
||||
|
||||
`make selfhost` は次のことを行います。
|
||||
|
||||
1. `.env` がなければ `.env.example` から生成し、**ランダムな JWT_SECRET と Postgres パスワード** を併せて作成します
|
||||
1. `.env` がなければ `.env.example` から生成し、**ランダムな JWT_SECRET** を併せて作成します
|
||||
2. 公式の Docker イメージ(PostgreSQL、Multica backend、Multica frontend)を取得します
|
||||
3. `docker-compose.selfhost.yml` を使ってすべてのサービスを起動します
|
||||
4. バックエンドの `/health` エンドポイントが準備できるまで待機します
|
||||
@@ -49,7 +49,7 @@ make selfhost
|
||||
- **バックエンド**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**ポートは `127.0.0.1` でのみ待ち受けます。** `docker-compose.selfhost.yml` は公開されるすべてのポートを loopback にバインドします — `ss -tlnp` には `0.0.0.0:8080` は表示されず、設計上、他のマシンからはサービスにアクセスできません。サーバーのシークレットと Postgres の認証情報は、公開インターネット上に置いては絶対にいけません。マシン間アクセスが必要な場合は、TLS を終端するリバースプロキシをスタックの前に置いてください — [ステップ5b — マシン間: リバースプロキシを前に置く](#5b-cross-machine-front-with-a-reverse-proxy)を参照してください。
|
||||
**ポートは `127.0.0.1` でのみ待ち受けます。** `docker-compose.selfhost.yml` は公開されるすべてのポートを loopback にバインドします — `ss -tlnp` には `0.0.0.0:8080` は表示されず、設計上、他のマシンからはサービスにアクセスできません。デフォルトの `JWT_SECRET` と Postgres の認証情報は、公開インターネット上に置いては絶対にいけません。マシン間アクセスが必要な場合は、TLS を終端するリバースプロキシをスタックの前に置いてください — [ステップ5b — マシン間: リバースプロキシを前に置く](#5b-cross-machine-front-with-a-reverse-proxy)を参照してください。
|
||||
</Callout>
|
||||
|
||||
## 2. 重要: プロダクションの安全設定を維持する
|
||||
@@ -154,7 +154,7 @@ multica setup self-host
|
||||
|
||||
### 5b. マシン間: リバースプロキシを前に置く
|
||||
|
||||
compose スタックは `127.0.0.1` でのみ待ち受けるため、別のマシンにあるデーモンは `http://<server-ip>:8080` に直接接続できません — そして、そうなることを望むべきでもありません。さもなければサーバーのシークレットが公開インターネットから到達可能になってしまうからです。TLS を終端し、`127.0.0.1:8080`(バックエンド)と `127.0.0.1:3000`(フロントエンド)へ転送するリバースプロキシをサーバーに置き、CLI を公開 HTTPS URL に向けてください。
|
||||
compose スタックは `127.0.0.1` でのみ待ち受けるため、別のマシンにあるデーモンは `http://<server-ip>:8080` に直接接続できません — そして、そうなることを望むべきでもありません。さもなければデフォルトの `JWT_SECRET` が公開インターネットから到達可能になってしまうからです。TLS を終端し、`127.0.0.1:8080`(バックエンド)と `127.0.0.1:3000`(フロントエンド)へ転送するリバースプロキシをサーバーに置き、CLI を公開 HTTPS URL に向けてください。
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
@@ -162,10 +162,6 @@ multica setup self-host \
|
||||
--app-url https://<your-domain>
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
フラグより環境変数を使いたい場合は、対応するフラグを省略すると `setup self-host` が `MULTICA_SERVER_URL` と `MULTICA_APP_URL` を読み取ります(両方設定した場合はフラグが優先されます)。`MULTICA_SERVER_URL` は[環境変数](/environment-variables)で示される `ws://…/ws` というデーモン形式も受け付け、HTTP ベース URL に正規化します。
|
||||
</Callout>
|
||||
|
||||
単一のホスト名でフロントエンドとバックエンドの両方を前段に置く(デーモンと Web アプリの両方に必要な WebSocket サポートを含む)最小限の Caddyfile は次のとおりです。
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -30,7 +30,7 @@ make selfhost
|
||||
|
||||
`make selfhost`는 다음을 수행합니다.
|
||||
|
||||
1. `.env`가 없으면 `.env.example`로부터 생성하며 **무작위 JWT_SECRET과 Postgres 비밀번호**를 함께 만듭니다
|
||||
1. `.env`가 없으면 `.env.example`로부터 생성하며 **무작위 JWT_SECRET**을 함께 만듭니다
|
||||
2. 공식 Docker 이미지(PostgreSQL, Multica backend, Multica frontend)를 받아옵니다
|
||||
3. `docker-compose.selfhost.yml`을 사용해 모든 서비스를 시작합니다
|
||||
4. 백엔드의 `/health` 엔드포인트가 준비될 때까지 기다립니다
|
||||
@@ -49,7 +49,7 @@ make selfhost
|
||||
- **백엔드**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**포트는 `127.0.0.1`에서만 수신합니다.** `docker-compose.selfhost.yml`은 공개된 모든 포트를 loopback에 바인딩합니다 — `ss -tlnp`에서는 `0.0.0.0:8080`이 보이지 않으며, 설계상 다른 기기에서는 서비스에 접근할 수 없습니다. 서버 시크릿과 Postgres 자격 증명이 공개 인터넷에 노출되어서는 절대 안 됩니다. 기기 간 접근이 필요하면 TLS를 종료하는 리버스 프록시를 스택 앞에 두세요 — [5b단계 — 기기 간: 리버스 프록시를 앞에 두기](#5b-cross-machine-front-with-a-reverse-proxy)를 참고하세요.
|
||||
**포트는 `127.0.0.1`에서만 수신합니다.** `docker-compose.selfhost.yml`은 공개된 모든 포트를 loopback에 바인딩합니다 — `ss -tlnp`에서는 `0.0.0.0:8080`이 보이지 않으며, 설계상 다른 기기에서는 서비스에 접근할 수 없습니다. 기본 `JWT_SECRET`과 Postgres 자격 증명이 공개 인터넷에 노출되어서는 절대 안 됩니다. 기기 간 접근이 필요하면 TLS를 종료하는 리버스 프록시를 스택 앞에 두세요 — [5b단계 — 기기 간: 리버스 프록시를 앞에 두기](#5b-cross-machine-front-with-a-reverse-proxy)를 참고하세요.
|
||||
</Callout>
|
||||
|
||||
## 2. 중요: 프로덕션 안전 설정 유지하기
|
||||
@@ -154,7 +154,7 @@ multica setup self-host
|
||||
|
||||
### 5b. 기기 간: 리버스 프록시를 앞에 두기
|
||||
|
||||
compose 스택은 `127.0.0.1`에서만 수신하므로, 다른 기기에 있는 데몬은 `http://<server-ip>:8080`에 직접 연결할 수 없습니다 — 그리고 그렇게 되기를 원해서도 안 됩니다. 그렇지 않으면 서버 시크릿이 공개 인터넷에서 접근 가능해지기 때문입니다. 서버에 TLS를 종료하고 `127.0.0.1:8080`(백엔드)과 `127.0.0.1:3000`(프런트엔드)으로 전달하는 리버스 프록시를 두고, CLI를 공개 HTTPS URL로 연결하세요.
|
||||
compose 스택은 `127.0.0.1`에서만 수신하므로, 다른 기기에 있는 데몬은 `http://<server-ip>:8080`에 직접 연결할 수 없습니다 — 그리고 그렇게 되기를 원해서도 안 됩니다. 그렇지 않으면 기본 `JWT_SECRET`이 공개 인터넷에서 접근 가능해지기 때문입니다. 서버에 TLS를 종료하고 `127.0.0.1:8080`(백엔드)과 `127.0.0.1:3000`(프런트엔드)으로 전달하는 리버스 프록시를 두고, CLI를 공개 HTTPS URL로 연결하세요.
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
@@ -162,10 +162,6 @@ multica setup self-host \
|
||||
--app-url https://<your-domain>
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
플래그 대신 환경 변수를 선호한다면, 해당 플래그를 생략할 때 `setup self-host`가 `MULTICA_SERVER_URL`과 `MULTICA_APP_URL`을 읽습니다(둘 다 설정하면 플래그가 우선합니다). `MULTICA_SERVER_URL`은 [환경 변수](/environment-variables)에 나오는 `ws://…/ws` 데몬 형식도 허용하며 HTTP 기본 URL로 정규화합니다.
|
||||
</Callout>
|
||||
|
||||
단일 호스트네임에서 프런트엔드와 백엔드를 모두 앞단에 두는(데몬과 웹 앱 모두에 필요한 WebSocket 지원 포함) 최소 Caddyfile은 다음과 같습니다.
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -30,7 +30,7 @@ make selfhost
|
||||
|
||||
`make selfhost` will:
|
||||
|
||||
1. Generate a `.env` from `.env.example` if missing, with a **random JWT_SECRET and Postgres password**
|
||||
1. Generate a `.env` from `.env.example` if missing, with a **random JWT_SECRET**
|
||||
2. Pull the official Docker images (PostgreSQL, Multica backend, Multica frontend)
|
||||
3. Bring up every service using `docker-compose.selfhost.yml`
|
||||
4. Wait until the backend's `/health` endpoint is ready
|
||||
@@ -50,7 +50,7 @@ Once it's up:
|
||||
- **Backend**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. Secrets and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
|
||||
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
|
||||
</Callout>
|
||||
|
||||
## 2. Important: keep production safety on
|
||||
@@ -155,7 +155,7 @@ That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3
|
||||
|
||||
### 5b. Cross-machine: front with a reverse proxy
|
||||
|
||||
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since server secrets would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
|
||||
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
@@ -163,10 +163,6 @@ multica setup self-host \
|
||||
--app-url https://<your-domain>
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Prefer environment variables over flags? `setup self-host` reads `MULTICA_SERVER_URL` and `MULTICA_APP_URL` when the matching flag is omitted — a flag still takes precedence over the env var. `MULTICA_SERVER_URL` also accepts the `ws://…/ws` daemon form from [Environment variables](/environment-variables) and normalizes it to the HTTP base.
|
||||
</Callout>
|
||||
|
||||
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -30,7 +30,7 @@ make selfhost
|
||||
|
||||
`make selfhost` 会:
|
||||
|
||||
1. 如果没有 `.env` 文件,从 `.env.example` 自动生成一份,并**生成随机 JWT_SECRET 和 Postgres 密码**
|
||||
1. 如果没有 `.env` 文件,从 `.env.example` 自动生成一份并**生成随机 JWT_SECRET**
|
||||
2. 拉取官方 Docker 镜像(PostgreSQL、Multica backend、Multica frontend)
|
||||
3. 用 `docker-compose.selfhost.yml` 启动全部服务
|
||||
4. 等后端 `/health` 端点准备就绪
|
||||
@@ -49,7 +49,7 @@ make selfhost
|
||||
- **后端**:[http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免服务密钥和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS,详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
|
||||
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS,详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
|
||||
</Callout>
|
||||
|
||||
## 2. 重要:保持生产安全配置
|
||||
@@ -154,7 +154,7 @@ multica setup self-host
|
||||
|
||||
### 5b. 跨机访问:用反向代理把服务挡在前面
|
||||
|
||||
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则服务密钥会直接暴露在公网。正确做法是在 server 上跑一个反向代理(Caddy / nginx / Cloudflare Tunnel),由它终结 TLS,再反代到 `127.0.0.1:8080`(backend)和 `127.0.0.1:3000`(frontend)。然后把 CLI 指到公开的 HTTPS 域名:
|
||||
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理(Caddy / nginx / Cloudflare Tunnel),由它终结 TLS,再反代到 `127.0.0.1:8080`(backend)和 `127.0.0.1:3000`(frontend)。然后把 CLI 指到公开的 HTTPS 域名:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
@@ -162,10 +162,6 @@ multica setup self-host \
|
||||
--app-url https://<你的域名>
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
更习惯用环境变量?省略对应 flag 时,`setup self-host` 会读取 `MULTICA_SERVER_URL` 和 `MULTICA_APP_URL`(同时设置时 flag 优先)。`MULTICA_SERVER_URL` 也接受[环境变量](/environment-variables)里那种 `ws://…/ws` 的 daemon 写法,并自动归一化为 HTTP 地址。
|
||||
</Callout>
|
||||
|
||||
最小可用的 Caddyfile,单域名同时挂前后端(带 WebSocket 转发,daemon 和网页端都依赖):
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -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 つのソースから取得します。
|
||||
@@ -60,7 +54,7 @@ GitHub や ClawHub からインポートしたスキルには、スクリプト
|
||||
- **スキル** = 構造化された**ナレッジパック**(静的なコンテンツ + 指示)。エージェントはスキルを読んで「問題 X を見たら、こう考えてこう行動する」を学びます。
|
||||
- **MCP**(Model Context Protocol)= **ツールチャネル**。エージェントは MCP を使って外部サービス(データベース、ファイルシステム、サードパーティ API)に接続し、それらを**呼び出します**。
|
||||
|
||||
この 2 つは相互補完的です。現在の Multica では、MCP サポートは**ツールごとに実装されています**: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw は `mcp_config` を使用し、他のツールはこのフィールドを受け取っても実際には使いません。MCP 専用のセクションは今後のリリースで追加される予定です。
|
||||
この 2 つは相互補完的です。現在の Multica では、MCP のサポートを**実際に使うのは Claude Code だけ**です — 他のツールは MCP 設定を受け取りはしますが、実際には使いません。MCP 専用のセクションは今後のリリースで追加される予定です。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` 같은 형제 이름으로 워크스페이스 사본을 씁니다. 그러면 도구에는 조정된 이름을 가진 워크스페이스 사본을 포함해 두 스킬이 모두 보일 수 있습니다.
|
||||
|
||||
## 스킬 가져오기
|
||||
|
||||
워크스페이스 스킬은 네 가지 소스에서 가져옵니다.
|
||||
@@ -60,7 +54,7 @@ GitHub나 ClawHub에서 가져온 스킬에는 스크립트와 실행 가능한
|
||||
- **스킬** = 구조화된 **지식 팩**(정적 콘텐츠 + 지침). 에이전트는 스킬을 읽어 "문제 X를 만나면 이렇게 생각하고 이렇게 처리하라"를 학습합니다.
|
||||
- **MCP**(Model Context Protocol) = **도구 채널**. 에이전트는 MCP를 사용해 외부 서비스(데이터베이스, 파일 시스템, 서드파티 API)에 연결하고 이를 **호출**합니다.
|
||||
|
||||
이 둘은 상호 보완적입니다. 현재 Multica에서 MCP 지원은 **도구별로 구현됩니다**: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw는 `mcp_config`를 사용하고, 다른 도구들은 이 필드를 받더라도 실제로 사용하지 않습니다. MCP 전용 섹션은 추후 릴리스에서 추가될 예정입니다.
|
||||
이 둘은 상호 보완적입니다. 현재 Multica에서 MCP 지원은 **도구별로 구현됩니다**: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw는 `mcp_config`를 사용하고, 다른 도구들은 이 필드를 받더라도 실제로 사용하지 않습니다. MCP 전용 섹션은 추후 릴리스에서 추가될 예정입니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -60,7 +54,7 @@ Both augment what an agent can do, but in different directions:
|
||||
- **Skill** = a structured **knowledge pack** (static content + instructions). The agent reads a skill to learn "when I see problem X, here's how to think and what to do."
|
||||
- **MCP** (Model Context Protocol) = a **tool channel**. The agent uses MCP to connect to external services (databases, filesystems, third-party APIs) and **invoke** them.
|
||||
|
||||
The two are complementary. In Multica today, MCP support is **provider-specific**: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw consume `mcp_config`; other tools receive the field but don't actually use it. A dedicated MCP section will come in a later release.
|
||||
The two are complementary. In Multica today, MCP support is **provider-specific**: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw consume `mcp_config`; other tools receive the field but don't actually use it. A dedicated MCP section will come in a later release.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,16 +12,10 @@ Skill 是给 [智能体](/agents) 的**专业知识包**——一个 `SKILL.md`
|
||||
Multica 支持两种 Skill 来源:
|
||||
|
||||
- **工作区 Skill(workspace skill)** —— 存在 Multica 云端。挂到智能体后,任务执行时自动同步到你本机的守护进程。这是**团队共享 Skill 的标准方式**。
|
||||
- **本机 Skill(local skill)** —— 直接存在你本机的某个目录里。[守护进程](/daemon-runtimes) 按你的请求扫描本机,发现后由你手动选入工作区。守护进程会**按优先级扫描两个根目录**:先扫该运行时自己的 skill 目录(每款 AI 编程工具有约定的默认路径,比如 Claude Code 的 `~/.claude/skills/`),再扫跨工具通用目录 `~/.agents/skills/`——这是 Codex、Gemini CLI 等生态共用的位置。当同名 skill 在两处都存在时,**provider 专用目录优先**,所以通用根目录只会*额外*带出新的 skill,绝不会改变已有 skill 的解析结果。
|
||||
- **本机 Skill(local 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 而先把它们导入 Multica;Multica 也不会自动把它们导入工作区 Skill 注册表。
|
||||
|
||||
对支持项目级发现的工具,工作区 Skill 也会同步到同一个 provider 原生位置。如果某个工作区 Skill 会和已有的仓库 Skill 目录冲突,Multica 会把工作区副本写到类似 `review-helper-multica` 的 sibling 名称下,而不是覆盖仓库文件。工具随后可能会同时看到两个 Skill,其中工作区副本使用调整后的名称。
|
||||
|
||||
## 导入 Skill
|
||||
|
||||
工作区 Skill 有四种来源:
|
||||
@@ -60,7 +54,7 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
|
||||
- **Skill** = 结构化的**知识包**(静态内容 + 指令)。智能体读 Skill 来学"遇到 X 类问题该怎么想、怎么做"。
|
||||
- **MCP**(Model Context Protocol)= **工具通道**。智能体通过 MCP 连外部服务(数据库、文件系统、第三方 API)并**调用**它们。
|
||||
|
||||
两者可以同时用。目前 Multica 的 MCP 支持是**按工具实现**的:Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw 会消费 `mcp_config`;其他工具会接收到这个字段但不会实际用。MCP 的专题会在后续版本展开。
|
||||
两者可以同时用。目前 Multica 的 MCP 支持是**按工具实现**的:Claude Code、Codex、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw 会消费 `mcp_config`;其他工具会接收到这个字段但不会实际用。MCP 的专题会在后续版本展开。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -105,7 +105,8 @@ Multica はタスク中にセッション ID を**2 回**固定します: 開始
|
||||
|
||||
ただし、**実際にどの AI コーディングツールがこれをサポートするか**は大きく異なります。
|
||||
|
||||
- ✅ **実際にサポート** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ✅ **実際にサポート** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **コードはあるが使用不可** — Codex, Cursor
|
||||
- ❌ **サポートなし** — Gemini
|
||||
|
||||
[プロバイダー対応表 → セッション再開](/providers#session-resumption-who-really-supports-it)を参照してください。
|
||||
|
||||
@@ -105,7 +105,8 @@ Multica는 작업 중에 세션 ID를 **두 번** 고정합니다: 시작 시
|
||||
|
||||
하지만 **실제로 어떤 AI 코딩 도구가 이를 지원하는지**는 크게 다릅니다:
|
||||
|
||||
- ✅ **실제 지원** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ✅ **실제 지원** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **코드는 있지만 사용 불가** — Codex, Cursor
|
||||
- ❌ **지원 안 함** — Gemini
|
||||
|
||||
[제공자 매트릭스 → 세션 재개](/providers#session-resumption-who-really-supports-it)를 참고하세요.
|
||||
|
||||
@@ -105,7 +105,8 @@ Multica pins the session ID **twice** during a task: once at the start (when the
|
||||
|
||||
But **which AI coding tools actually support this** varies a lot:
|
||||
|
||||
- ✅ **Real support** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ✅ **Real support** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **Code exists but unusable** — Codex, Cursor
|
||||
- ❌ **No support** — Gemini
|
||||
|
||||
See [Providers Matrix → Session resumption](/providers#session-resumption-who-really-supports-it).
|
||||
|
||||
@@ -42,8 +42,6 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
|
||||
|
||||
两种超时的失败原因都是 `timeout`,**会自动重试**(下一节)。关联的运行时失联判定见 [守护进程与运行时 → 运行时什么时候被判定为离线](/daemon-runtimes#运行时什么时候被判定为离线)。
|
||||
|
||||
上面这层是**服务端的粗粒度兜底**——按任务启动时间算,不看任务是否还在活动。真正区分「卡死」和「正常的长任务」的是**本地守护进程**:它不再用固定墙钟时长砍任务(`MULTICA_AGENT_TIMEOUT` 默认 `0` = 不设上限),而是看活动——只要 agent 还在持续产出事件(消息、工具调用),守护进程就不会因为跑得久判它超时(服务端那条 2.5h 仍是外层上限)。只有真正静默卡死时才会被**空闲看门狗**(`MULTICA_AGENT_IDLE_WATCHDOG`,默认 30 分钟)终止;如果是某个工具调用发出后长时间无任何输出(疑似卡死的子进程),则由更大的**工具看门狗**预算(`MULTICA_AGENT_TOOL_WATCHDOG`,默认 2 小时)兜底。这类被看门狗终止的任务失败原因是 `idle_watchdog`,和墙钟 `timeout` 区分开。各参数见 [环境变量 → 守护进程的调节参数](/environment-variables#守护进程的调节参数)。
|
||||
|
||||
## 哪些失败会自动重试,哪些不会
|
||||
|
||||
失败分两类:**可重试**和**不可重试**。
|
||||
@@ -107,7 +105,8 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
|
||||
|
||||
但**哪些 AI 编程工具真的支持**差别很大:
|
||||
|
||||
- ✅ **真支持**——Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
|
||||
- ✅ **真支持**——Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
|
||||
- ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor
|
||||
- ❌ **不支持**——Gemini
|
||||
|
||||
详见 [Providers Matrix → 会话恢复](/providers#会话恢复谁真的支持)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Agent Runs sheet — presented as a formSheet by the parent Stack. Two
|
||||
* sections: Active (queued/dispatched/running, created_at desc) and Past
|
||||
* (completed_at desc, status rank as tiebreaker). Empty
|
||||
* (failed → cancelled → completed, completed_at desc within each). Empty
|
||||
* sections hide entirely.
|
||||
*
|
||||
* Both entry points (the in-card AgentActivityRow and the Stack-header
|
||||
@@ -58,9 +58,9 @@ export default function IssueRunsRoute() {
|
||||
t.status === "cancelled",
|
||||
);
|
||||
return filtered.sort((a, b) => {
|
||||
const timeDiff = (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
|
||||
const ord = PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
|
||||
if (ord !== 0) return ord;
|
||||
return (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
|
||||
});
|
||||
}, [allTasks]);
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import { router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { WorkspaceAvatar } from "@/components/workspace/workspace-avatar";
|
||||
import { workspaceListOptions } from "@/data/queries/workspaces";
|
||||
import { useWorkspaceStore } from "@/data/workspace-store";
|
||||
import { useColorScheme } from "@/lib/use-color-scheme";
|
||||
@@ -46,12 +45,12 @@ export default function SwitchWorkspaceRoute() {
|
||||
const onSelect = (ws: Workspace) => {
|
||||
if (ws.slug === activeSlug) return;
|
||||
Alert.alert(
|
||||
"Switch workspace",
|
||||
`Switch to "${ws.name}"?`,
|
||||
"切换工作区",
|
||||
`确定切换到 "${ws.name}"?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "取消", style: "cancel" },
|
||||
{
|
||||
text: "Switch",
|
||||
text: "切换",
|
||||
onPress: () => {
|
||||
router.dismiss();
|
||||
router.replace(`/${ws.slug}/inbox`);
|
||||
@@ -65,7 +64,7 @@ export default function SwitchWorkspaceRoute() {
|
||||
<View className="flex-1">
|
||||
<View className="px-4 pt-4 pb-3">
|
||||
<Text className="text-base font-semibold text-foreground">
|
||||
Switch workspace
|
||||
切换工作区
|
||||
</Text>
|
||||
</View>
|
||||
{isLoading ? (
|
||||
@@ -81,6 +80,7 @@ export default function SwitchWorkspaceRoute() {
|
||||
active={ws.slug === activeSlug}
|
||||
onPress={() => onSelect(ws)}
|
||||
iconTint={t.foreground}
|
||||
mutedIconTint={t.mutedForeground}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
@@ -94,11 +94,13 @@ function WorkspaceRow({
|
||||
active,
|
||||
onPress,
|
||||
iconTint,
|
||||
mutedIconTint,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
iconTint: string;
|
||||
mutedIconTint: string;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
@@ -106,18 +108,18 @@ function WorkspaceRow({
|
||||
disabled={active}
|
||||
accessibilityLabel={
|
||||
active
|
||||
? `${workspace.name}, current workspace`
|
||||
: `Switch to ${workspace.name}`
|
||||
? `${workspace.name}, 当前工作区`
|
||||
: `切换到 ${workspace.name}`
|
||||
}
|
||||
className={cn(
|
||||
"flex-row items-center gap-3 px-4 py-3 active:bg-secondary",
|
||||
active && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<WorkspaceAvatar
|
||||
name={workspace.name}
|
||||
avatarUrl={workspace.avatar_url}
|
||||
size={24}
|
||||
<ExpoImage
|
||||
source="sf:building.2"
|
||||
tintColor={active ? iconTint : mutedIconTint}
|
||||
style={{ width: 18, height: 18 }}
|
||||
/>
|
||||
<Text
|
||||
className={cn(
|
||||
|
||||
@@ -47,7 +47,6 @@ const PRIORITY_LABEL: Record<IssuePriority, string> = {
|
||||
// Mirrors useTypeLabels in packages/views/inbox/components/inbox-detail-label.tsx
|
||||
const TYPE_LABEL: Record<InboxItemType, string> = {
|
||||
issue_assigned: "Assigned",
|
||||
issue_subscribed: "Subscribed",
|
||||
unassigned: "Unassigned",
|
||||
assignee_changed: "Reassigned",
|
||||
status_changed: "Status changed",
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { WorkspaceAvatar } from "@/components/workspace/workspace-avatar";
|
||||
import { workspaceListOptions } from "@/data/queries/workspaces";
|
||||
import { useAuthStore } from "@/data/auth-store";
|
||||
import { useWorkspaceStore } from "@/data/workspace-store";
|
||||
@@ -139,10 +138,10 @@ export function MoreTabDropdownAnchor({
|
||||
|
||||
<WorkspaceCard
|
||||
currentWorkspaceName={currentWorkspace?.name}
|
||||
currentWorkspaceAvatarUrl={currentWorkspace?.avatar_url}
|
||||
onPress={() =>
|
||||
slug && router.push(`/${slug}/switch-workspace`)
|
||||
}
|
||||
iconTint={t.foreground}
|
||||
chevronTint={t.mutedForeground}
|
||||
/>
|
||||
|
||||
@@ -248,13 +247,13 @@ function UserCard({
|
||||
*/
|
||||
function WorkspaceCard({
|
||||
currentWorkspaceName,
|
||||
currentWorkspaceAvatarUrl,
|
||||
onPress,
|
||||
iconTint,
|
||||
chevronTint,
|
||||
}: {
|
||||
currentWorkspaceName: string | undefined;
|
||||
currentWorkspaceAvatarUrl: string | null | undefined;
|
||||
onPress: () => void;
|
||||
iconTint: string;
|
||||
chevronTint: string;
|
||||
}) {
|
||||
const { data } = useQuery(workspaceListOptions());
|
||||
@@ -266,14 +265,16 @@ function WorkspaceCard({
|
||||
disabled={!canSwitch}
|
||||
className="h-12 gap-3"
|
||||
accessibilityLabel={
|
||||
canSwitch ? "Switch workspace" : currentWorkspaceName ?? "Workspace"
|
||||
canSwitch ? "切换工作区" : currentWorkspaceName ?? "Workspace"
|
||||
}
|
||||
>
|
||||
<WorkspaceAvatar
|
||||
name={currentWorkspaceName ?? "Workspace"}
|
||||
avatarUrl={currentWorkspaceAvatarUrl}
|
||||
size={32}
|
||||
/>
|
||||
<View className="size-8 rounded-md bg-muted items-center justify-center">
|
||||
<ExpoImage
|
||||
source="sf:building.2"
|
||||
tintColor={iconTint}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1 min-w-0">
|
||||
<Text
|
||||
className="text-sm font-medium text-foreground"
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Mobile WorkspaceAvatar. Mirrors packages/views/workspace/workspace-avatar.tsx:
|
||||
* a resolved avatar_url renders as a rounded-square logo image; otherwise the
|
||||
* workspace's initial letter sits in a muted tile. Same fallback semantics as
|
||||
* web/desktop so a workspace looks identical across clients (apps/mobile/CLAUDE.md
|
||||
* behavioral-parity rule).
|
||||
*
|
||||
* URL resolution goes through resolveAttachmentUrl — the mobile mirror of
|
||||
* core's resolvePublicFileUrl — because avatar_url comes back as a server-
|
||||
* relative path on self-hosted backends without a CDN signer, which RN's
|
||||
* <Image> can't load without an absolute origin.
|
||||
*
|
||||
* Both branches render a `border border-border` tile and thread `className`
|
||||
* through, matching web's <img>/<span> (both carry `border` + `className`).
|
||||
* The logo sits inside an overflow-hidden View rather than styling the
|
||||
* <ExpoImage> directly: NativeWind has no cssInterop for expo-image, so
|
||||
* className/border utilities are silently dropped on <ExpoImage>. The wrapper
|
||||
* View is how the rest of the app borders/rounds an expo-image — see
|
||||
* lib/markdown/markdown-image.tsx.
|
||||
*/
|
||||
import { View } from "react-native";
|
||||
import { Image as ExpoImage } from "expo-image";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { resolveAttachmentUrl } from "@/lib/attachment-url";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function WorkspaceAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
size = 24,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
avatarUrl: string | null | undefined;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const resolved = resolveAttachmentUrl(avatarUrl);
|
||||
const borderRadius = Math.round(size / 4);
|
||||
|
||||
if (resolved) {
|
||||
return (
|
||||
<View
|
||||
className={cn("overflow-hidden border border-border", className)}
|
||||
style={{ width: size, height: size, borderRadius }}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{ uri: resolved }}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={name}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn("items-center justify-center bg-muted border border-border", className)}
|
||||
style={{ width: size, height: size, borderRadius }}
|
||||
>
|
||||
<Text
|
||||
className="font-semibold text-muted-foreground"
|
||||
style={{ fontSize: Math.round(size * 0.48) }}
|
||||
>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ export const AttachmentSchema: z.ZodType<Attachment> = z.object({
|
||||
filename: z.string(),
|
||||
url: z.string(),
|
||||
download_url: z.string().default(""),
|
||||
markdown_url: z.string().default(""),
|
||||
content_type: z.string().default(""),
|
||||
size_bytes: z.number().default(0),
|
||||
created_at: z.string().default(""),
|
||||
@@ -89,7 +88,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 = {
|
||||
@@ -308,7 +306,6 @@ export const TaskMessagePayloadSchema: z.ZodType<TaskMessagePayload> = z.object(
|
||||
content: z.string().optional(),
|
||||
input: z.record(z.string(), z.unknown()).optional(),
|
||||
output: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
}).loose();
|
||||
|
||||
export const TaskMessageListSchema = z.array(TaskMessagePayloadSchema).default([]);
|
||||
@@ -664,7 +661,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: {},
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
import { deduplicateInboxItems } from "./inbox-display";
|
||||
|
||||
function item(overrides: Partial<InboxItem>): InboxItem {
|
||||
return {
|
||||
id: "inbox-1",
|
||||
workspace_id: "workspace-1",
|
||||
recipient_type: "member",
|
||||
recipient_id: "member-1",
|
||||
actor_type: "agent",
|
||||
actor_id: "agent-1",
|
||||
type: "new_comment",
|
||||
severity: "info",
|
||||
issue_id: "issue-1",
|
||||
title: "Issue title",
|
||||
body: null,
|
||||
issue_status: null,
|
||||
read: false,
|
||||
archived: false,
|
||||
created_at: "2026-06-15T08:00:00Z",
|
||||
details: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deduplicateInboxItems", () => {
|
||||
it("keeps the newest issue row while preserving an older comment anchor", () => {
|
||||
const merged = deduplicateInboxItems([
|
||||
item({
|
||||
id: "comment-notification",
|
||||
created_at: "2026-06-15T08:00:00Z",
|
||||
details: { comment_id: "comment-1" },
|
||||
}),
|
||||
item({
|
||||
id: "status-notification",
|
||||
type: "status_changed",
|
||||
created_at: "2026-06-15T08:01:00Z",
|
||||
details: { from: "in_progress", to: "in_review" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]).toMatchObject({
|
||||
id: "status-notification",
|
||||
type: "status_changed",
|
||||
details: {
|
||||
from: "in_progress",
|
||||
to: "in_review",
|
||||
comment_id: "comment-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -62,9 +62,7 @@ export function getInboxDisplayTitle(item: InboxItem): string {
|
||||
* 2. Group by `issue_id` (fall back to `id` for items with no issue
|
||||
* attached — e.g. quick_create_failed).
|
||||
* 3. In each group, keep the newest by `created_at`.
|
||||
* 4. Preserve the newest grouped `comment_id` anchor when the newest row
|
||||
* is a later status/metadata event for the same issue.
|
||||
* 5. Sort the result newest-first.
|
||||
* 4. Sort the result newest-first.
|
||||
*/
|
||||
export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
const active = items.filter((i) => !i.archived);
|
||||
@@ -81,22 +79,7 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
const newest = group[0];
|
||||
if (!newest) continue;
|
||||
|
||||
const commentId =
|
||||
newest.details?.comment_id ??
|
||||
group.find((item) => item.details?.comment_id)?.details?.comment_id;
|
||||
|
||||
if (commentId && newest.details?.comment_id !== commentId) {
|
||||
merged.push({
|
||||
...newest,
|
||||
details: { ...(newest.details ?? {}), comment_id: commentId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(newest);
|
||||
if (group[0]) merged.push(group[0]);
|
||||
}
|
||||
return merged.sort(
|
||||
(a, b) =>
|
||||
|
||||
@@ -125,18 +125,11 @@ function LoginPageContent() {
|
||||
router.push(await resolveLoggedInDestination(qc, onboarded, list));
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform, next URL, and CLI callback
|
||||
// params so the callback can redirect to the right place after login.
|
||||
// CLI callback/state must survive the Google OAuth round-trip so the
|
||||
// post-login callback page can redirect the JWT back to the CLI's local
|
||||
// HTTP listener (critical for headless / WSL2 environments).
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
// can redirect to the right place after login.
|
||||
const googleState = [
|
||||
platform === "desktop" ? "platform:desktop" : "",
|
||||
nextUrl ? `next:${nextUrl}` : "",
|
||||
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
|
||||
? `cli_callback:${encodeURIComponent(cliCallbackRaw)}`
|
||||
: "",
|
||||
cliState ? `cli_state:${encodeURIComponent(cliState)}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { WebNotificationBridge } from "@/components/web-notification-bridge";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -16,7 +15,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<WebNotificationBridge />
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -197,104 +197,6 @@ describe("CallbackPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to CLI callback with token when state contains valid cli_callback", async () => {
|
||||
const { api: mockedApi } = await import("@multica/core/api");
|
||||
const mockGoogleLogin = mockedApi.googleLogin as ReturnType<typeof vi.fn>;
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
|
||||
});
|
||||
|
||||
try {
|
||||
mockSearchParams.set(
|
||||
"state",
|
||||
"cli_callback:http://127.0.0.1:46233/callback,cli_state:abc123",
|
||||
);
|
||||
mockGoogleLogin.mockResolvedValue({ token: "cli-jwt-token" });
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGoogleLogin).toHaveBeenCalledWith(
|
||||
"test-code",
|
||||
expect.stringContaining("/auth/callback"),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:46233/callback?token=cli-jwt-token&state=abc123",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("falls through to normal web flow when state contains invalid cli_callback", async () => {
|
||||
mockSearchParams.set("state", "cli_callback:https://evil.com/callback");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
mockListMyInvitations.mockResolvedValue([]);
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Normal web flow: loginWithGoogle is called (not googleLogin)
|
||||
expect(mockLoginWithGoogle).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to CLI callback even when state also contains platform:desktop", async () => {
|
||||
// cli_callback takes precedence over platform:desktop — the CLI flow
|
||||
// is a specific user intent that should not be derailed by desktop flag.
|
||||
const { api: mockedApi } = await import("@multica/core/api");
|
||||
const mockGoogleLogin = mockedApi.googleLogin as ReturnType<typeof vi.fn>;
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
|
||||
});
|
||||
|
||||
try {
|
||||
mockSearchParams.set(
|
||||
"state",
|
||||
"platform:desktop,cli_callback:http://localhost:12345/callback,cli_state:mystate",
|
||||
);
|
||||
mockGoogleLogin.mockResolvedValue({ token: "mixed-jwt" });
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGoogleLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
"http://localhost:12345/callback?token=mixed-jwt&state=mystate",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("onboarded users with missing source land in the workspace; the source-backfill modal is mounted there", async () => {
|
||||
// Source attribution backfill is now an in-workspace modal — see
|
||||
// `<SourceBackfillModal />` mounted inside `DashboardLayout`. The
|
||||
|
||||
@@ -7,7 +7,6 @@ import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths, resolvePostAuthDestination } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import { validateCliCallback, redirectToCliCallback } from "@multica/views/auth";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -47,39 +46,9 @@ function CallbackContent() {
|
||||
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
|
||||
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
|
||||
|
||||
// CLI callback params — carried across the Google OAuth round-trip so
|
||||
// headless/WSL2 `multica login` can receive the JWT after browser-based
|
||||
// Google auth completes.
|
||||
const cliCallbackPart = stateParts.find((p) => p.startsWith("cli_callback:"));
|
||||
const cliStatePart = stateParts.find((p) => p.startsWith("cli_state:"));
|
||||
const cliCallbackRaw = cliCallbackPart
|
||||
? decodeURIComponent(cliCallbackPart.slice("cli_callback:".length))
|
||||
: null;
|
||||
const cliState = cliStatePart
|
||||
? decodeURIComponent(cliStatePart.slice("cli_state:".length))
|
||||
: "";
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
// Validate the CLI callback URL before redirecting — the state parameter
|
||||
// passes through Google OAuth and must be treated as attacker-controlled.
|
||||
const cliCallback =
|
||||
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
|
||||
? cliCallbackRaw
|
||||
: null;
|
||||
|
||||
if (cliCallback) {
|
||||
// CLI login flow: exchange the Google code for a JWT, then redirect the
|
||||
// token back to the CLI's local HTTP listener (e.g. WSL2 host).
|
||||
api
|
||||
.googleLogin(code, redirectUri)
|
||||
.then(({ token }) => {
|
||||
redirectToCliCallback(cliCallback, token, cliState);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
});
|
||||
} else if (isDesktop) {
|
||||
if (isDesktop) {
|
||||
// Desktop flow: exchange code for token, then redirect via deep link
|
||||
api
|
||||
.googleLogin(code, redirectUri)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { captureException } from "@multica/core/analytics";
|
||||
|
||||
/**
|
||||
* Route-level error boundary for the web app. Next.js renders this (replacing
|
||||
* the root layout) when an error escapes everything below it — the full-page
|
||||
* white-screen case. React catches these before they reach window.onerror, so
|
||||
* posthog-js's automatic exception capture never sees them; we report them
|
||||
* explicitly here. Section-level failures are handled in place by
|
||||
* `@multica/ui` ErrorBoundary and don't reach this far.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
captureException(error, { source: "global-error", digest: error.digest });
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body
|
||||
style={{
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 420, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</h1>
|
||||
<p style={{ marginTop: 8, color: "#666" }}>
|
||||
The page hit an unexpected error. Try reloading.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "8px 16px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #ccc",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// Mutable pathname + a spy for the shared capture helper. The tracker reads
|
||||
// usePathname() and forwards it to capturePageview; section-normalization and
|
||||
// dedup live in @multica/core/analytics and are unit-tested there, so here we
|
||||
// only assert the wiring (which path is forwarded, and that the query string
|
||||
// never re-triggers the effect).
|
||||
const { state, capturePageview } = vi.hoisted(() => ({
|
||||
state: { pathname: "/" as string | null },
|
||||
capturePageview: vi.fn<(path?: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => state.pathname,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/analytics", () => ({
|
||||
capturePageview,
|
||||
}));
|
||||
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
beforeEach(() => {
|
||||
state.pathname = "/";
|
||||
capturePageview.mockClear();
|
||||
});
|
||||
|
||||
describe("web PageviewTracker", () => {
|
||||
it("captures the pathname on mount and on each pathname change", () => {
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
expect(capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(capturePageview).toHaveBeenLastCalledWith("/");
|
||||
|
||||
state.pathname = "/acme/issues";
|
||||
rerender(<PageviewTracker />);
|
||||
expect(capturePageview).toHaveBeenCalledTimes(2);
|
||||
expect(capturePageview).toHaveBeenLastCalledWith("/acme/issues");
|
||||
});
|
||||
|
||||
it("does not re-capture on a query-string-only navigation", () => {
|
||||
state.pathname = "/acme/issues";
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
expect(capturePageview).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A filter/sort/search change alters only the query string, which the
|
||||
// tracker no longer reads — usePathname() is unchanged so the effect's
|
||||
// dependency does not change and no new pageview fires.
|
||||
rerender(<PageviewTracker />);
|
||||
expect(capturePageview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user