mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 19:09:27 +02:00
Compare commits
92 Commits
fix/header
...
agent/j/4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0721cf935 | ||
|
|
8dfe3328e2 | ||
|
|
93028d303b | ||
|
|
506f2df7ad | ||
|
|
3c61f729d4 | ||
|
|
81291e334e | ||
|
|
59cb534e87 | ||
|
|
f892e03e41 | ||
|
|
d970b68ce7 | ||
|
|
5d79696fb5 | ||
|
|
de7f3cb9e3 | ||
|
|
b336f07617 | ||
|
|
10b33b14f5 | ||
|
|
9f1766cdb3 | ||
|
|
2b940046d7 | ||
|
|
f59cb2f494 | ||
|
|
d2bc85e01a | ||
|
|
63eb6f73ad | ||
|
|
c2e8892194 | ||
|
|
5206d7c613 | ||
|
|
e444698a09 | ||
|
|
658e63d9be | ||
|
|
11a3cf206b | ||
|
|
6e2d2c003c | ||
|
|
4fb6c0fb0e | ||
|
|
0c2f93bcd1 | ||
|
|
78d668a2f2 | ||
|
|
e2103a240d | ||
|
|
ff0979008b | ||
|
|
24754f091b | ||
|
|
37d9fafda6 | ||
|
|
a252f47337 | ||
|
|
bbf758e1af | ||
|
|
256a0a9b27 | ||
|
|
8c84415864 | ||
|
|
3692b6a862 | ||
|
|
9c1d8d2659 | ||
|
|
6dcf82a58a | ||
|
|
8d0ea04fb0 | ||
|
|
714f9b1ab7 | ||
|
|
553419f8ef | ||
|
|
73b9a41260 | ||
|
|
cd6cd9dcd1 | ||
|
|
9e807efc62 | ||
|
|
54145ad72e | ||
|
|
f1e6c18e3e | ||
|
|
0d8df7032c | ||
|
|
f0d6d88069 | ||
|
|
87ddbde316 | ||
|
|
8ff312dbe9 | ||
|
|
7d0c73d11f | ||
|
|
8e7d28bff1 | ||
|
|
35e5455953 | ||
|
|
adddfbdf89 | ||
|
|
33967611b2 | ||
|
|
93ed3dc131 | ||
|
|
aa4478af52 | ||
|
|
cb6616f530 | ||
|
|
57d1a0a00f | ||
|
|
65ce228e10 | ||
|
|
f4dba5d6b0 | ||
|
|
b71d9d0ab9 | ||
|
|
d9bf4b85c9 | ||
|
|
0d3b49f2c7 | ||
|
|
a03055b07d | ||
|
|
bea028784a | ||
|
|
343ace89a7 | ||
|
|
dfa384ffa2 | ||
|
|
5e824a94ae | ||
|
|
a6cf4cb46a | ||
|
|
34bd115808 | ||
|
|
3adfaf4285 | ||
|
|
a66f7ce8b1 | ||
|
|
c3d8529ba5 | ||
|
|
9db80a0940 | ||
|
|
3e21e58df0 | ||
|
|
4d7111d396 | ||
|
|
20eecfb093 | ||
|
|
1ac3a03e5d | ||
|
|
79c9158097 | ||
|
|
cb7cc82ecb | ||
|
|
76c58a4ee8 | ||
|
|
af34b8f83a | ||
|
|
189f95fabb | ||
|
|
8ad673fdb7 | ||
|
|
b92e4a53fb | ||
|
|
4a8210912a | ||
|
|
a7908e6967 | ||
|
|
00b9668cd2 | ||
|
|
ce28d0aa0e | ||
|
|
3103ed1082 | ||
|
|
4064b164be |
26
.env.example
26
.env.example
@@ -71,6 +71,28 @@ MULTICA_CODEX_MODEL=
|
||||
MULTICA_CODEX_WORKDIR=
|
||||
MULTICA_CODEX_TIMEOUT=20m
|
||||
|
||||
# Feature flags
|
||||
# Optional path to a YAML file declaring feature flag rules. When unset,
|
||||
# every flag falls through to the caller's default, which lets the server
|
||||
# boot before any flag config is authored. When set, the file is read once
|
||||
# at startup and a parse / IO error fails fast — same loud-failure shape as
|
||||
# DATABASE_URL or JWT_SECRET misconfig. See docs/feature-flags.md for the
|
||||
# full schema; the minimum example is:
|
||||
#
|
||||
# billing_new_invoice_email:
|
||||
# default: true
|
||||
# checkout_algo:
|
||||
# default: false
|
||||
# variant: experiment-v2
|
||||
# percent: { percent: 25, by: user_id }
|
||||
#
|
||||
# Individual flags can also be overridden without touching the YAML by
|
||||
# setting FF_<FLAG_KEY> env vars (FF_BILLING_NEW_INVOICE_EMAIL=false, 25%,
|
||||
# or any variant string). The env override beats the YAML, which is the
|
||||
# Ops kill-switch path — flip a flag without redeploying by restarting the
|
||||
# process with the env var set.
|
||||
MULTICA_FEATURE_FLAGS_FILE=
|
||||
|
||||
# Self-host image channel
|
||||
# Default stable release channel. Pin to an exact release like v0.2.4 if you
|
||||
# want to stay on a specific version. If the selected tag has not been
|
||||
@@ -233,6 +255,10 @@ MULTICA_LARK_SECRET_KEY=
|
||||
# clear these afterwards. See docs/lark-bot-integration.
|
||||
MULTICA_LARK_HTTP_BASE_URL=
|
||||
MULTICA_LARK_CALLBACK_BASE_URL=
|
||||
# Optional fixed HTTP CONNECT proxy URL for Lark/Feishu WebSocket long-conn
|
||||
# handshakes. Leave empty to use standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY
|
||||
# environment handling.
|
||||
MULTICA_LARK_WS_PROXY_URL=
|
||||
|
||||
# Frontend
|
||||
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
|
||||
|
||||
76
.github/workflows/ci.yml
vendored
76
.github/workflows/ci.yml
vendored
@@ -11,28 +11,99 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
# Decides whether the (heavy, ~6min) frontend job has anything to do.
|
||||
# The frontend job validates the web/desktop apps, the shared packages,
|
||||
# the install graph, and the selfhost / reserved-slugs scripts it runs;
|
||||
# a pure backend-only or docs-only PR touches none of those and gains
|
||||
# nothing from a full web build. This job emits a single `frontend`
|
||||
# output consumed by the frontend job below.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
frontend: ${{ steps.decide.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Filter paths
|
||||
id: filter
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
# apps/docs is excluded from the frontend turbo run, so a
|
||||
# docs-only change does not need this job. apps/mobile has its
|
||||
# own mobile-verify workflow. Everything else the frontend job
|
||||
# touches is listed here; bias toward over-matching since a
|
||||
# missed path silently skips validation.
|
||||
filters: |
|
||||
frontend:
|
||||
- 'apps/web/**'
|
||||
- 'apps/desktop/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- '.npmrc'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'scripts/generate-reserved-slugs.mjs'
|
||||
- 'server/internal/handler/reserved_slugs.json'
|
||||
- 'scripts/selfhost-config.test.sh'
|
||||
- 'scripts/check.sh'
|
||||
- 'scripts/dev.sh'
|
||||
- 'scripts/local-env.sh'
|
||||
- '.env.example'
|
||||
- 'docker-compose.selfhost.yml'
|
||||
|
||||
- name: Decide
|
||||
id: decide
|
||||
# Always run the frontend job on push to main (full validation);
|
||||
# on pull_request, run only when frontend-relevant paths changed.
|
||||
# The frontend job itself always runs and reports success — its
|
||||
# steps are gated on this output rather than the job being skipped
|
||||
# — so the required "frontend" status check is satisfied with a
|
||||
# genuine green instead of being left pending on filtered PRs.
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
FRONTEND_CHANGED: ${{ steps.filter.outputs.frontend }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ] || [ "$FRONTEND_CHANGED" = "true" ]; then
|
||||
echo "frontend=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "frontend=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
frontend:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
run: pnpm install
|
||||
|
||||
- name: Test self-host env derivation
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
run: bash scripts/selfhost-config.test.sh
|
||||
|
||||
- name: Verify reserved-slugs.ts is up to date
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
# Re-runs the generator and fails on any drift from the
|
||||
# checked-in TypeScript output. The Go side embeds the JSON
|
||||
# source directly, so a passing diff here proves both sides
|
||||
@@ -42,8 +113,9 @@ jobs:
|
||||
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
|
||||
|
||||
- name: Build, type check, lint, and test
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
# Mobile lives in a parallel mobile-verify workflow (path-filtered
|
||||
# to apps/mobile/** + packages/core/types/**) so it doesn't add
|
||||
# to apps/mobile/** + packages/core/**) so it doesn't add
|
||||
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
|
||||
# filter in sync with the root package.json scripts, which also
|
||||
# exclude @multica/mobile.
|
||||
|
||||
@@ -90,7 +90,7 @@ pnpm exec playwright test
|
||||
pnpm ui:add badge # shadcn/Base UI component into packages/ui
|
||||
```
|
||||
|
||||
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`.
|
||||
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`. `pnpm dev:desktop` additionally self-isolates per worktree (its own renderer port + app name) automatically, independent of `.env.worktree`.
|
||||
|
||||
CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.
|
||||
|
||||
|
||||
@@ -489,6 +489,25 @@ VITE_API_URL=http://localhost:<backend-port>
|
||||
VITE_WS_URL=ws://localhost:<backend-port>/ws
|
||||
```
|
||||
|
||||
#### Running multiple worktrees side-by-side
|
||||
|
||||
`pnpm dev:desktop` auto-isolates a worktree so several worktrees can run their
|
||||
own desktop dev instance at once — no extra setup. From a linked worktree it
|
||||
derives, from the worktree path (same `cksum % 1000` offset as the backend /
|
||||
frontend ports in `.env.worktree`):
|
||||
|
||||
- `DESKTOP_RENDERER_PORT` = `5174 + offset` — its own Vite dev server (`5174`
|
||||
base leaves `5173` for the primary checkout, even when `offset` is `0`)
|
||||
- `DESKTOP_APP_SUFFIX` = `<folder>-<offset>` — its own single-instance lock /
|
||||
`userData`, and an app named `Multica Canary <folder>-<offset>` so it is
|
||||
distinguishable in Cmd+Tab. The offset keeps it unique across worktrees that
|
||||
share a folder name at different paths.
|
||||
|
||||
The primary checkout is left untouched (`5173`, `Multica Canary`). Set either
|
||||
env var explicitly to override the derived value. Which backend each instance
|
||||
talks to is still controlled only by `apps/desktop/.env*` above — point each
|
||||
worktree's desktop at its own backend to also isolate the daemon profile.
|
||||
|
||||
### Isolation Guarantee
|
||||
|
||||
Nothing in this flow touches the system-installed `multica` or the default
|
||||
|
||||
38
Makefile
38
Makefile
@@ -37,6 +37,29 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# Self-hosting requires the Docker Compose CLI plugin (`docker compose`).
|
||||
# The self-host compose files use compose-spec syntax (top-level `name:`, no
|
||||
# `version:`) that the legacy v1 `docker-compose` standalone cannot parse, so we
|
||||
# fail early with an actionable message instead of a cryptic CLI parse error
|
||||
# (e.g. "unknown shorthand flag: 'f' in -f") when the plugin is missing or v1.
|
||||
# Keep the message short and OS-agnostic: per-OS install steps belong in docs.
|
||||
define REQUIRE_COMPOSE
|
||||
@if ! compose_version=$$($(COMPOSE) version --short 2>/dev/null); then \
|
||||
echo "Docker Compose ('docker compose') was not found."; \
|
||||
echo "Self-hosting requires the Compose CLI plugin; legacy 'docker-compose' v1 is not supported."; \
|
||||
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
case "$$compose_version" in \
|
||||
1.*|v1.*) \
|
||||
echo "'$(COMPOSE)' is legacy Docker Compose v1 ($$compose_version)."; \
|
||||
echo "Self-hosting requires the Compose CLI plugin; legacy 'docker-compose' v1 is not supported."; \
|
||||
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac
|
||||
endef
|
||||
|
||||
# Default target changed from selfhost to help: bare `make` now prints this help
|
||||
# instead of launching a full Docker Compose build, which is safer for onboarding.
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -54,6 +77,7 @@ makehelp: help ## Alias for `make help`
|
||||
##@ Self-hosting
|
||||
|
||||
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
|
||||
$(REQUIRE_COMPOSE)
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
@@ -71,7 +95,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
|
||||
fi
|
||||
@echo "==> Pulling official Multica images..."
|
||||
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
|
||||
@if ! $(COMPOSE) -f docker-compose.selfhost.yml pull; then \
|
||||
echo ""; \
|
||||
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
|
||||
echo "If this is before the first GHCR release, build from the current checkout:"; \
|
||||
@@ -79,7 +103,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "==> Starting Multica via Docker Compose..."
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
$(COMPOSE) -f docker-compose.selfhost.yml up -d
|
||||
@echo "==> Waiting for backend to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
@@ -105,10 +129,11 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
else \
|
||||
echo ""; \
|
||||
echo "Services are still starting. Check logs:"; \
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
|
||||
$(REQUIRE_COMPOSE)
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
@@ -126,7 +151,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
|
||||
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
|
||||
fi
|
||||
@echo "==> Building Multica from the current checkout..."
|
||||
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
|
||||
$(COMPOSE) -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
|
||||
@echo "==> Waiting for backend to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
@@ -152,12 +177,13 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
|
||||
else \
|
||||
echo ""; \
|
||||
echo "Services are still starting. Check logs:"; \
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
selfhost-stop: ## Stop the self-hosted Docker Compose stack
|
||||
$(REQUIRE_COMPOSE)
|
||||
@echo "==> Stopping Multica services..."
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
$(COMPOSE) -f docker-compose.selfhost.yml down
|
||||
@echo "✓ All services stopped."
|
||||
|
||||
# ---------- One-click commands ----------
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"scripts": {
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
|
||||
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
|
||||
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:staging": "node scripts/dev.mjs --mode staging",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
// matches. The patch is isolated to this worktree's node_modules — we
|
||||
// unlink the file before rewriting so we never mutate a pnpm-store inode
|
||||
// shared with another project.
|
||||
//
|
||||
// In a worktree, scripts/dev.mjs sets DESKTOP_APP_SUFFIX so the name becomes
|
||||
// "Multica Canary <suffix>" — distinguishable in Cmd+Tab and matching the app
|
||||
// name src/main/index.ts derives from the same env var.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -17,7 +21,9 @@ import { resolve } from "node:path";
|
||||
|
||||
if (process.platform !== "darwin") process.exit(0);
|
||||
|
||||
const DESIRED_NAME = "Multica Canary";
|
||||
const DESIRED_NAME = process.env.DESKTOP_APP_SUFFIX
|
||||
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
|
||||
: "Multica Canary";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// `require('electron')` returns the path to the executable
|
||||
|
||||
53
apps/desktop/scripts/dev.mjs
Normal file
53
apps/desktop/scripts/dev.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
// Dev launcher for `pnpm dev:desktop`.
|
||||
//
|
||||
// Derives per-worktree isolation env (renderer port + app name) so multiple
|
||||
// worktrees can run `pnpm dev:desktop` side-by-side, then runs the same chain
|
||||
// as before — bundle the CLI, brand the dev Electron, start electron-vite —
|
||||
// inheriting the augmented env. A plain `&&` chain in package.json can't do
|
||||
// this: each `&&` step is its own process, so an env tweak in step 1 wouldn't
|
||||
// reach electron-vite in step 3. Args (e.g. `--mode staging`) pass through to
|
||||
// electron-vite.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
applyWorktreeDevEnv,
|
||||
repoRootFromScriptDir,
|
||||
} from "./worktree-dev-env.mjs";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
applyWorktreeDevEnv(process.env, {
|
||||
root: repoRootFromScriptDir(here),
|
||||
log: true,
|
||||
});
|
||||
|
||||
function run(command, args, { shell = false } = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell,
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`[dev:desktop] failed to run ${command}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const node = process.execPath;
|
||||
run(node, [join(here, "bundle-cli.mjs")]);
|
||||
run(node, [join(here, "brand-dev-electron.mjs")]);
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const electronVite = join(
|
||||
here,
|
||||
"..",
|
||||
"node_modules",
|
||||
".bin",
|
||||
isWin ? "electron-vite.cmd" : "electron-vite",
|
||||
);
|
||||
run(electronVite, ["dev", ...process.argv.slice(2)], { shell: isWin });
|
||||
116
apps/desktop/scripts/worktree-dev-env.mjs
Normal file
116
apps/desktop/scripts/worktree-dev-env.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
// Per-worktree dev isolation for `pnpm dev:desktop`.
|
||||
//
|
||||
// Two `pnpm dev:desktop` instances from two different git worktrees collide on
|
||||
// the renderer Vite port (5173) and the single-instance lock / userData dir
|
||||
// (keyed by the app name "Multica Canary"). The env hooks to override both
|
||||
// already exist — electron.vite.config.ts reads DESKTOP_RENDERER_PORT and
|
||||
// src/main/index.ts reads DESKTOP_APP_SUFFIX — but nothing derives unique
|
||||
// values per worktree. This module does, mirroring the offset scheme that
|
||||
// scripts/init-worktree-env.sh already uses for backend/frontend ports.
|
||||
//
|
||||
// Backend targeting is deliberately NOT touched here: which backend the desktop
|
||||
// connects to stays driven by apps/desktop/.env* (VITE_API_URL / VITE_WS_URL),
|
||||
// exactly as documented. This module only adds the two knobs needed for two
|
||||
// Electron processes to coexist.
|
||||
|
||||
import { statSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
// Worktree renderer ports start at 5174 so they never reuse 5173 — the primary
|
||||
// checkout's default — even when a worktree's offset is 0 (e.g. POSIX cksum of
|
||||
// "/tmp/multica-3494" is 1189739000, and 1189739000 % 1000 === 0). Range 5174–6173.
|
||||
const RENDERER_PORT_BASE = 5174;
|
||||
const OFFSET_MODULO = 1000;
|
||||
|
||||
// POSIX cksum (CRC-32), kept byte-compatible with `cksum(1)` so the offset
|
||||
// matches scripts/init-worktree-env.sh — a worktree's backend (18080+offset),
|
||||
// frontend (13000+offset) and desktop renderer (5174+offset) ports all share
|
||||
// one offset. Verified against coreutils: cksum of "/tmp/foo" → 427878967.
|
||||
function cksumTable() {
|
||||
const table = new Uint32Array(256);
|
||||
const POLY = 0x04c11db7;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let crc = i << 24;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
crc = crc & 0x80000000 ? (crc << 1) ^ POLY : crc << 1;
|
||||
}
|
||||
table[i] = crc >>> 0;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
const TABLE = cksumTable();
|
||||
|
||||
export function cksum(buf) {
|
||||
let crc = 0;
|
||||
for (const byte of buf) {
|
||||
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ byte) & 0xff]) >>> 0;
|
||||
}
|
||||
// POSIX appends the byte length, least-significant byte first.
|
||||
let len = buf.length;
|
||||
while (len > 0) {
|
||||
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ (len & 0xff)) & 0xff]) >>> 0;
|
||||
len = Math.floor(len / 256);
|
||||
}
|
||||
return (~crc) >>> 0;
|
||||
}
|
||||
|
||||
export function offsetForPath(path) {
|
||||
return cksum(Buffer.from(path)) % OFFSET_MODULO;
|
||||
}
|
||||
|
||||
export function rendererPortForPath(path) {
|
||||
return RENDERER_PORT_BASE + offsetForPath(path);
|
||||
}
|
||||
|
||||
// Worktree → a readable, unique, filesystem-safe suffix "<folder>-<offset>".
|
||||
// The dev app then shows e.g. "Multica Canary mul-3724-194" in Cmd+Tab and gets
|
||||
// its own userData / single-instance lock under that name. The offset is what
|
||||
// makes the lock unique: the folder name alone collides for worktrees that share
|
||||
// a basename at different paths (e.g. /a/multica vs /b/multica) or whose names
|
||||
// slug to the same fallback — those would share one lock and the second Electron
|
||||
// would still be blocked.
|
||||
export function appSuffixForPath(path) {
|
||||
const slug =
|
||||
basename(path)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "worktree";
|
||||
return `${slug}-${offsetForPath(path)}`;
|
||||
}
|
||||
|
||||
// A linked git worktree has a `.git` FILE (a "gitdir:" pointer); the primary
|
||||
// checkout has a `.git` DIRECTORY. We only auto-isolate linked worktrees, so
|
||||
// the primary checkout keeps the unchanged 5173 / "Multica Canary" defaults.
|
||||
export function isLinkedWorktree(root) {
|
||||
try {
|
||||
return statSync(join(root, ".git")).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// scripts live at <root>/apps/desktop/scripts
|
||||
export function repoRootFromScriptDir(scriptDir) {
|
||||
return join(scriptDir, "..", "..", "..");
|
||||
}
|
||||
|
||||
// Populate DESKTOP_RENDERER_PORT / DESKTOP_APP_SUFFIX on `env` for a worktree
|
||||
// checkout, without overriding values the caller set explicitly. Returns `env`.
|
||||
export function applyWorktreeDevEnv(env, { root, log = false } = {}) {
|
||||
const hasPort = Boolean(env.DESKTOP_RENDERER_PORT);
|
||||
const hasSuffix = Boolean(env.DESKTOP_APP_SUFFIX);
|
||||
if (hasPort && hasSuffix) return env; // explicit overrides win outright
|
||||
if (!isLinkedWorktree(root)) return env; // primary checkout → keep defaults
|
||||
|
||||
if (!hasPort) env.DESKTOP_RENDERER_PORT = String(rendererPortForPath(root));
|
||||
if (!hasSuffix) env.DESKTOP_APP_SUFFIX = appSuffixForPath(root);
|
||||
|
||||
if (log) {
|
||||
console.log(
|
||||
`[dev:desktop] worktree isolation → renderer port ${env.DESKTOP_RENDERER_PORT}, ` +
|
||||
`app "Multica Canary ${env.DESKTOP_APP_SUFFIX}"`,
|
||||
);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
101
apps/desktop/scripts/worktree-dev-env.test.mjs
Normal file
101
apps/desktop/scripts/worktree-dev-env.test.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appSuffixForPath,
|
||||
applyWorktreeDevEnv,
|
||||
cksum,
|
||||
offsetForPath,
|
||||
rendererPortForPath,
|
||||
} from "./worktree-dev-env.mjs";
|
||||
|
||||
const cleanups = [];
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()();
|
||||
});
|
||||
|
||||
function tmpRoot(kind /* "file" | "dir" | "none" */) {
|
||||
const root = mkdtempSync(join(tmpdir(), "wt-"));
|
||||
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
if (kind === "file") writeFileSync(join(root, ".git"), "gitdir: /elsewhere\n");
|
||||
else if (kind === "dir") mkdirSync(join(root, ".git"));
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("worktree-dev-env", () => {
|
||||
it("cksum is byte-compatible with coreutils cksum(1)", () => {
|
||||
// `printf '%s' "/tmp/foo" | cksum` → 427878967 8
|
||||
expect(cksum(Buffer.from("/tmp/foo"))).toBe(427878967);
|
||||
// `printf '' | cksum` → 4294967295 0
|
||||
expect(cksum(Buffer.from(""))).toBe(4294967295);
|
||||
});
|
||||
|
||||
it("derives the offset from the path, mod 1000", () => {
|
||||
expect(offsetForPath("/tmp/foo")).toBe(427878967 % 1000);
|
||||
});
|
||||
|
||||
it("renderer port is 5174 + offset (5173 reserved for the primary checkout)", () => {
|
||||
expect(rendererPortForPath("/tmp/foo")).toBe(5174 + (427878967 % 1000));
|
||||
});
|
||||
|
||||
it("never reuses 5173 even when the offset is 0", () => {
|
||||
// POSIX cksum("/tmp/multica-3494") === 1189739000, % 1000 === 0
|
||||
expect(offsetForPath("/tmp/multica-3494")).toBe(0);
|
||||
expect(rendererPortForPath("/tmp/multica-3494")).toBe(5174);
|
||||
expect(rendererPortForPath("/tmp/multica-3494")).not.toBe(5173);
|
||||
});
|
||||
|
||||
it("suffix is '<folder>-<offset>' so it stays recognizable and unique", () => {
|
||||
expect(appSuffixForPath("/work/MUL-3724_Desktop")).toBe(
|
||||
`mul-3724-desktop-${offsetForPath("/work/MUL-3724_Desktop")}`,
|
||||
);
|
||||
expect(appSuffixForPath("/work/feat/some thing")).toBe(
|
||||
`some-thing-${offsetForPath("/work/feat/some thing")}`,
|
||||
);
|
||||
// empty/non-ascii slug falls back to "worktree", still disambiguated by offset
|
||||
expect(appSuffixForPath("/work/___")).toBe(`worktree-${offsetForPath("/work/___")}`);
|
||||
});
|
||||
|
||||
it("disambiguates worktrees that share a folder name at different paths", () => {
|
||||
// Same basename "multica", different parent dirs → different offsets/suffixes,
|
||||
// so each gets its own single-instance lock.
|
||||
expect(offsetForPath("/tmp/a/multica")).not.toBe(offsetForPath("/tmp/b/multica"));
|
||||
expect(appSuffixForPath("/tmp/a/multica")).not.toBe(
|
||||
appSuffixForPath("/tmp/b/multica"),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-isolates a linked worktree (.git is a file)", () => {
|
||||
const root = tmpRoot("file");
|
||||
const env = {};
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBe(String(rendererPortForPath(root)));
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
|
||||
});
|
||||
|
||||
it("leaves the primary checkout untouched (.git is a dir)", () => {
|
||||
const root = tmpRoot("dir");
|
||||
const env = {};
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBeUndefined();
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects explicit env overrides", () => {
|
||||
const root = tmpRoot("file");
|
||||
const env = { DESKTOP_RENDERER_PORT: "9999", DESKTOP_APP_SUFFIX: "manual" };
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBe("manual");
|
||||
});
|
||||
|
||||
it("fills only the missing knob when one is set explicitly", () => {
|
||||
const root = tmpRoot("file");
|
||||
const env = { DESKTOP_RENDERER_PORT: "9999" };
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import type { Metadata } from "next";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
|
||||
import { VideoEmbed } from "@/components/video-embed";
|
||||
import { docsSlugStaticParams } from "@/lib/static-params";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
@@ -35,7 +36,9 @@ export default async function Page(props: {
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<DocsLocaleProvider lang={lang}>
|
||||
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
|
||||
<MDX
|
||||
components={{ ...defaultMdxComponents, a: LocaleLink, VideoEmbed }}
|
||||
/>
|
||||
</DocsLocaleProvider>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
|
||||
@@ -5,6 +5,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
import { DocsHero } from "@/components/hero";
|
||||
import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/components/editorial";
|
||||
import { VideoEmbed } from "@/components/video-embed";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { homeCopy } from "@/lib/translations";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
@@ -62,6 +63,7 @@ export default async function Page({
|
||||
NumberedCard,
|
||||
NumberedSteps,
|
||||
Step,
|
||||
VideoEmbed,
|
||||
}}
|
||||
/>
|
||||
</DocsLocaleProvider>
|
||||
|
||||
116
apps/docs/components/video-embed.tsx
Normal file
116
apps/docs/components/video-embed.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
/**
|
||||
* VideoEmbed — provider-agnostic, click-to-load video embed for docs MDX.
|
||||
*
|
||||
* Renders a lightweight facade (no third-party iframe on first paint) and only
|
||||
* mounts the real player after a user click, so the docs first paint never
|
||||
* pays for an external player or its trackers. `provider` is abstracted so a
|
||||
* future English-docs YouTube embed is a one-line MDX change, not a second
|
||||
* component.
|
||||
*
|
||||
* Usage in MDX (registered in the docs MDX components map):
|
||||
* <VideoEmbed provider="bilibili" id="BV1cv7Y6gEg7" title="Multica 介绍视频" />
|
||||
*/
|
||||
|
||||
type Provider = "bilibili" | "youtube";
|
||||
|
||||
interface ProviderConfig {
|
||||
/** Embeddable player URL. Autoplay is only requested after a user gesture. */
|
||||
embedUrl: (id: string, autoplay: boolean) => string;
|
||||
/** Canonical watch page — the load-failure / slow-network fallback link. */
|
||||
watchUrl: (id: string) => string;
|
||||
/** Human label for the fallback link ("在 Bilibili 观看"). */
|
||||
siteName: string;
|
||||
/** Validates the id shape so a typo renders a notice, not a broken frame. */
|
||||
isValidId: (id: string) => boolean;
|
||||
}
|
||||
|
||||
const PROVIDERS: Record<Provider, ProviderConfig> = {
|
||||
bilibili: {
|
||||
embedUrl: (id, autoplay) =>
|
||||
`https://player.bilibili.com/player.html?bvid=${id}&autoplay=${autoplay ? 1 : 0}&high_quality=1&danmaku=0`,
|
||||
watchUrl: (id) => `https://www.bilibili.com/video/${id}/`,
|
||||
siteName: "Bilibili",
|
||||
isValidId: (id) => /^BV[0-9A-Za-z]+$/.test(id),
|
||||
},
|
||||
// Reserved for a future English-docs YouTube embed. Not wired into any page
|
||||
// yet, but kept here so the second provider is config, not a new component.
|
||||
youtube: {
|
||||
embedUrl: (id, autoplay) =>
|
||||
`https://www.youtube-nocookie.com/embed/${id}?autoplay=${autoplay ? 1 : 0}&rel=0`,
|
||||
watchUrl: (id) => `https://www.youtube.com/watch?v=${id}`,
|
||||
siteName: "YouTube",
|
||||
isValidId: (id) => /^[0-9A-Za-z_-]{11}$/.test(id),
|
||||
},
|
||||
};
|
||||
|
||||
export function VideoEmbed({
|
||||
provider = "bilibili",
|
||||
id,
|
||||
title,
|
||||
}: {
|
||||
provider?: Provider;
|
||||
id: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const [active, setActive] = useState(false);
|
||||
const config = PROVIDERS[provider];
|
||||
|
||||
// Bad / missing id → a calm inline notice, never a broken or blank iframe.
|
||||
if (!config || !id || !config.isValidId(id)) {
|
||||
return (
|
||||
<div className="not-prose my-7 rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
视频暂时无法加载{title ? `:${title}` : ""}。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const watchUrl = config.watchUrl(id);
|
||||
const label = title ?? "观看视频";
|
||||
|
||||
return (
|
||||
<figure className="not-prose my-7">
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg border border-border bg-muted/40">
|
||||
{active ? (
|
||||
<iframe
|
||||
src={config.embedUrl(id, true)}
|
||||
title={label}
|
||||
loading="lazy"
|
||||
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="absolute inset-0 size-full"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(true)}
|
||||
aria-label={`播放:${label}`}
|
||||
className="group absolute inset-0 flex size-full flex-col items-center justify-center gap-3 bg-gradient-to-b from-muted/20 to-muted/60 transition-colors hover:from-muted/30 hover:to-muted/70"
|
||||
>
|
||||
<span className="flex size-16 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)] shadow-lg transition-transform group-hover:scale-105">
|
||||
<Play className="size-7 translate-x-0.5 fill-current" />
|
||||
</span>
|
||||
<span className="px-6 text-center text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<figcaption className="mt-2 text-xs text-muted-foreground">
|
||||
加载缓慢或无法播放?
|
||||
<a
|
||||
href={watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
在 {config.siteName} 观看
|
||||
</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
93
apps/docs/content/docs/channels.ja.mdx
Normal file
93
apps/docs/content/docs/channels.ja.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Chat 連携(channels)
|
||||
description: Multica がどのようにエージェントをチャットプラットフォームに接続するか——1 つのチャンネルエンジンと、Lark(飞书)および Slack 向けのプラットフォーム別アダプター——受信パイプライン、セッション、認可までを解説します。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**チャンネル**は、Multica の[エージェント](/agents)をチャットプラットフォームに接続し、チームが普段やり取りしている場所でそのまま使えるようにします。現在チャンネルは 2 つあり——[Lark(飞书)](/lark-bot-integration)と [Slack](/slack-bot-integration)——どちらも**同じエンジン**で動いています。プラットフォームに依存しないコアと、薄いプラットフォーム別アダプターの組み合わせです。プラットフォームを追加するということは「アダプターを実装する」ことであり、「パイプラインを作り直す」ことではありません。
|
||||
|
||||
**インストール**は、それらを結びつける単位です。1 つの Bot が 1 つの `(workspace, agent)` に紐づきます。受信メッセージはまずインストールにルーティングされ、その後共有パイプラインを通り、エージェントの返信は同じチャットに送り返されます。
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["チャットプラットフォーム"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["チャンネルエンジン(プラットフォーム非依存)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>インストールごとに 1 本のライブ接続"]
|
||||
ROU["Router パイプライン:<br/>route → dedup → auth → session → trigger"]
|
||||
end
|
||||
LK -->|長時間接続| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|生イベント| ADP["プラットフォーム別アダプター<br/>変換 + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|エージェントタスク| RUN["デーモンがエージェントを実行"]
|
||||
RUN -->|返信| OUT["プラットフォーム別の送信<br/>(bot token → プラットフォーム API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## 受信パイプライン(共通)
|
||||
|
||||
すべての受信メッセージは——Lark でも Slack でも——エンジンの `Router` 内で同じ順序のステップを通ります。プラットフォームアダプターが供給するのはプラットフォーム別の部品(`ResolverSet`)だけで、ポリシーはエンジンの中にあります。
|
||||
|
||||
1. **インストールへのルーティング** —— イベントを `channel_installation`(→ ワークスペース + エージェント)に対応づけます。Lark は `app_id` でルーティングし、Slack はイベントに含まれる app id でルーティングします。
|
||||
2. **宛先フィルター** —— グループ/チャンネルでは、**Bot を @ メンション**したメッセージだけが先へ進みます。アイドル状態のグループの雑談は破棄されます(読み取られません)。
|
||||
3. **重複排除(dedup)** —— 2 フェーズの `(installation, message_id)` クレームにより、サーバーのレプリカをまたいでも厳密に 1 回だけ処理されることを保証します。
|
||||
4. **アイデンティティ + 認可** —— 送信者のプラットフォームユーザー id を Multica ユーザーに解決し([アカウントの紐づけ](#認可))、その上でワークスペースのメンバーシップを再チェックします。紐づいていない送信者には「アカウントを紐づける」プロンプトが返され、メンバーでない場合は破棄されます。
|
||||
5. **セッション** —— この会話に対応する[chat セッション](/chat)を見つけるか作成し、メッセージを追加します([セッション](#セッションとコンテキスト)を参照)。
|
||||
6. **トリガー** —— エージェントの[タスク](/tasks)をエンキューします。[デーモン](/daemon-runtimes)がエージェントを実行し、返信がチャットに送り返されます。
|
||||
|
||||
## セッションとコンテキスト
|
||||
|
||||
エージェントのコンテキストは、**chat セッションのトランスクリプト**——そのセッションに時間をかけて取り込まれてきたメッセージ——です。このトランスクリプトのモデルは共通です(すべてのチャンネルで共有されます)。プラットフォームごとに異なるのは、アダプターが組み立てる**セッション分離キー**です。
|
||||
|
||||
| プラットフォーム | 分離キー | 効果 |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | チャット id | チャット/グループごとに 1 セッション——同じチャット内の連続したやり取りが 1 つのトランスクリプトに蓄積されます(複数ターンの記憶)。 |
|
||||
| **Slack** | DM: チャンネル/チャンネル: `channel + thread root` | 各 DM が 1 セッション。**各 @bot スレッドがそれぞれ独立したセッション**になるので、同じチャンネル内の 2 つのスレッドが混ざりません。 |
|
||||
|
||||
<Callout type="info">
|
||||
グループでは、**Bot を @ メンション**したメッセージだけが取り込まれます。どちらのチャンネルも、現時点ではチャンネルの他の(@ されていない)メッセージや過去ログを読まないため、エージェントは自分が宛先になっていないメッセージを見ることはありません。前後の履歴をコンテキストとして取得することは、今後の拡張として計画されています。
|
||||
</Callout>
|
||||
|
||||
## 認可
|
||||
|
||||
共有グループ内で Bot を守るために、2 つの独立したゲートがあります——どちらもエンジンであらゆるメッセージに対し、Lark と Slack で同一に適用されます。
|
||||
|
||||
- **アカウントの紐づけ(認証)** —— 送信者のプラットフォームユーザー id が Multica ユーザーにリンクされている必要があります。誰かが初めて Bot にメッセージを送ると、**自分自身の** Multica アカウントにアイデンティティを紐づけるための使い切りリンクを受け取ります。それまではエージェントは実行されません。
|
||||
- **ワークスペースのメンバーシップ(認可)** —— 紐づいた Multica ユーザーが、そのインストールのワークスペースのメンバーである必要があり、これはメッセージごとに再チェックされます。メンバーでない場合は黙って破棄されます。
|
||||
|
||||
そのため、Bot を公開チャンネルに追加しても安全です。アイデンティティを紐づけたワークスペースメンバーだけがエージェントを動かせ、各送信者は独立してチェックされます。ユーザー向けのプロンプトについては、各プラットフォームのページを参照してください。
|
||||
|
||||
## 2 つのチャンネル
|
||||
|
||||
<Callout type="info">
|
||||
**Lark(飞书) — スキャンしてインストール。** ワークスペースの admin が Lark アプリで QR をスキャンするだけでエージェントを紐づけられます。開発者コンソールでの操作は不要です。エージェントごとに 1 つの Bot。[Lark Bot 連携](/lark-bot-integration)を参照してください。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack — 自分のアプリを持ち込む。** ワークスペースの admin が Slack アプリを作成し、自分の Slack ワークスペースにインストールして、その bot token + app-level token を Multica に貼り付けます。エージェントごとに専用の Slack アプリを持つため、1 つの Slack ワークスペース内で複数のエージェントがそれぞれ異なる Bot を持てます。マニフェストと手順は [Slack Bot 連携](/slack-bot-integration)を参照してください。
|
||||
</Callout>
|
||||
|
||||
## セルフホスト
|
||||
|
||||
各チャンネルは、**保存時の暗号化キーを設定するまでオフ**です(このキーは、各 Bot のトークンがデータベースに触れる前にそれを暗号化します)。
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
Multica Cloud では両方ともすでに設定済みです。完全なリファレンスは[環境変数](/environment-variables)を参照してください。
|
||||
|
||||
## 次に
|
||||
|
||||
- [Lark Bot 連携](/lark-bot-integration) — スキャンしてインストール、DM / @ メンション / `/issue`
|
||||
- [Slack Bot 連携](/slack-bot-integration) — 自分のアプリを持ち込むセットアップ(マニフェスト + トークン)、エージェントごとの Bot
|
||||
- [エージェント](/agents) · [Chat](/chat) · [タスク](/tasks)
|
||||
93
apps/docs/content/docs/channels.ko.mdx
Normal file
93
apps/docs/content/docs/channels.ko.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Chat 연동 (channels)
|
||||
description: Multica가 에이전트를 채팅 플랫폼에 어떻게 연결하는지 — 하나의 channel 엔진과 Lark(飞书) 및 Slack을 위한 플랫폼별 어댑터 — 인바운드 파이프라인, 세션, 권한을 다룹니다.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**channel**은 Multica [에이전트](/agents)를 채팅 플랫폼에 연결하여, 팀이 이미 대화하고 있는 곳에서 그 에이전트와 함께 일할 수 있게 합니다. 현재 두 개의 channel이 있습니다 — [Lark (飞书)](/lark-bot-integration)와 [Slack](/slack-bot-integration) — 그리고 둘 다 **같은 엔진** 위에서 동작합니다: 플랫폼 중립적인 코어에 얇은 플랫폼별 어댑터가 더해진 구조입니다. 플랫폼을 추가하는 일은 "어댑터를 구현하는 것"이지, "파이프라인을 다시 만드는 것"이 아닙니다.
|
||||
|
||||
**installation**은 이 모든 것을 하나로 묶는 단위입니다: 하나의 봇이 하나의 `(workspace, agent)`에 바인딩됩니다. 인바운드 메시지는 installation으로 라우팅된 다음 공유 파이프라인을 거치며, 에이전트의 답변은 동일한 채팅으로 돌아갑니다.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["채팅 플랫폼"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["Channel 엔진 (플랫폼 중립적)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>installation당 하나의 활성 연결"]
|
||||
ROU["Router 파이프라인:<br/>route → dedup → auth → session → trigger"]
|
||||
end
|
||||
LK -->|long connection| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|raw event| ADP["플랫폼별 어댑터<br/>변환 + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|agent task| RUN["Daemon이 에이전트를 실행"]
|
||||
RUN -->|reply| OUT["플랫폼별 아웃바운드<br/>(bot token → platform API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## 인바운드 파이프라인 (공통)
|
||||
|
||||
모든 인바운드 메시지는 — Lark든 Slack이든 — 엔진의 `Router`에서 동일하게 정해진 순서의 단계를 거칩니다. 플랫폼 어댑터는 플랫폼별 조각(`ResolverSet`)만 공급하며, 정책은 엔진 안에 있습니다.
|
||||
|
||||
1. **Route to installation** — 이벤트를 `channel_installation`(→ workspace + agent)에 매핑합니다. Lark는 `app_id`로 라우팅하고, Slack은 이벤트에 실린 app id로 라우팅합니다.
|
||||
2. **Addressing filter** — 그룹/채널에서는 **봇을 @로 멘션한** 메시지만 계속 진행되며, 한가한 그룹 잡담은 폐기됩니다(읽지 않음).
|
||||
3. **Dedup** — 두 단계로 이루어진 `(installation, message_id)` 클레임이 서버 레플리카가 여러 개여도 정확히 한 번만 처리됨을 보장합니다.
|
||||
4. **Identity + authorization** — 보낸 사람의 플랫폼 사용자 id를 Multica 사용자([계정 바인딩](#권한))로 해석한 다음, 워크스페이스 멤버십을 다시 확인합니다. 바인딩되지 않은 발신자에게는 "계정을 연결하세요" 안내가 표시되고, 멤버가 아닌 사람은 폐기됩니다.
|
||||
5. **Session** — 이 대화에 대한 [chat 세션](/chat)을 찾거나 생성하고 메시지를 추가합니다([세션](#세션과-컨텍스트) 참조).
|
||||
6. **Trigger** — 에이전트 [task](/tasks)를 큐에 넣습니다. [daemon](/daemon-runtimes)이 에이전트를 실행하고 그 답변이 채팅으로 돌아갑니다.
|
||||
|
||||
## 세션과 컨텍스트
|
||||
|
||||
에이전트의 컨텍스트는 **chat 세션 트랜스크립트**입니다 — 시간이 지나며 그 세션에 수집된 메시지들입니다. 이 트랜스크립트 모델은 공통(모든 channel이 공유)입니다. 플랫폼마다 다른 것은 어댑터가 구성하는 **세션 격리 키**입니다:
|
||||
|
||||
| 플랫폼 | 격리 키 | 효과 |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | 채팅 id | 채팅/그룹당 하나의 세션 — 같은 채팅에서의 연속된 턴이 하나의 트랜스크립트로 쌓입니다(멀티턴 메모리). |
|
||||
| **Slack** | DM: 채널; 채널: `channel + thread root` | 각 DM이 하나의 세션이고, **각 @bot 스레드가 자체 세션**이므로, 한 채널의 두 스레드는 섞이지 않습니다. |
|
||||
|
||||
<Callout type="info">
|
||||
그룹에서는 **봇을 @로 멘션한** 메시지만 수집됩니다. 어느 channel도 현재 채널의 다른(멘션되지 않은) 메시지나 스크롤백을 읽지 않으므로, 에이전트는 자신이 호출되지 않은 메시지를 보지 못합니다. 주변 기록을 컨텍스트로 가져오는 기능은 향후 개선 사항으로 계획되어 있습니다.
|
||||
</Callout>
|
||||
|
||||
## 권한
|
||||
|
||||
공유 그룹에서 봇을 보호하는 두 개의 독립적인 관문이 있으며 — 둘 다 모든 메시지에 대해 엔진에서, Lark와 Slack에 동일하게 적용됩니다:
|
||||
|
||||
- **계정 바인딩(인증)** — 보낸 사람의 플랫폼 사용자 id가 Multica 사용자에 연결되어 있어야 합니다. 누군가 봇에게 처음 메시지를 보내면 **자기 자신의** Multica 계정에 신원을 바인딩하는 일회용 링크를 받으며, 그 전까지는 어떤 에이전트도 실행되지 않습니다.
|
||||
- **워크스페이스 멤버십(권한)** — 바인딩된 Multica 사용자는 installation의 워크스페이스 멤버여야 하며, 이는 모든 메시지마다 다시 확인됩니다. 멤버가 아닌 사람은 조용히 폐기됩니다.
|
||||
|
||||
따라서 공개 채널에 봇을 추가해도 안전합니다: 신원을 바인딩한 워크스페이스 멤버만 에이전트를 움직일 수 있고, 각 발신자는 독립적으로 확인됩니다. 사용자에게 표시되는 안내는 플랫폼별 페이지를 참고하세요.
|
||||
|
||||
## 두 개의 channel
|
||||
|
||||
<Callout type="info">
|
||||
**Lark (飞书) — 스캔하여 설치.** 워크스페이스 admin이 Lark 앱으로 QR을 스캔하여 에이전트를 바인딩합니다. 개발자 콘솔 작업이 없습니다. 에이전트당 하나의 Bot. [Lark Bot 연동](/lark-bot-integration)을 참고하세요.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack — 자체 앱 사용.** 워크스페이스 admin이 Slack 앱을 만들고, 자신의 Slack 워크스페이스에 설치한 다음, bot token과 app-level token을 Multica에 붙여넣습니다. 각 에이전트가 자체 Slack 앱을 갖기 때문에, 하나의 Slack 워크스페이스에서 여러 에이전트가 각각 별개의 봇을 가질 수 있습니다. 매니페스트와 단계별 설정은 [Slack Bot 연동](/slack-bot-integration)을 참고하세요.
|
||||
</Callout>
|
||||
|
||||
## 자체 호스팅
|
||||
|
||||
각 channel은 **at-rest 암호화 키를 설정하기 전까지 꺼져 있습니다**(이 키는 각 봇의 토큰이 데이터베이스에 닿기 전에 암호화합니다):
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
Multica Cloud에서는 둘 다 이미 구성되어 있습니다. 전체 참조는 [환경 변수](/environment-variables)를 참고하세요.
|
||||
|
||||
## 다음
|
||||
|
||||
- [Lark Bot 연동](/lark-bot-integration) — 스캔하여 설치, DM / @-멘션 / `/issue`
|
||||
- [Slack Bot 연동](/slack-bot-integration) — 자체 앱 사용 설정(매니페스트 + 토큰), 에이전트별 봇
|
||||
- [에이전트](/agents) · [Chat](/chat) · [Tasks](/tasks)
|
||||
93
apps/docs/content/docs/channels.mdx
Normal file
93
apps/docs/content/docs/channels.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Chat integrations (channels)
|
||||
description: How Multica connects agents to chat platforms — one channel engine, per-platform adapters for Lark (飞书) and Slack — covering the inbound pipeline, sessions, and authorization.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
A **channel** connects a Multica [agent](/agents) to a chat platform so your team can work with it where they already talk. Today there are two channels — [Lark (飞书)](/lark-bot-integration) and [Slack](/slack-bot-integration) — and both run on the **same engine**: a platform-neutral core plus a thin per-platform adapter. Adding a platform is "implement the adapter," not "rebuild the pipeline."
|
||||
|
||||
An **installation** is the unit that ties it together: one bot bound to one `(workspace, agent)`. Inbound messages are routed to an installation, then through a shared pipeline; the agent's reply is sent back to the same chat.
|
||||
|
||||
## Architecture
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["Chat platforms"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["Channel engine (platform-neutral)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>one live connection per installation"]
|
||||
ROU["Router pipeline:<br/>route → dedup → auth → session → trigger"]
|
||||
end
|
||||
LK -->|long connection| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|raw event| ADP["Per-platform adapter<br/>translate + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|agent task| RUN["Daemon runs the agent"]
|
||||
RUN -->|reply| OUT["Per-platform outbound<br/>(bot token → platform API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## The inbound pipeline (generic)
|
||||
|
||||
Every inbound message — Lark or Slack — runs through the same ordered steps in the engine `Router`. A platform adapter only supplies the per-platform pieces (the `ResolverSet`); the policy lives in the engine.
|
||||
|
||||
1. **Route to installation** — map the event to a `channel_installation` (→ workspace + agent). Lark routes by `app_id`; Slack routes by the app id carried on the event.
|
||||
2. **Addressing filter** — in a group/channel, only messages that **@-mention the bot** continue; idle group chatter is dropped (not read).
|
||||
3. **Dedup** — a two-phase `(installation, message_id)` claim guarantees exactly-once processing, even across server replicas.
|
||||
4. **Identity + authorization** — resolve the sender's platform user id to a Multica user (the [account binding](#authorization)), then re-check workspace membership. Unbound senders get a "link your account" prompt; non-members are dropped.
|
||||
5. **Session** — find or create a [chat session](/chat) for this conversation and append the message (see [Sessions](#sessions-and-context)).
|
||||
6. **Trigger** — enqueue an agent [task](/tasks); a [daemon](/daemon-runtimes) runs the agent and the reply is sent back into the chat.
|
||||
|
||||
## Sessions and context
|
||||
|
||||
The agent's context is the **chat-session transcript** — the messages that have been ingested into that session over time. This transcript model is generic (shared by every channel). What differs per platform is the **session-isolation key** the adapter composes:
|
||||
|
||||
| Platform | Isolation key | Effect |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | the chat id | One session per chat/group — consecutive turns in the same chat accumulate into one transcript (multi-turn memory). |
|
||||
| **Slack** | DM: the channel; channel: `channel + thread root` | Each DM is one session; **each @bot thread is its own session**, so two threads in one channel don't mix. |
|
||||
|
||||
<Callout type="info">
|
||||
In a group, only messages that **@-mention the bot** are ingested. Neither channel reads the channel's other (un-@'d) messages or scrollback today, so the agent won't see messages it wasn't addressed in. Fetching surrounding history as context is a planned enhancement.
|
||||
</Callout>
|
||||
|
||||
## Authorization
|
||||
|
||||
Two independent gates protect a bot in a shared group — both enforced in the engine for every message, identically for Lark and Slack:
|
||||
|
||||
- **Account binding (authentication)** — the sender's platform user id must be linked to a Multica user. The first time someone messages the bot they get a one-time link to bind their identity to **their own** Multica account; until then no agent runs.
|
||||
- **Workspace membership (authorization)** — the bound Multica user must be a member of the installation's workspace, re-checked on every message. Non-members are silently dropped.
|
||||
|
||||
So adding a bot to a public channel is safe: only workspace members who have bound their identity can drive the agent, and each sender is checked independently. See the per-platform pages for the user-facing prompts.
|
||||
|
||||
## The two channels
|
||||
|
||||
<Callout type="info">
|
||||
**Lark (飞书) — scan to install.** A workspace admin binds an agent by scanning a QR with the Lark app; no developer console steps. One Bot per agent. See [Lark Bot integration](/lark-bot-integration).
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack — bring your own app.** A workspace admin creates a Slack app, installs it to their Slack workspace, and pastes its bot token + app-level token into Multica. Each agent gets its own Slack app, so several agents can each have a distinct bot in one Slack workspace. See [Slack Bot integration](/slack-bot-integration) for the manifest and step-by-step setup.
|
||||
</Callout>
|
||||
|
||||
## Self-host
|
||||
|
||||
Each channel is **off until you set its at-rest encryption key** (the key encrypts each bot's tokens before they touch the database):
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
On Multica Cloud both are already configured. See [Environment variables](/environment-variables) for the full reference.
|
||||
|
||||
## Next
|
||||
|
||||
- [Lark Bot integration](/lark-bot-integration) — scan-to-install, DM / @-mention / `/issue`
|
||||
- [Slack Bot integration](/slack-bot-integration) — bring-your-own-app setup (manifest + tokens), per-agent bots
|
||||
- [Agents](/agents) · [Chat](/chat) · [Tasks](/tasks)
|
||||
93
apps/docs/content/docs/channels.zh.mdx
Normal file
93
apps/docs/content/docs/channels.zh.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: 聊天集成(channels)
|
||||
description: Multica 如何把智能体接入聊天平台——一个统一的 channel 引擎,加上针对飞书(Lark)和 Slack 的各平台适配器——涵盖入站流水线、会话与授权。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**channel** 把一个 Multica [智能体](/agents)接入聊天平台,团队就能在他们日常沟通的地方直接使用它。目前有两个 channel——[Lark(飞书)](/lark-bot-integration) 和 [Slack](/slack-bot-integration)——两者都跑在**同一个引擎**上:一个平台无关的内核,加上一层很薄的各平台适配器。新增一个平台是「实现适配器」,而不是「重建流水线」。
|
||||
|
||||
**安装(installation)** 是把这一切串起来的单元:一个 Bot 绑定到一个 `(workspace, agent)`。入站消息被路由到某个安装,再经过共享的流水线;智能体的回复会被发回同一个聊天里。
|
||||
|
||||
## 架构
|
||||
|
||||
<Mermaid chart={`
|
||||
flowchart LR
|
||||
subgraph P["聊天平台"]
|
||||
LK["Lark / 飞书"]
|
||||
SL["Slack"]
|
||||
end
|
||||
subgraph ENG["Channel 引擎(平台无关)"]
|
||||
direction TB
|
||||
SUP["Supervisor<br/>每个安装一条实时连接"]
|
||||
ROU["路由流水线:<br/>路由 → 去重 → 鉴权 → 会话 → 触发"]
|
||||
end
|
||||
LK -->|长连接| SUP
|
||||
SL -->|Socket Mode| SUP
|
||||
SUP -->|原始事件| ADP["各平台适配器<br/>转换 + ResolverSet"]
|
||||
ADP --> ROU
|
||||
ROU -->|智能体任务| RUN["守护进程运行智能体"]
|
||||
RUN -->|回复| OUT["各平台出站<br/>(bot token → 平台 API)"]
|
||||
OUT --> P
|
||||
`} />
|
||||
|
||||
## 入站流水线(通用)
|
||||
|
||||
每一条入站消息——无论来自 Lark 还是 Slack——都会走引擎 `Router` 里同一套有序步骤。平台适配器只提供各平台特有的部分(即 `ResolverSet`);策略本身住在引擎里。
|
||||
|
||||
1. **路由到安装** —— 把事件映射到一个 `channel_installation`(→ workspace + agent)。Lark 按 `app_id` 路由;Slack 按事件携带的 app id 路由。
|
||||
2. **寻址过滤** —— 在群 / 频道里,只有 **@ 了 Bot** 的消息才会继续往下走;无关的群聊闲谈会被丢弃(不读取)。
|
||||
3. **去重** —— 一个两阶段的 `(installation, message_id)` 认领机制保证恰好处理一次,即便跨多个服务器副本也成立。
|
||||
4. **身份 + 授权** —— 把发送者的平台用户 id 解析成一个 Multica 用户(即[账号绑定](#账号绑定)),然后再次校验 workspace 成员身份。未绑定的发送者会收到一条「绑定你的账号」提示;非成员会被丢弃。
|
||||
5. **会话** —— 为这段对话找到或创建一个 [chat 会话](/chat),并把消息追加进去(见[会话](#会话与上下文))。
|
||||
6. **触发** —— 入队一个智能体[任务](/tasks);一个[守护进程](/daemon-runtimes)运行智能体,回复会被发回聊天里。
|
||||
|
||||
## 会话与上下文
|
||||
|
||||
智能体的上下文就是**这段 chat 会话的对话记录**——也就是随时间被纳入该会话的那些消息。这套对话记录模型是通用的(每个 channel 共用)。各平台不同的地方在于适配器拼出来的**会话隔离键**:
|
||||
|
||||
| 平台 | 隔离键 | 效果 |
|
||||
|---|---|---|
|
||||
| **Lark / 飞书** | 聊天 id | 每个聊天 / 群一个会话——同一个聊天里连续的几轮会累积成一份对话记录(多轮记忆)。 |
|
||||
| **Slack** | 私聊:频道;频道:`channel + thread root` | 每段私聊是一个会话;**每个 @bot 的 thread 是它自己的会话**,所以同一个频道里的两个 thread 不会混在一起。 |
|
||||
|
||||
<Callout type="info">
|
||||
在群里,只有 **@ 了 Bot** 的消息才会被纳入。目前两个 channel 都不会读取频道里其他(没 @ 的)消息或历史滚动记录,所以智能体看不到那些没有点名它的消息。把周边历史作为上下文拉取进来,是计划中的增强功能。
|
||||
</Callout>
|
||||
|
||||
## 账号绑定
|
||||
|
||||
在共享群里,有两道相互独立的关卡保护着 Bot——两者都在引擎里对每一条消息强制执行,且 Lark 和 Slack 一视同仁:
|
||||
|
||||
- **账号绑定(认证)** —— 发送者的平台用户 id 必须关联到一个 Multica 用户。某人第一次给 Bot 发消息时,会拿到一个一次性链接,把自己的身份绑定到**他自己的** Multica 账号;在那之前不会有任何智能体运行。
|
||||
- **Workspace 成员身份(授权)** —— 绑定后的 Multica 用户必须是该安装所属 workspace 的成员,每条消息都会重新校验。非成员会被静默丢弃。
|
||||
|
||||
所以把 Bot 加进一个公开频道是安全的:只有已绑定身份的 workspace 成员才能驱动智能体,而且每个发送者都会被独立校验。面向用户的提示文案请见各平台的页面。
|
||||
|
||||
## 两个 channel
|
||||
|
||||
<Callout type="info">
|
||||
**Lark(飞书)—— 扫码安装。** workspace 管理员用飞书 App 扫一个二维码就能绑定一个智能体;无需任何开发者后台步骤。一个智能体一个 Bot。见 [Lark Bot 接入](/lark-bot-integration)。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**Slack —— 自带应用。** workspace 管理员创建一个 Slack app,把它安装到自己的 Slack workspace,再把它的 bot token + app-level token 粘贴进 Multica。每个智能体都有自己的 Slack app,所以多个智能体可以在同一个 Slack workspace 里各自拥有一个独立的 Bot。manifest 和分步设置见 [Slack Bot 接入](/slack-bot-integration)。
|
||||
</Callout>
|
||||
|
||||
## 自部署
|
||||
|
||||
每个 channel 在**你设置好它的静态加密密钥之前都是关闭的**(这个密钥会在每个 Bot 的 token 落库之前对其加密):
|
||||
|
||||
```dotenv
|
||||
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
在 Multica Cloud 上两者都已配置好。完整参考见[环境变量](/environment-variables)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Lark Bot 接入](/lark-bot-integration) —— 扫码安装,私聊 / @ 提及 / `/issue`
|
||||
- [Slack Bot 接入](/slack-bot-integration) —— 自带应用的设置(manifest + token),每个智能体一个 Bot
|
||||
- [智能体](/agents) · [Chat](/chat) · [任务](/tasks)
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
このページは Multica Cloud を最初から最後まで案内します — **サインアップ → [CLI](/cli) のインストール → [デーモン](/daemon-runtimes)の起動 → [エージェント](/agents)の作成 → 最初の[タスク](/tasks)の割り当て**。約 5 分かかります。
|
||||
|
||||
前提条件は 1 つだけです: ローカルに [AI コーディングツール](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) のいずれか)を少なくとも 1 つ、すでにインストールしておくこと。デーモンは起動時にこれらを自動検出し、1 つもなければ起動を拒否します。
|
||||
前提条件は 1 つだけです: ローカルに [AI コーディングツール](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) のいずれか)を少なくとも 1 つ、すでにインストールしておくこと。デーモンは起動時にこれらを自動検出し、1 つもなければ起動を拒否します。
|
||||
|
||||
## 1. アカウントを作成する
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
이 페이지는 Multica Cloud를 처음부터 끝까지 안내합니다 — **가입 → [CLI](/cli) 설치 → [데몬](/daemon-runtimes) 시작 → [에이전트](/agents) 생성 → 첫 [작업](/tasks) 할당**. 약 5분이 걸립니다.
|
||||
|
||||
전제 조건은 하나뿐입니다: 로컬에 [AI 코딩 도구](/providers)([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi) 중 하나)를 이미 최소 하나는 설치해 두어야 합니다. 데몬은 시작할 때 이들을 자동으로 감지하며, 하나도 없으면 시작을 거부합니다.
|
||||
전제 조건은 하나뿐입니다: 로컬에 [AI 코딩 도구](/providers)([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi) 중 하나)를 이미 최소 하나는 설치해 두어야 합니다. 데몬은 시작할 때 이들을 자동으로 감지하며, 하나도 없으면 시작을 거부합니다.
|
||||
|
||||
## 1. 계정 만들기
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
|
||||
|
||||
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
|
||||
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
|
||||
|
||||
## 1. Create an account
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
|
||||
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
|
||||
## 1. 注册账号
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
起動時にデーモンは 4 つのことを行います。
|
||||
|
||||
1. ログイン時に保存された認証情報を読み込みます
|
||||
2. `PATH` にインストールされた AI コーディングツールを検出します(内蔵 12 種: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
2. `PATH` にインストールされた AI コーディングツールを検出します(内蔵 12 種: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
3. 検出した各ツールに対するランタイムとともに、自身をサーバーに登録します
|
||||
4. **3 秒ごと**に取得すべきタスクがないかポーリングし、**15 秒ごとにハートビートを送信**し続けます
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
시작 시 데몬은 네 가지 일을 합니다.
|
||||
|
||||
1. 로그인할 때 저장된 인증 정보를 읽습니다
|
||||
2. `PATH`에 설치된 AI 코딩 도구를 감지합니다(내장 12종: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
2. `PATH`에 설치된 AI 코딩 도구를 감지합니다(내장 12종: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
3. 감지된 각 도구에 대한 런타임과 함께 자신을 서버에 등록합니다
|
||||
4. **3초마다** 가져올 작업이 있는지 폴링하고, **15초마다 하트비트를 전송**합니다
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
On startup it does four things:
|
||||
|
||||
1. Reads the credentials saved when you logged in
|
||||
2. Detects AI coding tools installed on your `PATH` (12 built-in: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
2. Detects AI coding tools installed on your `PATH` (12 built-in: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
3. Registers itself with the server, along with a runtime for each detected tool
|
||||
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
启动后它会做四件事:
|
||||
|
||||
1. 读取你登录时保存的凭证
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
|
||||
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
|
||||
|
||||
|
||||
@@ -181,9 +181,19 @@ S3 の前段に CloudFront を置く場合、3 つの変数が適用されます
|
||||
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 最大同時タスク数 |
|
||||
| `MULTICA_<PROVIDER>_PATH` | CLI 名に一致 | 各 AI コーディングツールの実行ファイルへのパス(例: `MULTICA_CLAUDE_PATH`) |
|
||||
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI コーディングツールのデフォルトモデル |
|
||||
| `MULTICA_<PROVIDER>_ARGS` | 空 | バックエンドごとのデーモン全体のデフォルト CLI 引数。各タスクに対し、各エージェント自身の `custom_args` より前に適用される。`MULTICA_CLAUDE_ARGS`、`MULTICA_CODEX_ARGS`、`MULTICA_CODEBUDDY_ARGS` をサポート |
|
||||
|
||||
各パラメータがデーモンの動作にどう影響するかの完全な説明は、[デーモンとランタイム](/daemon-runtimes)を参照してください。
|
||||
|
||||
### デフォルトのエージェント引数(`MULTICA_<PROVIDER>_ARGS`)
|
||||
|
||||
バックエンドに対して**フリート全体のデフォルト**となる CLI フラグの層を設定します。各エージェントの `custom_args` を個別に編集することなく、デーモン上のすべてのエージェントにデフォルトのコスト・リソースのベースライン(例: `--max-turns`)を適用できる便利な手段です。これはデフォルトの層であり、超えられない上限ではありません。各エージェント自身の `custom_args` が後から追加され、これを上書きできます(下記の**優先順位**を参照)。
|
||||
|
||||
- **優先順位:** デフォルト引数が先に適用され、その後に各エージェント自身の `custom_args` が追加されます。値を取るフラグについては、下流 CLI 自身の引数パーサーが最終的な勝者を決めます(多くのツールでは最後の出現が優先)。そのため個々のエージェントはデーモンのデフォルトを引き上げられますが、エージェントが上書きしない箇所ではデフォルトが引き続き有効です。
|
||||
- **パース:** 値は POSIX シェルワード規則で分割されるため、クォートが使えます——`MULTICA_CLAUDE_ARGS='--append-system-prompt "multi word"'` は 2 つのトークンに解析されます。
|
||||
- **安全性:** デフォルト引数の層と各エージェントの `custom_args` の層は、いずれも同じ blocked-flags フィルターを通過します。そのためプロトコル上重要なフラグ(Claude の `-p`、`--output-format`、`--input-format`、`--permission-mode`、`--mcp-config`、および Codex の `--listen` など)はどちらの層からも注入できません。
|
||||
- **未設定・空** の場合は動作に変化はありません。
|
||||
|
||||
## フロントエンドのアクセス制御
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|
||||
@@ -181,9 +181,19 @@ S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLO
|
||||
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 최대 동시 작업 수 |
|
||||
| `MULTICA_<PROVIDER>_PATH` | CLI 이름과 일치 | 각 AI 코딩 도구 실행 파일의 경로 (예: `MULTICA_CLAUDE_PATH`) |
|
||||
| `MULTICA_<PROVIDER>_MODEL` | 비어 있음 | 각 AI 코딩 도구의 기본 모델 |
|
||||
| `MULTICA_<PROVIDER>_ARGS` | 비어 있음 | 백엔드별 데몬 전역 기본 CLI 인자. 각 작업에 대해 각 에이전트 자체의 `custom_args`보다 먼저 적용됩니다. `MULTICA_CLAUDE_ARGS`, `MULTICA_CODEX_ARGS`, `MULTICA_CODEBUDDY_ARGS`를 지원 |
|
||||
|
||||
각 파라미터가 데몬 동작에 어떻게 영향을 미치는지에 대한 전체 설명은 [데몬과 런타임](/daemon-runtimes)을 참고하세요.
|
||||
|
||||
### 기본 에이전트 인자 (`MULTICA_<PROVIDER>_ARGS`)
|
||||
|
||||
백엔드에 대해 **플릿 전역 기본값** 계층의 CLI 플래그를 설정합니다. 각 에이전트의 `custom_args`를 일일이 수정하지 않고도 데몬의 모든 에이전트에 기본 비용·리소스 기준선(예: `--max-turns`)을 적용할 수 있는 편리한 방법입니다. 이는 넘을 수 없는 상한이 아니라 기본 계층입니다. 각 에이전트 자체의 `custom_args`가 뒤에 추가되어 이를 덮어쓸 수 있습니다(아래 **우선순위** 참고).
|
||||
|
||||
- **우선순위:** 기본 인자가 먼저 적용되고, 그다음 각 에이전트 자체의 `custom_args`가 추가됩니다. 값을 받는 플래그의 경우 다운스트림 CLI 자체의 인자 파서가 최종 적용 값을 결정합니다(대부분의 도구는 마지막 항목이 우선). 따라서 개별 에이전트는 데몬 기본값을 높일 수 있지만, 에이전트가 덮어쓰지 않은 부분에는 기본값이 계속 적용됩니다.
|
||||
- **파싱:** 값은 POSIX 셸 단어 규칙으로 분할되므로 따옴표를 사용할 수 있습니다 — `MULTICA_CLAUDE_ARGS='--append-system-prompt "multi word"'`는 두 개의 토큰으로 파싱됩니다.
|
||||
- **안전성:** 기본 인자 계층과 에이전트별 `custom_args` 계층 모두 동일한 blocked-flags 필터를 통과합니다. 따라서 프로토콜에 중요한 플래그(Claude의 `-p`, `--output-format`, `--input-format`, `--permission-mode`, `--mcp-config` 및 Codex의 `--listen` 등)는 어느 계층을 통해서도 주입할 수 없습니다.
|
||||
- **미설정/빈 값**은 동작에 변화가 없음을 의미합니다.
|
||||
|
||||
## 프론트엔드 액세스 제어
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|
||||
@@ -181,9 +181,19 @@ The daemon runs on the user's local machine, and its config is read from local e
|
||||
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | Max concurrent tasks |
|
||||
| `MULTICA_<PROVIDER>_PATH` | matches the CLI name | Path to each AI coding tool's executable (for example `MULTICA_CLAUDE_PATH`) |
|
||||
| `MULTICA_<PROVIDER>_MODEL` | empty | Default model for each AI coding tool |
|
||||
| `MULTICA_<PROVIDER>_ARGS` | empty | Daemon-wide default CLI arguments for a backend, applied to every task before each agent's own `custom_args`. Supported for `MULTICA_CLAUDE_ARGS`, `MULTICA_CODEX_ARGS`, and `MULTICA_CODEBUDDY_ARGS` |
|
||||
|
||||
For a full explanation of how each parameter affects daemon behavior, see [Daemon and runtimes](/daemon-runtimes).
|
||||
|
||||
### Default agent arguments (`MULTICA_<PROVIDER>_ARGS`)
|
||||
|
||||
These set a **fleet-wide default** layer of CLI flags for a backend — a convenient way to apply a default cost or resource baseline (for example `--max-turns`) across every agent on a daemon without editing each agent's `custom_args` individually. This is a default layer, not a hard ceiling: per-agent `custom_args` are appended afterward and can override it (see **Precedence** below).
|
||||
|
||||
- **Precedence:** the default args are applied first, then each agent's own `custom_args` are appended after. For flags that take a value, the downstream CLI's own argument parser decides the winner (last occurrence wins for most tools), so an individual agent can raise a daemon default but the default still applies wherever the agent doesn't override it.
|
||||
- **Parsing:** the value is split with POSIX shell-word rules, so quoting works — `MULTICA_CLAUDE_ARGS='--append-system-prompt "multi word"'` parses into two tokens.
|
||||
- **Safety:** both the default-args and per-agent `custom_args` layers pass through the same blocked-flags filter, so protocol-critical flags (such as `-p`, `--output-format`, `--input-format`, `--permission-mode`, `--mcp-config` for Claude, and `--listen` for Codex) cannot be injected through either layer.
|
||||
- **Unset/empty** means no change to behavior.
|
||||
|
||||
## Frontend access control
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
||||
@@ -184,9 +184,19 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap
|
||||
| `MULTICA_AGENT_TOOL_WATCHDOG` | `2h` | 工具在途时的静默上限:某个工具调用发出后长时间无任何输出(疑似卡死的子进程)这么久就 force-stop。`0` = 关闭该兜底(在途工具永不被停)|
|
||||
| `MULTICA_<PROVIDER>_PATH` | 对应 CLI 名 | 各 AI 编程工具的可执行文件路径(如 `MULTICA_CLAUDE_PATH`)|
|
||||
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI 编程工具的默认模型 |
|
||||
| `MULTICA_<PROVIDER>_ARGS` | 空 | 守护进程级的默认 CLI 参数,作用于该后端的每个任务,并排在各智能体自身的 `custom_args` 之前。支持 `MULTICA_CLAUDE_ARGS`、`MULTICA_CODEX_ARGS`、`MULTICA_CODEBUDDY_ARGS` |
|
||||
|
||||
完整解释每个参数对守护进程行为的影响,见 [守护进程与运行时](/daemon-runtimes)。
|
||||
|
||||
### 默认智能体参数(`MULTICA_<PROVIDER>_ARGS`)
|
||||
|
||||
为某个后端设置一层**全机队默认**的 CLI 参数——可以方便地给一台守护进程上的所有智能体应用一个默认的成本或资源基线(例如 `--max-turns`),而不必逐个修改每个智能体的 `custom_args`。这是一层默认值,而不是不可突破的硬上限:每个智能体自己的 `custom_args` 会追加在后面,并可以覆盖它(见下方**优先级**)。
|
||||
|
||||
- **优先级:** 默认参数先生效,随后追加各智能体自己的 `custom_args`。对于带取值的参数,由下游 CLI 自己的参数解析器决定最终生效值(多数工具采用「后者覆盖」),因此单个智能体可以调高某个守护进程默认值,但在智能体没有覆盖的地方,默认值依然生效。
|
||||
- **解析:** 取值按 POSIX shell-word 规则切分,因此引号可用——`MULTICA_CLAUDE_ARGS='--append-system-prompt "multi word"'` 会解析为两个 token。
|
||||
- **安全:** 默认参数层和各智能体的 `custom_args` 层都会经过同一套 blocked-flags 过滤,因此协议关键标志(如 Claude 的 `-p`、`--output-format`、`--input-format`、`--permission-mode`、`--mcp-config`,以及 Codex 的 `--listen`)无法从任何一层注入。
|
||||
- **未设置 / 为空** 表示不改变行为。
|
||||
|
||||
## 前端访问控制
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica は**分散型**プラットフォームです。あなたが目にす
|
||||
|
||||
- **Multica サーバー** — あなたが目にするワークスペース、イシュー一覧、コメントスレッドは、すべてここのデータベースに保存されます。また、あなたと同僚の間でリアルタイム更新をプッシュする WebSocket ハブでもあります。エージェントのタスクは**実行しません**。
|
||||
- **デーモン** — Multica CLI の一部であり、あなた自身のマシンで実行されます。起動時にローカルにインストールされた AI コーディングツールを検出し、サーバーに登録したうえで、3 秒ごとにタスクをポーリングし、15 秒ごとにハートビートを送信し始めます。
|
||||
- **AI コーディングツール** — 次の 12 種類のうちの 1 つ(または複数を並列で): [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。デーモンがタスクを取得した後は、これらのツールを使って実際の作業を行います。
|
||||
- **AI コーディングツール** — 次の 12 種類のうちの 1 つ(または複数を並列で): [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。デーモンがタスクを取得した後は、これらのツールを使って実際の作業を行います。
|
||||
|
||||
ツールチェーンがローカルに留まるため、**あなたの API キー、コードディレクトリ、認可されたツール**は、あなたのマシン上でのみ使用されます。Multica サーバーはそのいずれも目にすることはありません。これはセルフホストでも Cloud でも同じように適用されます。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica는 **분산형** 플랫폼입니다. 여러분이 보는 웹 인터페
|
||||
|
||||
- **Multica 서버** — 여러분이 보는 워크스페이스, 이슈 목록, 댓글 스레드는 모두 이곳의 데이터베이스에 저장됩니다. 또한 여러분과 동료 사이의 실시간 업데이트를 푸시하는 WebSocket 허브이기도 합니다. 에이전트 작업은 **실행하지 않습니다.**
|
||||
- **데몬** — Multica CLI의 일부로, 여러분 자신의 기기에서 실행됩니다. 시작 시 로컬에 설치된 AI 코딩 도구를 감지하고, 서버에 등록한 다음, 3초마다 작업을 폴링하고 15초마다 하트비트를 전송하기 시작합니다.
|
||||
- **AI 코딩 도구** — 다음 열두 가지 중 하나(또는 여러 개를 병렬로): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). 데몬이 작업을 가져온 뒤에는 이러한 도구를 사용해 실제 작업을 수행합니다.
|
||||
- **AI 코딩 도구** — 다음 열두 가지 중 하나(또는 여러 개를 병렬로): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). 데몬이 작업을 가져온 뒤에는 이러한 도구를 사용해 실제 작업을 수행합니다.
|
||||
|
||||
도구 체인이 로컬에 유지되므로 **여러분의 API 키, 코드 디렉터리, 인증된 도구**는 오직 여러분의 기기에서만 사용됩니다. Multica 서버는 그중 어떤 것도 보지 못합니다. 이는 자체 호스팅을 하든 Cloud를 사용하든 동일하게 적용됩니다.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro
|
||||
|
||||
- **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks.
|
||||
- **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds.
|
||||
- **AI coding tools** — one of the twelve (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
|
||||
- **AI coding tools** — one of the twelve (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
|
||||
|
||||
Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
|
||||
|
||||
- **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub,把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。
|
||||
- **守护进程**(daemon)——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server,开始每 3 秒领一次任务、每 15 秒发一次心跳。
|
||||
- **AI 编程工具**——[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 12 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
- **AI 编程工具**——[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 12 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
|
||||
工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用;Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica は、人間と AI [エージェント](/agents)が同じ[ワークス
|
||||
|
||||
エージェントは Multica のサーバー上でタスクを実行**しません**。現在 Multica は 1 つのランタイムモデルをサポートしています。
|
||||
|
||||
- **ローカル[デーモン](/daemon-runtimes)** — 自分のマシンで `multica daemon` を実行すると、デーモンがローカルにインストールされた [AI コーディングツール](/providers)を駆動します。現在 12 種類が標準で組み込まれています: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。API キー、ツールチェーン、コードディレクトリはすべて自分のマシンに留まります。
|
||||
- **ローカル[デーモン](/daemon-runtimes)** — 自分のマシンで `multica daemon` を実行すると、デーモンがローカルにインストールされた [AI コーディングツール](/providers)を駆動します。現在 12 種類が標準で組み込まれています: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。API キー、ツールチェーン、コードディレクトリはすべて自分のマシンに留まります。
|
||||
|
||||
<Callout type="info">
|
||||
**クラウドランタイムが近日提供予定です。** 現在はウェイトリストのみで運用されています。提供が開始されればローカルデーモンは不要になり、エージェントのタスクは Multica Cloud 上で直接実行されます。[ダウンロード](https://multica.ai/download)ページで登録すると通知を受け取れます。
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica는 인간과 AI [에이전트](/agents)가 같은 [워크스페이스](/
|
||||
|
||||
에이전트는 Multica 서버에서 작업을 실행하지 **않습니다**. 현재 Multica는 하나의 런타임 모델을 지원합니다:
|
||||
|
||||
- **로컬 [데몬](/daemon-runtimes)** — 자신의 기기에서 `multica daemon`을 실행하면, 데몬이 로컬에 설치된 [AI 코딩 도구](/providers)를 구동합니다. 현재 열두 가지가 기본 내장되어 있습니다: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). API 키, 툴체인, 코드 디렉터리는 모두 자신의 기기에 머뭅니다.
|
||||
- **로컬 [데몬](/daemon-runtimes)** — 자신의 기기에서 `multica daemon`을 실행하면, 데몬이 로컬에 설치된 [AI 코딩 도구](/providers)를 구동합니다. 현재 열두 가지가 기본 내장되어 있습니다: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). API 키, 툴체인, 코드 디렉터리는 모두 자신의 기기에 머뭅니다.
|
||||
|
||||
<Callout type="info">
|
||||
**클라우드 런타임이 곧 제공됩니다.** 현재는 대기 명단으로만 운영됩니다. 출시되면 로컬 데몬이 필요 없어지며 — 에이전트 작업이 Multica Cloud에서 직접 실행됩니다. [다운로드](https://multica.ai/download) 페이지에서 등록하면 알림을 받을 수 있습니다.
|
||||
|
||||
@@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica.
|
||||
|
||||
Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model:
|
||||
|
||||
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Twelve are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
|
||||
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Twelve are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
|
||||
|
||||
<Callout type="info">
|
||||
**Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified.
|
||||
|
||||
@@ -7,13 +7,15 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在同一个 [工作区](/workspaces) 里共同工作。你可以像给同事派活一样,[把一个任务分配给智能体](/assigning-issues) ——由它去执行、汇报进展、在评论里回复你;也可以[打开聊天窗口直接和它对话](/chat),让它帮你起草任务、回答问题、或完成一次性请求。
|
||||
|
||||
<VideoEmbed provider="bilibili" id="BV1cv7Y6gEg7" title="Multica 中文介绍视频" />
|
||||
|
||||
这一页讲清楚智能体在哪里运行,以及你有哪几种方式开始使用 Multica。
|
||||
|
||||
## 智能体在哪里运行
|
||||
|
||||
智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式:
|
||||
|
||||
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
|
||||
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[CodeBuddy](/providers#codebuddy)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
|
||||
|
||||
<Callout type="info">
|
||||
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: エージェントランタイムをインストールする
|
||||
description: Multica はあなたのマシンにインストールされている AI コーディングツールを駆動します。このページでは、デーモンがそれらを検出できるように、サポートされている 12 種のツールをそれぞれインストールする方法を説明します。
|
||||
description: Multica はあなたのマシンにインストールされている AI コーディングツールを駆動します。このページでは、デーモンがそれらを検出できるように、サポートされている 13 種のツールをそれぞれインストールする方法を説明します。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica における**ランタイム**とは、あなたのマシンのデーモンと、デーモンが `PATH` で見つけた AI コーディングツール 1 つが組になったものです。オンボーディングの「ランタイムを接続」ステップで **No supported tools detected** と表示される場合、それはデーモンが `PATH` をスキャンしたものの、駆動方法を知っている 12 種のツールのいずれも見つけられなかったことを意味します。以下のツールのいずれか(または複数)をインストールしてから、そのステップに戻って再スキャンしてください — 数秒以内にランタイムが表示されます。
|
||||
Multica における**ランタイム**とは、あなたのマシンのデーモンと、デーモンが `PATH` で見つけた AI コーディングツール 1 つが組になったものです。オンボーディングの「ランタイムを接続」ステップで **No supported tools detected** と表示される場合、それはデーモンが `PATH` をスキャンしたものの、駆動方法を知っている 13 種のツールのいずれも見つけられなかったことを意味します。以下のツールのいずれか(または複数)をインストールしてから、そのステップに戻って再スキャンしてください — 数秒以内にランタイムが表示されます。
|
||||
|
||||
このページは次のドキュメントのインストール側の補完ドキュメントです。
|
||||
|
||||
@@ -31,13 +31,13 @@ multica daemon restart
|
||||
|
||||
または、デスクトップアプリではアプリを再起動するだけで構いません。デーモンは起動するたびに `PATH` を再スキャンします。
|
||||
|
||||
## サポートされている 12 種のツール
|
||||
## サポートされている 13 種のツール
|
||||
|
||||
おおよそ利用者の多い順に並べています。すでに認証情報を持っているものを選んで使ってください — 12 種すべてをインストールする必要はありません。
|
||||
おおよそ利用者の多い順に並べています。すでに認証情報を持っているものを選んで使ってください — 13 種すべてをインストールする必要はありません。
|
||||
|
||||
### Claude Code (Anthropic)
|
||||
|
||||
最も完全な連携です。セッション再開が動作し、MCP が動作し、**11 種のうちエージェントの `mcp_config` フィールドを実際に読み込む唯一のツール**です(詳しくは[マトリクス](/providers#mcp-configuration-only-claude-code-actually-reads-it)を参照)。
|
||||
最も完全な連携です。セッション再開が動作し、MCP が動作し、エージェントの `mcp_config` フィールドを消費します(詳しくは[マトリクス](/providers)を参照)。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -77,16 +77,6 @@ Cursor エディタに対応する CLI です。**セッション再開は動作
|
||||
| 認証 | CLI を通じたブラウザベースの GitHub ログイン。 |
|
||||
| 備考 | ログインしているアカウントに有効な GitHub Copilot サブスクリプションが必要です。 |
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
Gemini 2.5 および 3 シリーズをサポートします。セッション再開と MCP はありません — 単発のタスクに適しています。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| デーモンが探す名前 | `gemini` |
|
||||
| インストール | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) の公式ガイドに従ってください。標準的な方法は npm パッケージ `@google/gemini-cli` です。 |
|
||||
| 認証 | `gemini` を実行すると Google アカウントのログインを求められるか、`GEMINI_API_KEY` を設定してください。 |
|
||||
|
||||
### OpenCode (SST)
|
||||
|
||||
オープンソースの CLI エージェントです。独自の設定ファイルから利用可能なモデルを動的に発見します — 自分のモデルカタログを持ち込みたいユーザーによく合います。
|
||||
@@ -147,6 +137,26 @@ ACP プロトコルのエージェントです(Kimi とトランスポート
|
||||
| インストール | Inflection の CLI ドキュメント [pi.ai](https://pi.ai/) を参照してください。 |
|
||||
| 認証 | ベンダーのドキュメントに従います。 |
|
||||
|
||||
### CodeBuddy (Tencent)
|
||||
|
||||
Claude Code 互換の CLI エージェントです。Multica は Claude Code と同じ stream-json プロトコルで駆動します: セッション再開は `--resume` で動作し、MCP 構成は `--mcp-config` で渡され、スキルは `.claude/skills/` に配置されます。モデルは動的に探索されます。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| デーモンが探す名前 | `codebuddy` |
|
||||
| インストール | 公式 CLI ドキュメント [codebuddy.ai/cli](https://www.codebuddy.ai/cli) を参照してください。 |
|
||||
| 認証 | ベンダーのドキュメントに従います。 |
|
||||
|
||||
### Qoder (Alibaba)
|
||||
|
||||
stdio 上で ACP プロトコルを使用するエージェント型のコーディング CLI です(Hermes、Kimi、Kiro CLI とトランスポートを共有します)。セッション再開は ACP `session/resume` を通じて動作し、MCP 構成は ACP `mcpServers` として渡され、モデル選択は動的に探索され、スキルは `.qoder/skills/` にコピーされます。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| デーモンが探す名前 | `qodercli` |
|
||||
| インストール | 公式 CLI ドキュメント [qoder.com/cli](https://qoder.com/cli) を参照してください。 |
|
||||
| 認証 | ベンダーのドキュメントに従います。 |
|
||||
|
||||
### Antigravity (Google)
|
||||
|
||||
Google の Antigravity CLI(`agy`)です。Google の Antigravity サービスと組になり、Gemini ベースのモデルを実行します。セッション再開は `--conversation <id>` を通じて動作し、デーモンが CLI のログファイルからこれをキャプチャします。モデル選択は Antigravity CLI 自体の内部で管理されます — Multica はこのプロバイダーに対してエージェントごとのモデルピッカーを無効にします。スキルは `.agents/skills/` に書き込まれます(CLI が Gemini CLI のワークスペーススキルレイアウトを継承します — [Antigravity ドキュメント](https://antigravity.google/docs/gcli-migration)を参照)。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: 에이전트 런타임 설치하기
|
||||
description: Multica는 사용자 기기에 설치된 AI 코딩 도구를 구동합니다. 이 페이지에서는 데몬이 도구를 감지할 수 있도록 지원되는 12종의 도구를 각각 설치하는 방법을 설명합니다.
|
||||
description: Multica는 사용자 기기에 설치된 AI 코딩 도구를 구동합니다. 이 페이지에서는 데몬이 도구를 감지할 수 있도록 지원되는 13종의 도구를 각각 설치하는 방법을 설명합니다.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica에서 **런타임**이란 사용자 기기의 데몬과, 데몬이 `PATH`에서 찾아낸 AI 코딩 도구 하나가 짝을 이룬 것입니다. 온보딩의 "런타임 연결" 단계에서 **지원되는 도구를 감지하지 못했습니다**라고 표시된다면, 데몬이 `PATH`를 스캔했지만 구동 방법을 아는 12종의 도구 중 어느 것도 찾지 못했다는 뜻입니다. 아래 도구 중 하나(또는 여러 개)를 설치한 다음 해당 단계로 돌아와 다시 스캔하세요. 몇 초 안에 런타임이 나타납니다.
|
||||
Multica에서 **런타임**이란 사용자 기기의 데몬과, 데몬이 `PATH`에서 찾아낸 AI 코딩 도구 하나가 짝을 이룬 것입니다. 온보딩의 "런타임 연결" 단계에서 **지원되는 도구를 감지하지 못했습니다**라고 표시된다면, 데몬이 `PATH`를 스캔했지만 구동 방법을 아는 13종의 도구 중 어느 것도 찾지 못했다는 뜻입니다. 아래 도구 중 하나(또는 여러 개)를 설치한 다음 해당 단계로 돌아와 다시 스캔하세요. 몇 초 안에 런타임이 나타납니다.
|
||||
|
||||
이 페이지는 다음 문서의 설치 측면 동반 문서입니다.
|
||||
|
||||
@@ -31,9 +31,9 @@ multica daemon restart
|
||||
|
||||
또는 데스크톱 앱에서는 앱을 다시 실행하기만 하면 됩니다. 데몬은 시작될 때마다 `PATH`를 다시 스캔합니다.
|
||||
|
||||
## 지원되는 12종의 도구
|
||||
## 지원되는 13종의 도구
|
||||
|
||||
대략 많이 쓰이는 순서대로 나열했습니다. 이미 자격 증명을 갖고 있는 것을 골라 사용하세요. 12종을 모두 설치할 필요는 없습니다.
|
||||
대략 많이 쓰이는 순서대로 나열했습니다. 이미 자격 증명을 갖고 있는 것을 골라 사용하세요. 13종을 모두 설치할 필요는 없습니다.
|
||||
|
||||
### Claude Code (Anthropic)
|
||||
|
||||
@@ -77,16 +77,6 @@ Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니
|
||||
| 인증 | CLI를 통한 브라우저 기반 GitHub 로그인. |
|
||||
| 비고 | 로그인한 계정에 활성화된 GitHub Copilot 구독이 필요합니다. |
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
Gemini 2.5 및 3 시리즈를 지원합니다. 세션 재개와 MCP는 없습니다 — 단발성 작업에 적합합니다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 데몬이 찾는 이름 | `gemini` |
|
||||
| 설치 | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)의 공식 가이드를 따르세요. 일반적인 방법은 npm 패키지 `@google/gemini-cli`입니다. |
|
||||
| 인증 | `gemini`를 실행하면 Google 계정 로그인을 요청하거나, `GEMINI_API_KEY`를 설정하세요. |
|
||||
|
||||
### OpenCode (SST)
|
||||
|
||||
오픈 소스 CLI 에이전트입니다. 자체 설정 파일에서 사용 가능한 모델을 동적으로 발견합니다 — 자신만의 모델 카탈로그를 직접 가져오려는 사용자에게 잘 맞습니다. `OPENCODE_CONFIG_CONTENT`를 통해 에이전트의 `mcp_config` 필드도 소비합니다.
|
||||
@@ -147,6 +137,26 @@ ACP 프로토콜 에이전트입니다(Kimi와 전송 방식을 공유). 세션
|
||||
| 설치 | Inflection의 CLI 문서 [pi.ai](https://pi.ai/)를 참고하세요. |
|
||||
| 인증 | 공급사 문서에 따릅니다. |
|
||||
|
||||
### CodeBuddy (Tencent)
|
||||
|
||||
Claude Code 호환 CLI 에이전트입니다. Multica는 Claude Code와 동일한 stream-json 프로토콜로 구동합니다: 세션 재개는 `--resume`로 동작하고, MCP 구성은 `--mcp-config`로 전달되며, 스킬은 `.claude/skills/`에 배치됩니다. 모델은 동적으로 탐색됩니다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 데몬이 찾는 이름 | `codebuddy` |
|
||||
| 설치 | 공식 CLI 문서 [codebuddy.ai/cli](https://www.codebuddy.ai/cli)를 참고하세요. |
|
||||
| 인증 | 공급사 문서에 따릅니다. |
|
||||
|
||||
### Qoder (Alibaba)
|
||||
|
||||
stdio 위에서 ACP 프로토콜을 사용하는 에이전트형 코딩 CLI입니다(Hermes, Kimi, Kiro CLI와 전송 계층을 공유합니다). 세션 재개는 ACP `session/resume`를 통해 동작하고, MCP 구성은 ACP `mcpServers`로 전달되며, 모델 선택은 동적으로 탐색되고, 스킬은 `.qoder/skills/`로 복사됩니다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 데몬이 찾는 이름 | `qodercli` |
|
||||
| 설치 | 공식 CLI 문서 [qoder.com/cli](https://qoder.com/cli)를 참고하세요. |
|
||||
| 인증 | 공급사 문서에 따릅니다. |
|
||||
|
||||
### Antigravity (Google)
|
||||
|
||||
Google의 Antigravity CLI(`agy`)입니다. Google의 Antigravity 서비스와 짝을 이루며 Gemini 기반 모델을 실행합니다. 세션 재개는 `--conversation <id>`를 통해 작동하며, 데몬이 CLI 로그 파일에서 이를 캡처합니다. 모델 선택은 Antigravity CLI 자체 내부에서 관리됩니다 — Multica는 이 제공자에 대해 에이전트별 모델 선택기를 비활성화합니다. 스킬은 `.agents/skills/`에 기록됩니다(CLI가 Gemini CLI의 워크스페이스 스킬 레이아웃을 상속함 — [Antigravity 문서](https://antigravity.google/docs/gcli-migration) 참고).
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: Install an agent runtime
|
||||
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 12 supported tools so the daemon can detect them.
|
||||
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 13 supported tools so the daemon can detect them.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 12 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
|
||||
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 13 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
|
||||
|
||||
This page is the install-side companion to:
|
||||
|
||||
@@ -31,9 +31,9 @@ multica daemon restart
|
||||
|
||||
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
|
||||
|
||||
## The 12 supported tools
|
||||
## The 13 supported tools
|
||||
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 12.
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 13.
|
||||
|
||||
### Claude Code (Anthropic)
|
||||
|
||||
@@ -77,16 +77,6 @@ Model routing goes through your GitHub account entitlement — the tool doesn't
|
||||
| Authentication | Browser-based GitHub login through the CLI. |
|
||||
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `gemini` |
|
||||
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
|
||||
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
|
||||
|
||||
### OpenCode (SST)
|
||||
|
||||
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog. Consumes the agent's `mcp_config` field through `OPENCODE_CONFIG_CONTENT`.
|
||||
@@ -147,16 +137,36 @@ Minimalist. **Session resumption is unusual** — the resume id is the path to a
|
||||
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
### CodeBuddy (Tencent)
|
||||
|
||||
A Claude Code–compatible CLI agent. Multica drives it with the same stream-json protocol as Claude Code: session resumption works via `--resume`, MCP config is passed through `--mcp-config`, and skills land in `.claude/skills/`. Models are discovered dynamically.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `codebuddy` |
|
||||
| Install | See the official CLI docs at [codebuddy.ai/cli](https://www.codebuddy.ai/cli). |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
### Qoder (Alibaba)
|
||||
|
||||
Agentic coding CLI using the ACP protocol over stdio (shares the transport with Hermes, Kimi, and Kiro CLI). Session resumption works through ACP `session/resume`, MCP config is passed through ACP `mcpServers`, model selection is discovered dynamically, and skills are copied into `.qoder/skills/`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `qodercli` |
|
||||
| Install | See the official CLI docs at [qoder.com/cli](https://qoder.com/cli). |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
### Antigravity (Google)
|
||||
|
||||
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
|
||||
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Multica launches it with `agy -p`, the daemon-compatible non-interactive mode; current Antigravity CLI releases can execute tools from that mode, while `agy -i` requires an attached TTY. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `agy` |
|
||||
| Install | Follow the official guide at [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview). The CLI ships pre-built — run `agy install` once to wire up PATH and shell aliases. |
|
||||
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
|
||||
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text. |
|
||||
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text, and per-tool telemetry is not available today. |
|
||||
|
||||
## After installing
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: 安装一个 Agent 运行时
|
||||
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 12 款工具,让守护进程能扫到。
|
||||
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 13 款工具,让守护进程能扫到。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 Multica 里,一个**运行时**(runtime)就是你机器上的守护进程,配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 12 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
|
||||
在 Multica 里,一个**运行时**(runtime)就是你机器上的守护进程,配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 13 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
|
||||
|
||||
这一页是装机的入口,和它配套的是:
|
||||
|
||||
@@ -31,9 +31,9 @@ multica daemon restart
|
||||
|
||||
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
|
||||
|
||||
## 12 款支持的工具
|
||||
## 13 款支持的工具
|
||||
|
||||
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 12 个全装。
|
||||
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 13 个全装。
|
||||
|
||||
### Claude Code(Anthropic)
|
||||
|
||||
@@ -77,16 +77,6 @@ Cursor 编辑器的 CLI 对应物。**会话续接可用**——当前 Cursor Ag
|
||||
| 认证 | CLI 里走 GitHub 浏览器登录。 |
|
||||
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
|
||||
|
||||
### Gemini(Google)
|
||||
|
||||
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `gemini` |
|
||||
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
|
||||
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
|
||||
|
||||
### OpenCode(SST)
|
||||
|
||||
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。会通过 `OPENCODE_CONFIG_CONTENT` 消费 agent 配置里的 `mcp_config` 字段。
|
||||
@@ -147,16 +137,36 @@ ACP 协议 agent(和 Kimi 共享传输层)。会话续接可用,MCP 配置
|
||||
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
### CodeBuddy(Tencent)
|
||||
|
||||
一款兼容 Claude Code 的 CLI agent。Multica 用和 Claude Code 一样的 stream-json 协议驱动它:会话续接通过 `--resume` 可用,MCP 配置通过 `--mcp-config` 传入,skill 放在 `.claude/skills/`。模型为动态发现。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `codebuddy` |
|
||||
| 安装 | 看官方 CLI 文档 [codebuddy.ai/cli](https://www.codebuddy.ai/cli)。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
### Qoder(Alibaba)
|
||||
|
||||
一款 agentic 编程 CLI,在 stdio 上使用 ACP 协议(和 Hermes、Kimi、Kiro CLI 共享传输层)。会话续接通过 ACP `session/resume` 工作,MCP 配置通过 ACP `mcpServers` 传入,模型为动态发现,skill 复制到 `.qoder/skills/`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `qodercli` |
|
||||
| 安装 | 看官方 CLI 文档 [qoder.com/cli](https://qoder.com/cli)。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
### Antigravity(Google)
|
||||
|
||||
Google 的 Antigravity CLI(`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
|
||||
Google 的 Antigravity CLI(`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动它,这是适合 daemon 后台任务的一次性非交互模式;当前 Antigravity CLI 在这个模式下仍可执行工具,而 `agy -i` 需要连接 TTY,不适合 daemon 驱动。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `agy` |
|
||||
| 安装 | 看官方指引 [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview)。CLI 是预编译的,跑一次 `agy install` 配好 PATH 和 shell 别名即可。 |
|
||||
| 认证 | 交互式跑一次 `agy` 走 Google 账号登录流程;或者通过 Antigravity 桌面端登录——CLI 会复用 GUI 写入 keyring 的凭据。 |
|
||||
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 思考过程和最终回复都会作为 text 消息送回 Multica。 |
|
||||
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 过程和最终回复都会作为 text 消息送回 Multica,目前无法展示 Antigravity 的逐工具 telemetry。 |
|
||||
|
||||
## 装完之后
|
||||
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---インボックス---",
|
||||
"inbox",
|
||||
"---連携---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---セルフホスト & 運用---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---Inbox---",
|
||||
"inbox",
|
||||
"---Integrations---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---Self-hosting & ops---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"---인박스---",
|
||||
"inbox",
|
||||
"---연동---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---자체 호스팅 & 운영---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"squads",
|
||||
"---智能体怎么运行---",
|
||||
"daemon-runtimes",
|
||||
"install-agent-runtime",
|
||||
"tasks",
|
||||
"providers",
|
||||
"---与智能体协作---",
|
||||
@@ -29,8 +30,10 @@
|
||||
"---收件箱---",
|
||||
"inbox",
|
||||
"---集成---",
|
||||
"channels",
|
||||
"github-integration",
|
||||
"lark-bot-integration",
|
||||
"slack-bot-integration",
|
||||
"---自部署运维---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -28,12 +28,15 @@ The default resource type — checked out per task into an isolated worktree:
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": {
|
||||
"url": "https://github.com/owner/repo",
|
||||
"ref": "release/v2",
|
||||
"default_branch_hint": "main"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
|
||||
`ref` is optional — if present, `multica repo checkout <url>` uses it as the default branch, tag, or commit for tasks in this project. An explicit `multica repo checkout <url> --ref <other-ref>` still wins for that one checkout.
|
||||
|
||||
`default_branch_hint` is optional prompt context. It is not used for checkout; use `ref` when the project should pin a branch, tag, or SHA.
|
||||
|
||||
## Resource type: `local_directory`
|
||||
|
||||
@@ -168,6 +171,7 @@ multica project create \
|
||||
# Manage resources later
|
||||
multica project resource list <project-id>
|
||||
multica project resource add <project-id> --type github_repo --url <url>
|
||||
multica project resource add <project-id> --type github_repo --url <url> --ref <branch-or-sha>
|
||||
multica project resource remove <project-id> <resource-id>
|
||||
|
||||
# Generic escape hatch for any resource_type the server understands —
|
||||
@@ -251,7 +255,7 @@ The repo list shown to the agent (`## Repositories` block in `CLAUDE.md` / `AGEN
|
||||
|
||||
This keeps the agent's working set tight: when a project is explicit about its repos, that's the authoritative answer. The structured resource list at `.multica/project/resources.json` always carries the full set, so a skill that wants to inspect everything still can.
|
||||
|
||||
The daemon mirrors this on the checkout side: when a task arrives with project-scoped `github_repo` URLs, those URLs are merged into the per-workspace allowlist *and* synced into the local repo cache before the agent spawns. So a project repo URL that isn't bound at the workspace level is still a valid argument to `multica repo checkout` — the daemon won't reject it as "not configured." The allowlist split is internal: workspace-bound URLs and task-scoped URLs are tracked separately, so a workspace-repos refresh doesn't accidentally revoke a project URL mid-run.
|
||||
The daemon mirrors this on the checkout side: when a task arrives with project-scoped `github_repo` URLs, those URLs are merged into the per-workspace allowlist *and* synced into the local repo cache before the agent spawns. So a project repo URL that isn't bound at the workspace level is still a valid argument to `multica repo checkout` — the daemon won't reject it as "not configured." If the project resource includes `ref`, that ref becomes the default for `multica repo checkout <url>` during that task; passing `--ref` to the checkout command overrides it. The allowlist split is internal: workspace-bound URLs and task-scoped URLs are tracked separately, so a workspace-repos refresh doesn't accidentally revoke a project URL mid-run.
|
||||
|
||||
## What's intentionally **not** in scope here
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: AI コーディングツール対応表
|
||||
description: Multica は 12 個の AI コーディングツールをサポートしています。すべて同じインターフェースを実装していますが、機能の詳細は大きく異なります。
|
||||
description: Multica は 13 個の AI コーディングツールをサポートしています。すべて同じインターフェースを実装していますが、機能の詳細は大きく異なります。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica は **12 個の AI コーディングツール**を標準でサポートしています。これらはすべて同じインターフェース(キューへの投入、ディスパッチ、実行、結果の返却)を実装しているため、同じ Multica ボードからどれでも動かすことができます。**しかし機能の詳細は大きく異なります**: セッション再開が実際に動作するか、MCP をサポートするか、スキルファイルがどこに置かれるか、モデルをどう選択するか。このページがその完全な対応表です。
|
||||
Multica は **13 個の AI コーディングツール**を標準でサポートしています。これらはすべて同じインターフェース(キューへの投入、ディスパッチ、実行、結果の返却)を実装しているため、同じ Multica ボードからどれでも動かすことができます。**しかし機能の詳細は大きく異なります**: セッション再開が実際に動作するか、MCP をサポートするか、スキルファイルがどこに置かれるか、モデルをどう選択するか。このページがその完全な対応表です。
|
||||
|
||||
エージェントを作成するときにツールを選ぶ際のガイダンスは、[エージェントの作成と構成](/agents-create)を参照してください。
|
||||
|
||||
@@ -15,16 +15,17 @@ Multica は **12 個の AI コーディングツール**を標準でサポート
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 動的探索(`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静的 + flag |
|
||||
| **CodeBuddy** | Tencent | ✅ | ✅ | `.claude/skills/` | 動的探索 |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静的 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静的(アカウントの権限で決定) |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 動的探索 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静的 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/`(フォールバック) | 動的探索 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 動的探索 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | 動的探索 |
|
||||
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | 動的探索 + variant |
|
||||
| **OpenClaw** | オープンソース | ✅ | ✅ | `.agent_context/skills/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
|
||||
| **Pi** | Inflection AI | ✅(セッションがファイルパス) | ❌ | `.pi/skills/` | 動的探索 |
|
||||
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | 動的探索 |
|
||||
|
||||
## 各ツールの用途
|
||||
|
||||
@@ -36,6 +37,10 @@ Google が提供します。CLI バイナリ名は `agy` です。Google の Ant
|
||||
|
||||
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、MCP 構成を読み取り、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
|
||||
|
||||
### CodeBuddy
|
||||
|
||||
Tencent が提供します。Claude Code 互換の CLI エージェントです — Multica は Claude Code と同じ stream-json プロトコルで駆動するため、セッション再開が動作し(`--resume` 経由)、MCP 構成は `--mcp-config` で渡され、スキルは Claude Code の `.claude/skills/` レイアウトを使用します。モデルは動的に探索されます。
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI が提供します。JSON-RPC 2.0 を使用し、ステートフルな能力がより強く、よりきめ細かい承認メカニズム(`exec_command` および `patch_apply` に対する手動承認)を備えています。MCP 構成はタスクごとの `$CODEX_HOME/config.toml` に書き込まれます。**セッション再開は動作します** — Multica は Codex app-server の `thread/resume` で再開します。保存済み thread が見つからない、または古い場合は、新しい thread にフォールバックしてタスクを続行します。
|
||||
@@ -48,14 +53,18 @@ GitHub が提供します。モデルルーティングは GitHub アカウン
|
||||
|
||||
Anysphere が提供し、Cursor エディターに対応する CLI です。**セッション再開は動作します** — 現在の Cursor Agent の stream-json イベントには `session_id` が含まれ、Multica は次回実行時に `--resume <id>` でそれを渡します。MCP 構成はタスクワークスペースの `.cursor/mcp.json` に書き込まれ、Cursor のプロジェクト approval ファイルはタスクごとの `CURSOR_DATA_DIR` 配下に置かれるため、管理対象 MCP server はユーザーのグローバル Cursor approvals に依存しません。
|
||||
|
||||
### Gemini
|
||||
|
||||
Google が提供し、Gemini 2.5 および 3 シリーズをサポートします。**セッション再開も MCP もサポートしません** — 長いコンテキストの記憶が不要なワンショットタスクに適しています。
|
||||
|
||||
### Hermes
|
||||
|
||||
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作し、MCP 構成は ACP `mcpServers` として渡されます。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**(`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
|
||||
|
||||
**Hermes profile を選択する。** 特定の profile で Hermes を起動するには、エージェントの `custom_args` に profile フラグと profile 名を 2 つの独立したエントリとして設定します。たとえば `research` という profile を使う場合:
|
||||
|
||||
```json
|
||||
["-p", "research"]
|
||||
```
|
||||
|
||||
`"-p research"` のように 1 つの文字列へまとめないでください。Multica は配列の各要素を 1 つの argv エントリとしてツールへ渡します。`custom_args` はエージェントごとに設定します — [エージェントの作成と構成](/agents-create)を参照してください。
|
||||
|
||||
### Kimi
|
||||
|
||||
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有し、MCP 構成も ACP `mcpServers` として渡されますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
|
||||
@@ -76,22 +85,19 @@ SST が提供するオープンソースです。利用可能なモデルと mod
|
||||
|
||||
Inflection AI が提供し、ミニマルです。**セッション再開の方式が独特です** — セッション ID が文字列 ID ではなく、ディスク上のファイルパス(`~/.pi/...`)です。他のツールでは再開 id は CLI が返す文字列ですが、Pi では再開 id はセッションファイルそのものです。
|
||||
|
||||
### Qoder
|
||||
|
||||
Alibaba が提供します。エージェント型のコーディング CLI です。stdio 上で ACP プロトコルを使用します(Hermes、Kimi、Kiro CLI とトランスポートを共有します)。セッション再開は ACP `session/resume` を通じて動作し、MCP 構成は ACP `mcpServers` として渡され、モデル選択は動的に探索され、スキルはネイティブ探索のために `.qoder/skills/` にコピーされます。
|
||||
|
||||
## セッション再開: 実際にサポートするツール
|
||||
|
||||
セッション再開のメカニズムは[タスク](/tasks#can-a-task-continue-from-the-previous-context)で扱います。以下はツールごとの**正確な現在の状態**です。
|
||||
|
||||
| 状態 | ツール | 意味 |
|
||||
|---|---|---|
|
||||
| ✅ 実際に動作 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 再開 id を渡すと以前のコンテキストから続行します |
|
||||
| ❌ なし | Gemini | CLI に再開メカニズムがありません |
|
||||
|
||||
**意思決定のために**: ワークフローでエージェントがタスク間でコンテキストを保持する必要がある場合(失敗時のリトライ、手動の再実行、対話的な反復)、✅ の行にあるツールだけを選んでください。
|
||||
セッション再開のメカニズムは[タスク](/tasks#can-a-task-continue-from-the-previous-context)で扱います。**サポートされているすべてのツールがセッションを再開できます** — 再開 id を渡すと、タスクは以前のコンテキストから続行します。唯一の例外は Pi で、再開 id が文字列 ID ではなくディスク上のセッションファイルへのパスです(上記の [Pi](#pi) を参照)。
|
||||
|
||||
## MCP 構成: ツールごとの対応
|
||||
|
||||
**12 個のツールのうち、`mcp_config` を実際に消費するのは 8 個です: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。残りの 4 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
|
||||
**13 個のツールのうち、`mcp_config` を実際に消費するのは 10 個です: Claude Code、CodeBuddy、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Qoder**。残りの 3 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
|
||||
|
||||
接続方式はツールごとに異なります: Claude Code は `--mcp-config` と `--strict-mcp-config` で受け取り、Codex は daemon 管理の `mcp_servers` ブロックをタスクごとの `$CODEX_HOME/config.toml` に書き込み、Cursor は `.cursor/mcp.json` とタスクごとの `CURSOR_DATA_DIR` 配下のプロジェクト approval を書き込みます。Hermes、Kimi、Kiro CLI は ACP `mcpServers` で受け取ります。OpenCode は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン構成を受け取り、OpenClaw は Multica のタスクごとの config wrapper 経由で `mcp.servers` を受け取ります。OpenCode の経路はプロジェクトの `opencode.json` を書き換えません。
|
||||
接続方式はツールごとに異なります: Claude Code と CodeBuddy は `--mcp-config` と `--strict-mcp-config` で受け取り、Codex は daemon 管理の `mcp_servers` ブロックをタスクごとの `$CODEX_HOME/config.toml` に書き込み、Cursor は `.cursor/mcp.json` とタスクごとの `CURSOR_DATA_DIR` 配下のプロジェクト approval を書き込みます。Hermes、Kimi、Kiro CLI、Qoder は ACP `mcpServers` で受け取ります。OpenCode は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン構成を受け取り、OpenClaw は Multica のタスクごとの config wrapper 経由で `mcp.servers` を受け取ります。OpenCode の経路はプロジェクトの `opencode.json` を書き換えません。
|
||||
|
||||
<Callout type="warning">
|
||||
エージェント構成で `mcp_config` を設定しても、MCP 列に ✅ がないツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。MCP 連携はツールごとに実装されています。
|
||||
@@ -104,6 +110,7 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
|
||||
| ツール | パス | ネイティブ探索か |
|
||||
|---|---|---|
|
||||
| Claude Code | `.claude/skills/` | ✅ ネイティブ |
|
||||
| CodeBuddy | `.claude/skills/` | ✅ ネイティブ |
|
||||
| Codex | `$CODEX_HOME/skills/` | ✅ ネイティブ |
|
||||
| Copilot | `.github/skills/` | ✅ ネイティブ |
|
||||
| Cursor | `.cursor/skills/` | ✅ ネイティブ |
|
||||
@@ -111,12 +118,12 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ ネイティブ |
|
||||
| OpenCode | `.opencode/skills/` | ✅ ネイティブ |
|
||||
| Pi | `.pi/skills/` | ✅ ネイティブ |
|
||||
| Qoder | `.qoder/skills/` | ✅ ネイティブ |
|
||||
| Antigravity | `.agents/skills/` | ✅ ネイティブ(Gemini CLI のワークスペースレイアウトを継承 — [Antigravity ドキュメント](https://antigravity.google/docs/gcli-migration)を参照) |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 汎用フォールバック |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 汎用フォールバック |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ 汎用フォールバック |
|
||||
|
||||
フォールバックパスを使うツールが実際にこのディレクトリを読み取るかどうかは、そのツール自体のドキュメントによって異なり、保証されません。Gemini / Hermes / OpenClaw でスキルが適用されない場合は、まずこの点を確認してください。
|
||||
フォールバックパスを使うツールが実際にこのディレクトリを読み取るかどうかは、そのツール自体のドキュメントによって異なり、保証されません。Hermes / OpenClaw でスキルが適用されない場合は、まずこの点を確認してください。
|
||||
|
||||
ネイティブなプロジェクトレベルのパスでは、リポジトリスコープの探索は想定された挙動です。チェックアウトされたリポジトリが対応するディレクトリをすでに含んでいる場合、基盤となるツールはそのコミット済みスキルを自分で検出できます。そのリポジトリで使うためだけに、これらの repo skills を Multica へインポートする必要はありません。Multica はそれらのリポジトリファイルをそのまま保持します。ワークスペーススキルの自然なディレクトリ名が同じ場合、デーモンは `review-helper-multica` のような衝突しない兄弟ディレクトリへワークスペースコピーを書き込みます。
|
||||
|
||||
@@ -127,4 +134,4 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
|
||||
- [エージェントの作成と構成](/agents-create) — エージェントに使うツールを選ぶ
|
||||
- [タスク](/tasks) — タスクのライフサイクルとセッション再開のメカニズム
|
||||
- [デーモンとランタイム](/daemon-runtimes) — ツールが実行される場所と Multica への接続方法
|
||||
- [エージェントランタイムのインストール](/install-agent-runtime) — サポートされる 12 個のツールそれぞれのインストールと認証
|
||||
- [エージェントランタイムのインストール](/install-agent-runtime) — サポートされる 13 個のツールそれぞれのインストールと認証
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: AI 코딩 도구 대조표
|
||||
description: Multica는 12개의 AI 코딩 도구를 지원합니다. 모두 동일한 인터페이스를 구현하지만, 기능 세부사항은 크게 다릅니다.
|
||||
description: Multica는 13개의 AI 코딩 도구를 지원합니다. 모두 동일한 인터페이스를 구현하지만, 기능 세부사항은 크게 다릅니다.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은 모두 동일한 인터페이스(대기열 적재, 디스패치, 실행, 결과 반환)를 구현하므로, 같은 Multica 보드에서 어느 것이든 구동할 수 있습니다. **하지만 기능 세부사항은 크게 다릅니다**: 세션 재개가 실제로 동작하는지, MCP를 지원하는지, 스킬 파일이 어디에 위치하는지, 모델을 어떻게 선택하는지. 이 페이지가 전체 대조표입니다.
|
||||
Multica는 **13개의 AI 코딩 도구**를 기본 지원합니다. 이들은 모두 동일한 인터페이스(대기열 적재, 디스패치, 실행, 결과 반환)를 구현하므로, 같은 Multica 보드에서 어느 것이든 구동할 수 있습니다. **하지만 기능 세부사항은 크게 다릅니다**: 세션 재개가 실제로 동작하는지, MCP를 지원하는지, 스킬 파일이 어디에 위치하는지, 모델을 어떻게 선택하는지. 이 페이지가 전체 대조표입니다.
|
||||
|
||||
에이전트를 생성할 때 도구를 고르는 방법은 [에이전트 생성 및 구성](/agents-create)을 참고하세요.
|
||||
|
||||
@@ -15,16 +15,17 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 동적 탐색(`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 정적 + flag |
|
||||
| **CodeBuddy** | Tencent | ✅ | ✅ | `.claude/skills/` | 동적 탐색 |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 정적 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 정적 (계정 권한으로 결정) |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 동적 탐색 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 정적 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | 동적 탐색 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 동적 탐색 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | 동적 탐색 |
|
||||
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | 동적 탐색 + variant |
|
||||
| **OpenClaw** | 오픈소스 | ✅ | ✅ | `.agent_context/skills/` (fallback) | 에이전트에 바인딩되어 작업마다 전환 불가 |
|
||||
| **Pi** | Inflection AI | ✅ (세션이 파일 경로) | ❌ | `.pi/skills/` | 동적 탐색 |
|
||||
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | 동적 탐색 |
|
||||
|
||||
## 각 도구의 용도
|
||||
|
||||
@@ -36,6 +37,10 @@ Google에서 제공합니다. CLI 바이너리 이름은 `agy`입니다. Google
|
||||
|
||||
Anthropic에서 제공합니다. **신규 사용자에게 첫 번째 선택지**이며, 가장 완전한 기능 세트를 갖추고 있습니다: 세션 재개가 실제로 동작하고, MCP 구성을 읽으며, `--max-turns`와 `--append-system-prompt` 같은 세부 조정 flag를 지원합니다. Anthropic API 키가 필요합니다.
|
||||
|
||||
### CodeBuddy
|
||||
|
||||
Tencent에서 제공합니다. Claude Code 호환 CLI 에이전트입니다 — Multica는 Claude Code와 동일한 stream-json 프로토콜로 구동하므로 세션 재개가 동작하고(`--resume` 경유), MCP 구성은 `--mcp-config`로 전달되며, 스킬은 Claude Code의 `.claude/skills/` 레이아웃을 사용합니다. 모델은 동적으로 탐색됩니다.
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개합니다. 저장된 thread가 없거나 오래된 경우에는 새 thread로 폴백해 작업을 계속 실행합니다.
|
||||
@@ -48,14 +53,18 @@ GitHub에서 제공합니다. 모델 라우팅은 GitHub 계정 권한을 거칩
|
||||
|
||||
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent의 stream-json 이벤트에는 `session_id`가 포함되며, Multica는 다음 실행 때 이를 `--resume <id>`로 다시 전달합니다. MCP 구성은 작업 워크스페이스의 `.cursor/mcp.json`에 기록되고, Cursor의 프로젝트 approval 파일은 작업별 `CURSOR_DATA_DIR` 아래에 기록되므로, 관리되는 MCP 서버는 사용자의 전역 Cursor approval에 의존하지 않습니다.
|
||||
|
||||
### Gemini
|
||||
|
||||
Google에서 제공하며, Gemini 2.5 및 3 시리즈를 지원합니다. **세션 재개도 MCP도 지원하지 않습니다** — 긴 컨텍스트 기억이 필요 없는 일회성 작업에 적합합니다.
|
||||
|
||||
### Hermes
|
||||
|
||||
Nous Research에서 제공합니다. ACP 프로토콜을 사용합니다(Kimi와 전송 계층을 공유합니다). 세션 재개가 동작하고, MCP 구성은 ACP `mcpServers`로 전달됩니다. 하지만 **스킬 주입 경로는 전용 경로가 아니라 범용 fallback**(`.agent_context/skills/`)입니다 — Hermes CLI 자체가 이 경로를 읽지 않으면 스킬이 적용되지 않을 수 있습니다. 테스트로 확인하세요.
|
||||
|
||||
**Hermes profile 선택.** 특정 profile로 Hermes를 실행하려면 에이전트의 `custom_args`에 profile 플래그와 profile 이름을 두 개의 독립된 항목으로 설정하세요. 예를 들어 `research`라는 profile을 사용하려면:
|
||||
|
||||
```json
|
||||
["-p", "research"]
|
||||
```
|
||||
|
||||
`"-p research"`처럼 하나의 문자열로 합치지 마세요. Multica는 배열의 각 항목을 하나의 argv 항목으로 도구에 전달합니다. `custom_args`는 에이전트별로 설정합니다 — [에이전트 생성 및 구성](/agents-create)을 참고하세요.
|
||||
|
||||
### Kimi
|
||||
|
||||
Moonshot에서 제공하며, 중국 시장을 겨냥합니다. Hermes와 ACP 프로토콜을 공유하고 MCP 구성도 ACP `mcpServers`로 전달되지만, 스킬 경로 `.kimi/skills/`는 Kimi CLI의 기본 탐색 메커니즘으로 Hermes의 fallback과는 다릅니다.
|
||||
@@ -76,22 +85,19 @@ SST에서 제공하는 오픈소스입니다. 사용 가능한 모델과 모델
|
||||
|
||||
Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이 특이합니다** — 세션 ID가 문자열 ID가 아니라 디스크상의 파일 경로(`~/.pi/...`)입니다. 다른 도구에서는 재개 id가 CLI가 반환하는 문자열이지만, Pi에서는 재개 id가 세션 파일 그 자체입니다.
|
||||
|
||||
### Qoder
|
||||
|
||||
Alibaba에서 제공합니다. 에이전트형 코딩 CLI입니다. stdio 위에서 ACP 프로토콜을 사용합니다(Hermes, Kimi, Kiro CLI와 전송 계층을 공유합니다). 세션 재개는 ACP `session/resume`를 통해 동작하고, MCP 구성은 ACP `mcpServers`로 전달되며, 모델 선택은 동적으로 탐색되고, 스킬은 네이티브 탐색을 위해 `.qoder/skills/`로 복사됩니다.
|
||||
|
||||
## 세션 재개: 실제로 지원하는 도구
|
||||
|
||||
세션 재개 메커니즘은 [작업](/tasks#can-a-task-continue-from-the-previous-context)에서 다룹니다. 다음은 도구별 **정확한 현재 상태**입니다:
|
||||
|
||||
| 상태 | 도구 | 의미 |
|
||||
|---|---|---|
|
||||
| ✅ 실제로 동작 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
|
||||
| ❌ 없음 | Gemini | CLI에 재개 메커니즘이 없습니다 |
|
||||
|
||||
**의사결정을 위해**: 워크플로에서 에이전트가 작업 간에 컨텍스트를 유지해야 한다면(실패 재시도, 수동 재실행, 대화형 반복), ✅ 행에 있는 도구만 선택하세요.
|
||||
세션 재개 메커니즘은 [작업](/tasks#can-a-task-continue-from-the-previous-context)에서 다룹니다. **지원되는 모든 도구가 세션을 재개합니다** — 재개 id를 전달하면 작업이 이전 컨텍스트에서 이어집니다. 유일한 예외는 Pi로, 재개 id가 문자열 ID가 아니라 디스크상의 세션 파일 경로입니다(위의 [Pi](#pi) 참고).
|
||||
|
||||
## MCP 구성: 도구별 지원
|
||||
|
||||
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 8개입니다: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 4개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
|
||||
**13개 도구 중 `mcp_config`를 실제로 소비하는 것은 10개입니다: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Qoder**. 나머지 3개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
|
||||
|
||||
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Cursor는 `.cursor/mcp.json`과 작업별 `CURSOR_DATA_DIR` 아래의 프로젝트 approval을 기록합니다. Hermes/Kimi/Kiro CLI는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
|
||||
각 도구의 연결 방식은 다릅니다: Claude Code와 CodeBuddy는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Cursor는 `.cursor/mcp.json`과 작업별 `CURSOR_DATA_DIR` 아래의 프로젝트 approval을 기록합니다. Hermes/Kimi/Kiro CLI/Qoder는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
|
||||
|
||||
<Callout type="warning">
|
||||
에이전트 구성에서 `mcp_config`를 설정했더라도 MCP 열에 ✅가 없는 도구를 선택하면, MCP 서버가 해당 에이전트에 **아무런 효과**도 미치지 않습니다. MCP 연동은 도구별로 구현됩니다.
|
||||
@@ -104,6 +110,7 @@ Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이
|
||||
| 도구 | 경로 | 기본 탐색 여부 |
|
||||
|---|---|---|
|
||||
| Claude Code | `.claude/skills/` | ✅ 기본 |
|
||||
| CodeBuddy | `.claude/skills/` | ✅ 기본 |
|
||||
| Codex | `$CODEX_HOME/skills/` | ✅ 기본 |
|
||||
| Copilot | `.github/skills/` | ✅ 기본 |
|
||||
| Cursor | `.cursor/skills/` | ✅ 기본 |
|
||||
@@ -111,12 +118,12 @@ Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ 기본 |
|
||||
| OpenCode | `.opencode/skills/` | ✅ 기본 |
|
||||
| Pi | `.pi/skills/` | ✅ 기본 |
|
||||
| Qoder | `.qoder/skills/` | ✅ 기본 |
|
||||
| Antigravity | `.agents/skills/` | ✅ 기본 (Gemini CLI의 워크스페이스 레이아웃을 따름 — [Antigravity 문서](https://antigravity.google/docs/gcli-migration) 참고) |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 범용 fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 범용 fallback |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ 범용 fallback |
|
||||
|
||||
fallback 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는 해당 도구 자체의 문서에 따라 달라지며 — 보장되지 않습니다. Gemini / Hermes / OpenClaw에서 스킬이 적용되지 않는다면, 먼저 이 점을 확인하세요.
|
||||
fallback 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는 해당 도구 자체의 문서에 따라 달라지며 — 보장되지 않습니다. Hermes / OpenClaw에서 스킬이 적용되지 않는다면, 먼저 이 점을 확인하세요.
|
||||
|
||||
기본 프로젝트 수준 경로에서는 저장소 범위 탐색이 의도된 동작입니다. 체크아웃된 저장소가 이미 해당 디렉터리를 포함하고 있으면, 기반 도구가 커밋된 스킬을 자체적으로 탐색할 수 있습니다. 해당 저장소에서 사용하기 위해 이러한 repo skills를 Multica로 먼저 가져올 필요는 없습니다. Multica는 이러한 저장소 파일을 그대로 둡니다. 워크스페이스 스킬의 자연 디렉터리 이름이 같으면 데몬은 `review-helper-multica` 같은 충돌 없는 형제 디렉터리에 워크스페이스 사본을 씁니다.
|
||||
|
||||
@@ -127,4 +134,4 @@ fallback 경로를 쓰는 도구가 실제로 이 디렉터리를 읽는지는
|
||||
- [에이전트 생성 및 구성](/agents-create) — 에이전트에 사용할 도구를 선택하세요
|
||||
- [작업](/tasks) — 작업 생명주기와 세션 재개 메커니즘
|
||||
- [데몬과 런타임](/daemon-runtimes) — 도구가 실행되는 곳과 Multica에 연결되는 방식
|
||||
- [에이전트 런타임 설치](/install-agent-runtime) — 지원되는 12개 도구 각각의 설치 및 인증
|
||||
- [에이전트 런타임 설치](/install-agent-runtime) — 지원되는 13개 도구 각각의 설치 및 인증
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: AI coding tools matrix
|
||||
description: Multica supports 12 AI coding tools; they implement the same interface, but the capability details diverge significantly.
|
||||
description: Multica supports 13 AI coding tools; they implement the same interface, but the capability details diverge significantly.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica ships with built-in support for **12 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
|
||||
Multica ships with built-in support for **13 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
|
||||
|
||||
For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create).
|
||||
|
||||
@@ -15,27 +15,32 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Dynamic discovery (`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | Static + flag |
|
||||
| **CodeBuddy** | Tencent | ✅ | ✅ | `.claude/skills/` | Dynamic discovery |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | Static |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | Dynamic discovery |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | Static |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | Dynamic discovery |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | Dynamic discovery |
|
||||
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | Dynamic discovery + variants |
|
||||
| **OpenClaw** | Open source | ✅ | ✅ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
|
||||
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
|
||||
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | Dynamic discovery |
|
||||
|
||||
## What each tool is for
|
||||
|
||||
### Antigravity
|
||||
|
||||
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
|
||||
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. Multica launches Antigravity with `agy -p` because that is the daemon-compatible non-interactive mode; `agy -i` needs an attached TTY and is not suitable for background task execution. Current Antigravity CLI releases can still execute tools from this mode, but stdout is plain assistant text rather than a structured event stream, so Multica relays the transcript as text and cannot show per-tool telemetry for Antigravity today. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
|
||||
|
||||
### Claude Code
|
||||
|
||||
From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it reads MCP configuration, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
|
||||
|
||||
### CodeBuddy
|
||||
|
||||
From Tencent. A Claude Code–compatible CLI agent — Multica drives it with the same stream-json protocol as Claude Code, so session resumption works (via `--resume`), MCP config is passed through `--mcp-config`, and skills use Claude Code's `.claude/skills/` layout. Models are discovered dynamically.
|
||||
|
||||
### Codex
|
||||
|
||||
From OpenAI. Uses JSON-RPC 2.0, has stronger statefulness, and a finer-grained approve mechanism (manual approval for `exec_command` and `patch_apply`). MCP config is materialized into the per-task `$CODEX_HOME/config.toml`. **Session resumption works** through Codex app-server `thread/resume`; if the saved thread is missing or stale, Multica falls back to a fresh thread so the task can still run.
|
||||
@@ -48,14 +53,18 @@ From GitHub. Model routing goes through your GitHub account entitlement — the
|
||||
|
||||
From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption works** with current Cursor Agent releases: the stream-json event includes a `session_id`, and Multica passes it back with `--resume <id>` on the next run. MCP config is materialized into the task workspace's `.cursor/mcp.json`, with Cursor's project approval file written under a per-task `CURSOR_DATA_DIR` so managed MCP servers do not depend on the user's global Cursor approvals.
|
||||
|
||||
### Gemini
|
||||
|
||||
From Google, supports the Gemini 2.5 and 3 series. **No session resumption and no MCP** — suitable for one-shot tasks that don't need long context memory.
|
||||
|
||||
### Hermes
|
||||
|
||||
From Nous Research. Uses the ACP protocol (shares a transport with Kimi). Session resumption works, and MCP config is passed through ACP `mcpServers`. But the **skill injection path is the generic fallback** (`.agent_context/skills/`), not a dedicated one — if the Hermes CLI itself doesn't read this path, skills may not take effect. Verify by testing.
|
||||
|
||||
**Selecting a Hermes profile.** To launch Hermes under a specific profile, set the agent's `custom_args` to the profile flag and the profile name as two separate entries — for example, for a profile named `research`:
|
||||
|
||||
```json
|
||||
["-p", "research"]
|
||||
```
|
||||
|
||||
Don't combine them into one string like `"-p research"`; Multica passes each array item as a separate argv entry. `custom_args` is configured per agent — see [Creating and configuring agents](/agents-create).
|
||||
|
||||
### Kimi
|
||||
|
||||
From Moonshot, aimed at the Chinese market. Shares the ACP protocol with Hermes, including MCP config through ACP `mcpServers`, but the skill path `.kimi/skills/` is Kimi CLI's native discovery mechanism — different from Hermes's fallback.
|
||||
@@ -76,22 +85,19 @@ Open-source project, a CLI agent orchestrator. MCP config is materialized throug
|
||||
|
||||
From Inflection AI, minimalist. **Session resumption is unusual** — the session ID is a file path on disk (`~/.pi/...`) rather than a string ID. In other tools, the resume id is a string returned by the CLI; in Pi, the resume id is the session file itself.
|
||||
|
||||
### Qoder
|
||||
|
||||
From Alibaba. An agentic coding CLI. Uses the ACP protocol over stdio (shares a transport with Hermes, Kimi, and Kiro CLI). Session resumption works through ACP `session/resume`, MCP config is passed through ACP `mcpServers`, model selection is discovered dynamically, and skills are copied into `.qoder/skills/` for native discovery.
|
||||
|
||||
## Session resumption: who really supports it
|
||||
|
||||
The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continue-from-the-previous-context). Here's the **exact current state** per tool:
|
||||
|
||||
| Status | Tools | Meaning |
|
||||
|---|---|---|
|
||||
| ✅ Really works | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ❌ None | Gemini | The CLI has no resume mechanism |
|
||||
|
||||
**For your decision**: if your workflow needs agents to preserve context across tasks (failure retries, manual reruns, conversational iteration), pick only from the ✅ row.
|
||||
The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continue-from-the-previous-context). **Every supported tool resumes sessions** — pass the resume id and the task continues from the previous context. The one quirk is Pi, whose resume id is a session file path on disk rather than a string id (see [Pi](#pi) above).
|
||||
|
||||
## MCP configuration: provider-specific support
|
||||
|
||||
**Of the 12 tools, eight consume `mcp_config`: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other four accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
**Of the 13 tools, ten consume `mcp_config`: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Qoder**. The other three accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
|
||||
The runtime paths are provider-specific: Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, and Kiro CLI receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
|
||||
The runtime paths are provider-specific: Claude Code and CodeBuddy receive it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, Kiro CLI, and Qoder receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
|
||||
|
||||
<Callout type="warning">
|
||||
If you set `mcp_config` in an agent configuration but pick a tool not marked ✅ in the MCP column, your MCP servers have **no effect** on that agent. MCP integration is provider-specific.
|
||||
@@ -104,6 +110,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| Tool | Path | Native discovery? |
|
||||
|---|---|---|
|
||||
| Claude Code | `.claude/skills/` | ✅ Native |
|
||||
| CodeBuddy | `.claude/skills/` | ✅ Native |
|
||||
| Codex | `$CODEX_HOME/skills/` | ✅ Native |
|
||||
| Copilot | `.github/skills/` | ✅ Native |
|
||||
| Cursor | `.cursor/skills/` | ✅ Native |
|
||||
@@ -111,12 +118,12 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ Native |
|
||||
| OpenCode | `.opencode/skills/` | ✅ Native |
|
||||
| Pi | `.pi/skills/` | ✅ Native |
|
||||
| Qoder | `.qoder/skills/` | ✅ Native |
|
||||
| Antigravity | `.agents/skills/` | ✅ Native (inherits Gemini CLI's workspace layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)) |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
|
||||
Whether a fallback-path tool actually reads this directory depends on the tool's own documentation — no guarantees. If your skills aren't taking effect for Gemini / Hermes / OpenClaw, check this first.
|
||||
Whether a fallback-path tool actually reads this directory depends on the tool's own documentation — no guarantees. If your skills aren't taking effect for Hermes / OpenClaw, check this first.
|
||||
|
||||
For native project-level paths, repo-scoped discovery is expected: if the checked-out repository already contains a matching directory, the underlying tool can discover those committed skills on its own. You do not need to import those repo skills into Multica just to use them in that repo. Multica keeps the repo files intact. If a workspace skill has the same natural directory name, the daemon writes the workspace copy to a collision-free sibling such as `review-helper-multica`.
|
||||
|
||||
@@ -127,4 +134,4 @@ For creating and using skills, see [Skills](/skills).
|
||||
- [Creating and configuring agents](/agents-create) — pick a tool for your agent
|
||||
- [Tasks](/tasks) — task lifecycle and session-resumption mechanics
|
||||
- [Daemon and runtimes](/daemon-runtimes) — where the tools run and how they connect to Multica
|
||||
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 12 supported tools
|
||||
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 13 supported tools
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: AI 编程工具对照
|
||||
description: Multica 支持 12 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
|
||||
description: Multica 支持 13 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
|
||||
Multica 内置支持 **13 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
|
||||
|
||||
创建智能体时挑选工具的指引见 [创建和配置智能体](/agents-create)。
|
||||
|
||||
@@ -15,27 +15,32 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅(`--conversation <id>`)| ❌ | `.agents/skills/` | 动态发现(`agy models`)|
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静态 + flag |
|
||||
| **CodeBuddy** | Tencent | ✅ | ✅ | `.claude/skills/` | 动态发现 |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静态 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 动态发现 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静态 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback)| 动态发现 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 动态发现 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | 动态发现 + variant |
|
||||
| **OpenClaw** | 开源项目 | ✅ | ✅ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 |
|
||||
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
|
||||
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | 动态发现 |
|
||||
|
||||
## 每款工具的定位
|
||||
|
||||
### Antigravity
|
||||
|
||||
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` 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 系列模型。Multica 使用 `agy -p` 启动 Antigravity,因为这是适合 daemon 后台任务的一次性非交互模式;`agy -i` 需要连接 TTY,不适合后台执行。当前 Antigravity CLI 在 `agy -p` 下仍可执行工具,但 stdout 是纯文本而非结构化事件流,所以 Multica 会把 transcript 作为 text 转发,暂时无法展示逐工具 telemetry。**会话恢复真用**——通过 `--conversation <id>`,守护进程从 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))。
|
||||
|
||||
### Claude Code
|
||||
|
||||
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,会读 MCP 配置,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
|
||||
|
||||
### CodeBuddy
|
||||
|
||||
Tencent 出品。一款兼容 Claude Code 的 CLI agent——Multica 用和 Claude Code 一样的 stream-json 协议驱动它,所以会话恢复可用(通过 `--resume`),MCP 配置通过 `--mcp-config` 传入,skill 沿用 Claude Code 的 `.claude/skills/` 布局。模型为动态发现。
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI 出品。使用 JSON-RPC 2.0 协议,状态化更强,approve 机制更细(手动批准 `exec_command` 和 `patch_apply`)。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话恢复可用**——Multica 通过 Codex app-server 的 `thread/resume` 续接;如果已保存的 thread 不存在或过期,会回退到新 thread,让任务继续执行。
|
||||
@@ -48,14 +53,18 @@ GitHub 出品。模型路由走你的 GitHub 账号权益——工具自己不
|
||||
|
||||
Anysphere 出品,Cursor 编辑器的 CLI 对应物。**会话恢复可用**——当前 Cursor Agent 的 stream-json 事件会返回 `session_id`,Multica 会在下一次运行时通过 `--resume <id>` 传回去。MCP 配置会写入任务工作区的 `.cursor/mcp.json`,Cursor 的项目 approval 文件写在单次任务的 `CURSOR_DATA_DIR` 下,因此托管的 MCP server 不依赖用户全局 Cursor approvals。
|
||||
|
||||
### Gemini
|
||||
|
||||
Google 出品,支持 Gemini 2.5 和 3 系列。**不支持会话恢复也不支持 MCP**——适合一次性、不需要长上下文记忆的任务。
|
||||
|
||||
### Hermes
|
||||
|
||||
Nous Research 出品。使用 ACP 协议(和 Kimi 共享传输层)。会话恢复真用,MCP 配置通过 ACP `mcpServers` 传入。但 **skill 注入路径是通用 fallback**(`.agent_context/skills/`),不是专用路径——如果 Hermes CLI 本身不读这路径,skill 对它可能不起作用。需要结合实测再确认。
|
||||
|
||||
**指定 Hermes profile。** 要让 Hermes 使用某个 profile 启动,把智能体的 `custom_args` 设成 profile flag 和 profile 名两个独立条目。例如使用名为 `research` 的 profile:
|
||||
|
||||
```json
|
||||
["-p", "research"]
|
||||
```
|
||||
|
||||
不要合成一个字符串 `"-p research"`;Multica 会把数组里的每一项作为一个独立 argv 参数传给工具。`custom_args` 是按智能体配置的——见 [创建和配置智能体](/agents-create)。
|
||||
|
||||
### Kimi
|
||||
|
||||
Moonshot 出品,中国市场向。和 Hermes 共享 ACP 协议,MCP 配置同样通过 ACP `mcpServers` 传入;但 skill 路径 `.kimi/skills/` 是 Kimi CLI 的原生发现机制——和 Hermes 的 fallback 不一样。
|
||||
@@ -76,22 +85,19 @@ SST 出品,开源。动态发现可用模型和模型 variant(扫 CLI 的配
|
||||
|
||||
Inflection AI 出品,极简主义。**会话恢复机制特殊**——session ID 是磁盘上的文件路径(`~/.pi/...`),而不是字符串 ID。其他工具里,resume id 是 CLI 返回的字符串;Pi 里,resume id 就是会话文件本身。
|
||||
|
||||
### Qoder
|
||||
|
||||
Alibaba 出品。一款 agentic 编程 CLI。使用 ACP 协议(和 Hermes、Kimi、Kiro CLI 共享传输层)。会话恢复通过 ACP `session/resume` 工作,MCP 配置通过 ACP `mcpServers` 传入,模型为动态发现,skill 复制到 `.qoder/skills/` 做原生发现。
|
||||
|
||||
## 会话恢复:谁真的支持
|
||||
|
||||
会话恢复的机制在 [执行任务](/tasks#任务能接着上次的上下文继续吗) 里讲过。这里按工具列**精确现状**:
|
||||
|
||||
| 状态 | 工具 | 含义 |
|
||||
|---|---|---|
|
||||
| ✅ 真用 | Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ❌ 无 | Gemini | CLI 无 resume 机制 |
|
||||
|
||||
**对你的决策**:如果工作流需要智能体在多次任务之间保持上下文(失败重试、手动重跑、对话式迭代),只选 ✅ 那一行的工具。
|
||||
会话恢复的机制在 [执行任务](/tasks#任务能接着上次的上下文继续吗) 里讲过。**所有支持的工具都能恢复会话**——传 resume id,任务就会从上次的上下文接着继续。唯一的特例是 Pi:它的 resume id 是磁盘上的会话文件路径,而不是字符串 ID(见上文 [Pi](#pi))。
|
||||
|
||||
## MCP 配置:按工具不同
|
||||
|
||||
**12 款工具里有 8 款实际消费 `mcp_config`:Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 4 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
|
||||
**13 款工具里有 10 款实际消费 `mcp_config`:Claude Code、CodeBuddy、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Qoder**。其他 3 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
|
||||
|
||||
各工具的接入方式不同:Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收;Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`;Cursor 会写入 `.cursor/mcp.json`,并把项目 approval 写到单次任务的 `CURSOR_DATA_DIR`;Hermes、Kimi、Kiro CLI 通过 ACP `mcpServers` 接收;OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收;OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
|
||||
各工具的接入方式不同:Claude Code 和 CodeBuddy 通过 `--mcp-config` 加 `--strict-mcp-config` 接收;Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`;Cursor 会写入 `.cursor/mcp.json`,并把项目 approval 写到单次任务的 `CURSOR_DATA_DIR`;Hermes、Kimi、Kiro CLI、Qoder 通过 ACP `mcpServers` 接收;OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收;OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
|
||||
|
||||
<Callout type="warning">
|
||||
如果你在智能体配置里设置了 `mcp_config`,但选了矩阵 MCP 列没有标 ✅ 的工具,你的 MCP server 对这个智能体**没有效果**。MCP 集成是按工具实现的。
|
||||
@@ -104,6 +110,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
| 工具 | 路径 | 是否原生发现 |
|
||||
|---|---|---|
|
||||
| Claude Code | `.claude/skills/` | ✅ 原生 |
|
||||
| CodeBuddy | `.claude/skills/` | ✅ 原生 |
|
||||
| Codex | `$CODEX_HOME/skills/` | ✅ 原生 |
|
||||
| Copilot | `.github/skills/` | ✅ 原生 |
|
||||
| Cursor | `.cursor/skills/` | ✅ 原生 |
|
||||
@@ -111,12 +118,12 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.opencode/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/skills/` | ✅ 原生 |
|
||||
| Qoder | `.qoder/skills/` | ✅ 原生 |
|
||||
| Antigravity | `.agents/skills/` | ✅ 原生(沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))|
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
|
||||
fallback 路径对应的工具是否真的读取这个目录,取决于工具本身的文档——没保证。如果你的 skill 对 Gemini / Hermes / OpenClaw 没起效,先查这个问题。
|
||||
fallback 路径对应的工具是否真的读取这个目录,取决于工具本身的文档——没保证。如果你的 skill 对 Hermes / OpenClaw 没起效,先查这个问题。
|
||||
|
||||
对原生项目级路径来说,repo-scoped discovery 是预期行为:如果检出的仓库已经包含对应目录,底层工具可以自己发现这些提交在仓库里的 Skill。你不需要为了在这个仓库里使用这些 repo skills 而先把它们导入 Multica。Multica 会保持这些仓库文件不变。如果某个工作区 Skill 的自然目录名相同,守护进程会把工作区副本写到类似 `review-helper-multica` 的无冲突 sibling 目录。
|
||||
|
||||
|
||||
175
apps/docs/content/docs/slack-bot-integration.ja.mdx
Normal file
175
apps/docs/content/docs/slack-bot-integration.ja.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Slack Bot 連携
|
||||
description: Multica エージェントをあなた自身の Slack アプリに接続します——マニフェストからアプリを作成し、インストールして、bot トークンと app-level トークンを貼り付ければ、Slack の中から @ メンションしたり、DM したり、/issue と入力したりできます。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
任意の[エージェント](/agents)を Slack Bot に接続すれば、チームは Slack の中から直接それを使えます——Bot に DM したり、チャンネルで @ メンションしたり、`/issue` と入力してアプリを開かずに [Multica イシュー](/issues)を起票したりできます。
|
||||
|
||||
Slack は**自分のアプリを持ち込む(BYO: bring-your-own-app)**モデルを採用しています。ワークスペースの admin が Slack アプリを作成し、自分の Slack ワークスペースにインストールして、そのトークンを Multica に貼り付けます。エージェントごとに**専用の** Slack アプリを持つため、同じ Slack ワークスペース内で複数のエージェントがそれぞれ別個に @ メンションできる異なる Bot を持てます。(これは紐づけがスキャンしてインストールするフローである [Lark](/lark-bot-integration) とは異なります。)
|
||||
|
||||
セットアップ全体は以下のとおりで、所要時間は約 5 分です。最終的に、Multica に貼り付ける 2 つのトークンが得られます。
|
||||
|
||||
- **Bot トークン** —— `xoxb-` で始まります
|
||||
- **App-level トークン** —— `xapp-` で始まります
|
||||
|
||||
## Slack アプリをセットアップする
|
||||
|
||||
### 1. マニフェストからアプリを作成する
|
||||
|
||||
1. [https://api.slack.com/apps](https://api.slack.com/apps) を開き、**Create New App** をクリックします。
|
||||
2. **From a manifest** を選びます。
|
||||
3. アプリをインストールする Slack ワークスペースを選びます。
|
||||
4. **YAML** タブに切り替え、下記のマニフェストを貼り付けて、内容を確認しアプリを作成します。
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
このマニフェストは Multica が必要とするものをすべて設定するので、手作業で何かを設定する必要はありません。
|
||||
|
||||
| セクション | なぜそこにあるか |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | メンバーが Bot を開いて **DM** できるようにします。これがないと、Bot に直接メッセージを送れません。 |
|
||||
| `bot_user` | @ メンションされ、返信を投稿する Bot のアイデンティティを作成します。 |
|
||||
| `chat:write` | エージェントの返信を Slack に投稿し返します。 |
|
||||
| `app_mentions:read` + `app_mention` イベント | チャンネルでの @ メンションを受け取ります。 |
|
||||
| `im:history` + `message.im` | Bot への **DM** を受け取ります(すべての DM メッセージが読み取られます)。 |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 対応する `message.*` イベント | パブリックチャンネル、プライベートチャンネル、グループ DM のメッセージを受け取ります。これらの中では、Bot は自分を **@ メンション**したメッセージにのみ反応します。 |
|
||||
| `users:read` | Multica が(`bots.info` を介して)あなたの 2 つのトークンが同じアプリのものであることを検証するために必要です。 |
|
||||
| `socket_mode_enabled: true` | Bot は Socket Mode 経由で外向きに接続します——**公開 URL/リクエスト URL は不要**です。 |
|
||||
| `interactivity.is_enabled: false` | Multica のプロンプトはボタンではなくプレーンなリンクなので、インタラクティビティは不要です。 |
|
||||
|
||||
**OAuth リダイレクト URL はありません**。BYO は OAuth を使わないからです。
|
||||
|
||||
<Callout type="info">
|
||||
Slack で特定の名前を表示したいですか? 作成前に `display_information.name` と `features.bot_user.display_name`(たとえばエージェントの名前に)を変更するか、あとで **App Home** で編集してください。Slack は Bot をその **bot display name** で表示しますが、これはアプリ名と異なる場合があります。
|
||||
</Callout>
|
||||
|
||||
### 2. アプリをインストールして Bot トークンをコピーする
|
||||
|
||||
1. アプリの左ナビで **Install App**(または **OAuth & Permissions**)を開きます。
|
||||
2. **Install to Workspace** をクリックして承認します。
|
||||
3. **Bot User OAuth Token** をコピーします——`xoxb-` で始まります。これがあなたの **Bot トークン**です。
|
||||
|
||||
### 3. App-level トークンを作成する
|
||||
|
||||
app-level トークンは Socket Mode 接続を認可します。これはコンソールでしか作成できません(OAuth の一部ではありません)。
|
||||
|
||||
1. **Basic Information → App-Level Tokens** を開き、**Generate Token and Scopes** をクリックします。
|
||||
2. 任意の名前を付けます。
|
||||
3. **Add Scope** をクリックし、リストから **`connections:write`** を選びます(これはピッカーなので、入力せずに選択してください)。
|
||||
4. **Generate** をクリックし、トークンをコピーします——`xapp-` で始まります。これがあなたの **App-level トークン**です。
|
||||
|
||||
### 4. Multica で接続する
|
||||
|
||||
1. **Agents → _あなたのエージェント_** からそのエージェントを開き、**Integrations** タブ(または左サイドバーの **Integrations** 区画)を開きます。
|
||||
2. **Connect Slack** をクリックします。
|
||||
3. **Bot トークン**(`xoxb-`)と **App-level トークン**(`xapp-`)を貼り付け、**Connect** をクリックします。
|
||||
4. エージェントに **Connected to Slack** と表示されます。Bot はこれで、自身の Socket Mode 接続を通じて待ち受けています。
|
||||
|
||||
<Callout type="warning">
|
||||
2 つのトークンは**同じ** Slack アプリのものでなければならず、そのアプリはちょうど **1 つ**のエージェントに対応します。すでに別のエージェントやワークスペースに接続されているアプリを接続しようとすると拒否されます。アプリを別のエージェントへ移すには、まず切断してください。**新しい**アプリでエージェントを再接続すると、そのエージェントの Bot がその場で更新されます。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**複数のエージェント**でこれを設定しますか? フロー全体をエージェントごとに 1 回ずつ繰り返してください——各エージェントが専用の Slack アプリと専用のトークンのペアを持ち、Slack ワークスペース内で別々の Bot として表示されます。
|
||||
</Callout>
|
||||
|
||||
## この連携でできること
|
||||
|
||||
| 場所 | 動作 |
|
||||
|---|---|
|
||||
| **エージェント → Integrations** | owner と admin には **Connect Slack** が表示され、接続すると **Connected to Slack** バッジと **Disconnect** コントロールに切り替わります。 |
|
||||
| **Bot に DM** | ワークスペースメンバーが Bot に直接メッセージを送ります。会話はそのエージェントとの Multica [chat](/chat) セッションになり、すべての DM メッセージが読み取られます。 |
|
||||
| **チャンネルで @ メンション** | Bot を招待し(`/invite @your-bot`)、@ メンションします。読み取られるのはメンションしたメッセージだけで、Bot はチャンネル全体を聞いているわけではありません。各 @bot **スレッド**がそれぞれ独立したセッションになります。 |
|
||||
| **`/issue` コマンド** | `/issue <タイトル>`(続く行に本文を足してもよい)でメッセージを始めると、ワークスペースに新しい Multica イシューが作られ、あなたの名義になります。 |
|
||||
| **返信** | エージェントの回答は、同じ DM またはスレッドに投稿し返されます。 |
|
||||
|
||||
## Bot を使う(メンバー)
|
||||
|
||||
### 最初のメッセージ:アカウントを紐づける
|
||||
|
||||
初めて Bot を @ メンションするか DM すると、Bot は **アカウントを紐づける** プロンプトで返信します。リンクをタップして Multica にサインインすると、あなたの Slack アイデンティティがあなたの Multica メンバーシップに紐づきます——これによって、エージェントがあなたとして振る舞えるようになります(たとえば `/issue` はあなたの名義でイシューを起票します)。このリンクは使い切りで、約 15 分で失効します。新しいものが必要なら、もう一度 Bot にメッセージを送るだけです。
|
||||
|
||||
<Callout type="warning">
|
||||
Bot を使えるのは **ワークスペースのメンバー** だけです。メンバーでない場合や、アイデンティティの紐づけをスキップした場合、Bot は実行されません——あなたのメッセージは破棄されます(内容は保存せず、監査のために記録されます)。
|
||||
</Callout>
|
||||
|
||||
### 対話と `/issue`
|
||||
|
||||
- **チャンネルで** —— Bot は自動では参加しません。一度 `/invite @your-bot` を実行してから、`@your-bot <あなたのメッセージ>` とします。フォローアップのたびに再度メンションしてください(Bot は自分をメンションしたメッセージだけを読みます)。
|
||||
- **DM で** —— Slack サイドバーの **Apps** 区画から Bot を開いて直接メッセージを送ります。メンションは不要です。
|
||||
- **イシューを起票する** —— `/issue Fix the login redirect` と送ります。タイトルの後ろに行を足せば、それが説明になります。
|
||||
|
||||
## 管理と切断
|
||||
|
||||
ワークスペース全体の管理は **Settings → Integrations** にあります。
|
||||
|
||||
- **Connected bots** は、ワークスペース内のすべての Bot と、それぞれが紐づくエージェントを一覧表示します(すべてのメンバーから見えます)。
|
||||
- **Disconnect** は **owner / admin 専用** です。切断すると Bot は Slack メッセージの受信を停止し、その接続が破棄されます。インストール記録は監査のために保持され、あとで再接続できます。
|
||||
|
||||
## 権限
|
||||
|
||||
- **接続 / 切断** にはワークスペースの **owner** または **admin** が必要です。
|
||||
- **Bot との対話** には、Slack アイデンティティを紐づけたワークスペースメンバーであることが必要です。それ以外の人は一律に破棄されます。
|
||||
- 破棄されたメッセージの本文が保存されることはありません——監査のために破棄理由だけが記録されます。
|
||||
|
||||
## セルフホストのセットアップ
|
||||
|
||||
Multica Cloud では連携はすでに利用可能です——このセクションは飛ばしてください。
|
||||
|
||||
セルフホストの場合、Slack は**保存時の暗号化キーを設定するまでオフ**です。このキーは、各アプリの bot トークン + app-level トークンがデータベースに触れる前にそれを暗号化します。BYO には OAuth の client id/secret は**不要**で、デプロイレベルの app トークンも**不要**です——各インストールは admin が貼り付けたトークンを使います。
|
||||
|
||||
1. 32 バイトのキーを生成し、API サーバーに設定します。
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
たとえば: `openssl rand -base64 32`。
|
||||
|
||||
2. API を再起動します。キーを設定するまで、**Settings → Integrations** には「Slack integration not enabled」という通知が表示され、**Connect Slack** のエントリポイントは非表示のままになります。
|
||||
|
||||
<Callout type="info">
|
||||
キーはちょうど 32 バイトにデコードされなければなりません——`openssl rand -base64 32` はそれを満たします。これは長く使い続けるシークレットとして扱ってください。ローテーションしたり紛失したりすると、すでに保存済みのトークンが復号できなくなり、すべての Bot を再接続せざるを得なくなります。「アカウントを紐づける」リンクは、Web アプリの URL(`MULTICA_APP_URL`、未設定時は `FRONTEND_ORIGIN` にフォールバック)から生成されます。通常のデプロイではこれは既に設定されているため、追加で設定するものはありません。
|
||||
</Callout>
|
||||
|
||||
## 次に
|
||||
|
||||
- [Chat 連携](/channels) — チャンネルエンジン、セッション、認可の仕組み
|
||||
- [エージェント](/agents) · [Chat](/chat) · [イシュー](/issues)
|
||||
- [環境変数](/environment-variables) — セルフホスト構成の完全なリファレンス
|
||||
175
apps/docs/content/docs/slack-bot-integration.ko.mdx
Normal file
175
apps/docs/content/docs/slack-bot-integration.ko.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Slack Bot 연동
|
||||
description: Multica 에이전트를 자체 Slack 앱에 연결하세요 — 매니페스트로 앱을 만들고, 설치한 다음, bot + app-level 토큰을 붙여넣고, Slack 안에서 @로 멘션하거나 DM하거나 /issue를 입력하세요.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
아무 [에이전트](/agents)나 Slack 봇에 연결하면, 팀이 Slack 안에서 바로 그 에이전트와 함께 일할 수 있습니다 — 봇에게 DM을 보내거나, 채널에서 `@`로 멘션하거나, `/issue`를 입력해 앱을 열지 않고도 [Multica 이슈](/issues)를 생성하세요.
|
||||
|
||||
Slack은 **자체 앱 사용(BYO)** 모델을 따릅니다: 워크스페이스 admin이 Slack 앱을 만들고, 자신의 Slack 워크스페이스에 설치한 다음, 토큰을 Multica에 붙여넣습니다. 각 에이전트가 **자체** Slack 앱을 갖습니다 — 그래서 하나의 Slack 워크스페이스 안에서 여러 에이전트가 각각 별개로 `@`로 멘션할 수 있는 봇을 가질 수 있습니다. (바인딩이 스캔하여 설치하는 방식인 [Lark](/lark-bot-integration)와는 다릅니다.)
|
||||
|
||||
전체 설정은 아래에 있으며 약 5분이 걸립니다. 마지막에는 Multica에 붙여넣을 두 개의 토큰을 얻게 됩니다:
|
||||
|
||||
- **Bot token** — `xoxb-`로 시작
|
||||
- **App-level token** — `xapp-`로 시작
|
||||
|
||||
## Slack 앱 설정하기
|
||||
|
||||
### 1. 매니페스트로 앱 만들기
|
||||
|
||||
1. [https://api.slack.com/apps](https://api.slack.com/apps)로 이동해 **Create New App**을 클릭합니다.
|
||||
2. **From a manifest**를 선택합니다.
|
||||
3. 앱을 설치할 Slack 워크스페이스를 고릅니다.
|
||||
4. **YAML** 탭으로 전환해 아래 매니페스트를 붙여넣고, 검토한 뒤 앱을 생성합니다.
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
이 매니페스트는 Multica에 필요한 모든 것을 구성하므로, 직접 손으로 설정할 것이 없습니다:
|
||||
|
||||
| 섹션 | 이유 |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | 멤버가 봇을 열어 **DM**할 수 있게 합니다. 이것이 없으면 봇에게 직접 메시지를 보낼 수 없습니다. |
|
||||
| `bot_user` | `@`로 멘션되고 답변을 게시하는 봇 신원을 생성합니다. |
|
||||
| `chat:write` | 에이전트의 답변을 Slack으로 다시 게시합니다. |
|
||||
| `app_mentions:read` + `app_mention` 이벤트 | 채널에서 `@`-멘션을 받습니다. |
|
||||
| `im:history` + `message.im` | 봇에게 보내는 **DM**을 받습니다(모든 DM 메시지를 읽습니다). |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 대응하는 `message.*` 이벤트 | 공개 채널, 비공개 채널, 그룹 DM의 메시지를 받습니다. 이런 곳에서 봇은 자신을 **@로 멘션한** 메시지에만 반응합니다. |
|
||||
| `users:read` | Multica가 두 토큰이 같은 앱에 속하는지 (`bots.info`를 통해) 확인하는 데 필요합니다. |
|
||||
| `socket_mode_enabled: true` | 봇이 Socket Mode로 밖으로 연결합니다 — **공개 URL / request URL이 필요 없습니다**. |
|
||||
| `interactivity.is_enabled: false` | Multica의 안내는 버튼이 아니라 일반 링크라서, interactivity가 필요 없습니다. |
|
||||
|
||||
**OAuth redirect URL은 없습니다.** BYO는 OAuth를 사용하지 않기 때문입니다.
|
||||
|
||||
<Callout type="info">
|
||||
Slack에서 특정 이름을 쓰고 싶나요? 생성하기 전에 `display_information.name`과 `features.bot_user.display_name`을 (예: 에이전트 이름으로) 변경하거나, 나중에 **App Home**에서 편집하세요. Slack은 봇을 **bot display name**으로 표시하며, 이는 앱 이름과 다를 수 있습니다.
|
||||
</Callout>
|
||||
|
||||
### 2. 앱 설치하고 Bot token 복사하기
|
||||
|
||||
1. 앱의 왼쪽 내비게이션에서 **Install App**(또는 **OAuth & Permissions**)을 엽니다.
|
||||
2. **Install to Workspace**를 클릭하고 승인합니다.
|
||||
3. **Bot User OAuth Token**을 복사합니다 — `xoxb-`로 시작합니다. 이것이 당신의 **Bot token**입니다.
|
||||
|
||||
### 3. App-level token 생성하기
|
||||
|
||||
app-level token은 Socket Mode 연결을 인가합니다. 콘솔에서만 생성할 수 있습니다(OAuth의 일부가 아닙니다).
|
||||
|
||||
1. **Basic Information → App-Level Tokens**를 열고 **Generate Token and Scopes**를 클릭합니다.
|
||||
2. 아무 이름이나 지정합니다.
|
||||
3. **Add Scope**를 클릭하고 목록에서 **`connections:write`**를 고릅니다(선택기이므로 — 입력하지 말고 선택하세요).
|
||||
4. **Generate**를 클릭한 다음 토큰을 복사합니다 — `xapp-`로 시작합니다. 이것이 당신의 **App-level token**입니다.
|
||||
|
||||
### 4. Multica에서 연결하기
|
||||
|
||||
1. **Agents → _당신의 에이전트_** → **Integrations** 탭(또는 왼쪽 사이드바의 **Integrations** 섹션)에서 에이전트를 엽니다.
|
||||
2. **Connect Slack**을 클릭합니다.
|
||||
3. **Bot token**(`xoxb-`)과 **App-level token**(`xapp-`)을 붙여넣은 다음 **Connect**를 클릭합니다.
|
||||
4. 에이전트에 **Connected to Slack**이 표시됩니다. 봇은 이제 자체 Socket Mode 연결로 수신 대기합니다.
|
||||
|
||||
<Callout type="warning">
|
||||
두 토큰은 **같은** Slack 앱에서 와야 하며, 그 앱은 정확히 **하나의** 에이전트에 매핑됩니다. 이미 다른 에이전트나 워크스페이스에 연결된 앱을 연결하는 것은 거부됩니다. 앱을 다른 에이전트로 옮기려면 먼저 연결을 해제하세요. **새** 앱으로 에이전트를 다시 연결하면 그 에이전트의 봇이 그 자리에서 갱신됩니다.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
**여러 에이전트**에 설정하나요? 에이전트당 전체 과정을 한 번씩 반복하세요 — 각 에이전트가 자체 Slack 앱과 자체 토큰 한 쌍을 가지며, Slack 워크스페이스에 별개의 봇으로 나타납니다.
|
||||
</Callout>
|
||||
|
||||
## 연동이 하는 일
|
||||
|
||||
| 위치 | 동작 |
|
||||
|---|---|
|
||||
| **Agent → Integrations** | owner와 admin에게는 **Connect Slack**이 보이며, 연결되면 **Connected to Slack** 배지와 **Disconnect** 컨트롤로 바뀝니다. |
|
||||
| **봇에게 DM** | 워크스페이스 멤버가 봇에게 직접 메시지를 보냅니다. 그 대화는 에이전트와의 Multica [chat](/chat) 세션이 되며, 모든 DM 메시지를 읽습니다. |
|
||||
| **채널에서 `@`-멘션** | 봇을 초대(`/invite @your-bot`)하고 `@`로 멘션하세요. 멘션한 메시지만 읽으며, 봇이 채널 전체를 듣지는 않습니다. 각 @bot **스레드**가 자체 세션입니다. |
|
||||
| **`/issue` 명령** | `/issue <제목>`(다음 줄에 본문 추가 가능)으로 메시지를 시작하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. |
|
||||
| **답변** | 에이전트의 답변은 같은 DM 또는 스레드로 다시 게시됩니다. |
|
||||
|
||||
## 봇 사용하기 (멤버)
|
||||
|
||||
### 첫 메시지: 계정 연결하기
|
||||
|
||||
봇을 처음 `@`로 멘션하거나 DM하면, **계정을 연결하라**는 안내로 답합니다. 링크를 탭하고 Multica에 로그인하면, 당신의 Slack 신원이 Multica 멤버십에 바인딩됩니다 — 바로 이 단계가 에이전트로 하여금 당신을 대신해 행동하게 합니다(예: `/issue`는 당신 이름으로 이슈를 생성합니다). 이 링크는 일회용이며 약 15분 후에 만료됩니다. 새 링크가 필요하면 봇에게 다시 메시지를 보내세요.
|
||||
|
||||
<Callout type="warning">
|
||||
**워크스페이스 멤버**만 봇을 사용할 수 있습니다. 멤버가 아니거나 신원 연결을 건너뛰면 봇은 실행되지 않으며, 메시지는 폐기됩니다(감사 목적으로 기록되며, 내용은 저장하지 않습니다).
|
||||
</Callout>
|
||||
|
||||
### 대화와 `/issue`
|
||||
|
||||
- **채널에서** — 봇은 자동으로 참여하지 않습니다. `/invite @your-bot`을 한 번 실행한 다음 `@your-bot <당신의 메시지>`로 보내세요. 후속 메시지마다 다시 멘션하세요(봇은 자신을 멘션한 메시지만 읽습니다).
|
||||
- **DM에서** — Slack 사이드바의 **Apps** 섹션에서 봇을 열고 직접 메시지를 보내세요. 멘션이 필요 없습니다.
|
||||
- **이슈 생성** — `/issue Fix the login redirect`를 보내세요. 제목 뒤에 줄을 더 추가하면 설명이 됩니다.
|
||||
|
||||
## 관리 및 연결 해제
|
||||
|
||||
워크스페이스 전체 관리는 **Settings → Integrations**에 있습니다:
|
||||
|
||||
- **Connected bots**는 워크스페이스 내 모든 봇과 각 봇이 바인딩된 에이전트를 나열합니다(모든 멤버에게 보입니다).
|
||||
- **Disconnect**는 **owner / admin 전용**입니다. 봇이 Slack 메시지 수신을 멈추고 연결이 해체됩니다. 설치 기록은 감사용으로 유지되며, 이후 다시 연결할 수 있습니다.
|
||||
|
||||
## 권한
|
||||
|
||||
- **연결 / 연결 해제**에는 워크스페이스 **owner** 또는 **admin**이 필요합니다.
|
||||
- **봇과 대화하기**에는 Slack 신원이 연결된 워크스페이스 멤버여야 합니다. 그 외의 사람은 모두 폐기됩니다.
|
||||
- 폐기된 메시지의 본문은 절대 저장되지 않으며 — 감사용 폐기 사유만 기록됩니다.
|
||||
|
||||
## 자체 호스팅 설정
|
||||
|
||||
Multica Cloud에서는 연동이 이미 사용 가능합니다 — 이 섹션은 건너뛰세요.
|
||||
|
||||
자체 호스팅의 경우, Slack은 **at-rest 암호화 키를 설정하기 전까지 꺼져 있습니다**. 이 키는 각 앱의 bot + app-level 토큰이 데이터베이스에 닿기 전에 암호화합니다. BYO에는 OAuth client id/secret이 **필요 없고**, 배포 수준의 app token도 **필요 없습니다** — 각 installation은 admin이 붙여넣은 토큰을 사용합니다.
|
||||
|
||||
1. 32바이트 키를 생성해 API 서버에 설정합니다:
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
예를 들면: `openssl rand -base64 32`.
|
||||
|
||||
2. API를 재시작하세요. 키가 설정되기 전까지 **Settings → Integrations**에는 "Slack integration not enabled" 안내가 표시되고, **Connect Slack** 진입점은 숨겨진 채로 유지됩니다.
|
||||
|
||||
<Callout type="info">
|
||||
키는 정확히 32바이트로 디코딩되어야 하며 — `openssl rand -base64 32`가 이를 충족합니다. 오래 유지되는 시크릿으로 다루세요: 키를 회전하거나 잃으면 이미 저장된 토큰을 복호화할 수 없게 되어, 모든 봇이 다시 연결해야 합니다. "계정을 연결하세요" 링크는 웹 앱 URL(`MULTICA_APP_URL`, 없으면 `FRONTEND_ORIGIN`으로 폴백)에서 만들어집니다. 일반적인 배포에서는 이미 설정되어 있으므로 추가로 구성할 것은 없습니다.
|
||||
</Callout>
|
||||
|
||||
## 다음
|
||||
|
||||
- [Chat 연동](/channels) — channel 엔진, 세션, 권한이 어떻게 동작하는지
|
||||
- [에이전트](/agents) · [Chat](/chat) · [이슈](/issues)
|
||||
- [환경 변수](/environment-variables) — 전체 자체 호스팅 구성 참조
|
||||
175
apps/docs/content/docs/slack-bot-integration.mdx
Normal file
175
apps/docs/content/docs/slack-bot-integration.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Slack Bot integration
|
||||
description: Connect a Multica agent to your own Slack app — create the app from a manifest, install it, paste the bot + app-level tokens, then @-mention it, DM it, or type /issue from inside Slack.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Connect any [agent](/agents) to a Slack bot and your team can work with it from inside Slack — DM the bot, @-mention it in a channel, or type `/issue` to file a [Multica issue](/issues) without opening the app.
|
||||
|
||||
Slack uses a **bring-your-own-app (BYO)** model: a workspace admin creates a Slack app, installs it to their Slack workspace, and pastes its tokens into Multica. Each agent gets **its own** Slack app — so several agents can each have a distinct, separately @-mentionable bot in the same Slack workspace. (This differs from [Lark](/lark-bot-integration), where binding is a scan-to-install flow.)
|
||||
|
||||
The whole setup is below and takes about five minutes. You'll end up with two tokens to paste into Multica:
|
||||
|
||||
- a **Bot token** — starts with `xoxb-`
|
||||
- an **App-level token** — starts with `xapp-`
|
||||
|
||||
## Set up your Slack app
|
||||
|
||||
### 1. Create the app from a manifest
|
||||
|
||||
1. Go to [https://api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**.
|
||||
2. Choose **From a manifest**.
|
||||
3. Pick the Slack workspace to install the app into.
|
||||
4. Switch to the **YAML** tab, paste the manifest below, review, and create the app.
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
This manifest configures everything Multica needs, so you don't set anything by hand:
|
||||
|
||||
| Section | Why it's there |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | Lets members open the bot and **DM** it. Without it, the bot can't be messaged directly. |
|
||||
| `bot_user` | Creates the bot identity that gets @-mentioned and posts replies. |
|
||||
| `chat:write` | Post the agent's replies back into Slack. |
|
||||
| `app_mentions:read` + `app_mention` event | Receive @-mentions in channels. |
|
||||
| `im:history` + `message.im` | Receive **DMs** to the bot (every DM message is read). |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + the matching `message.*` events | Receive messages in public channels, private channels, and group DMs. In these, the bot only acts on messages that **@-mention** it. |
|
||||
| `users:read` | Required so Multica can verify (via `bots.info`) that your two tokens belong to the same app. |
|
||||
| `socket_mode_enabled: true` | The bot connects out over Socket Mode — **no public URL / request URL needed**. |
|
||||
| `interactivity.is_enabled: false` | Multica's prompts are plain links, not buttons, so interactivity isn't needed. |
|
||||
|
||||
There is **no OAuth redirect URL**, because BYO doesn't use OAuth.
|
||||
|
||||
<Callout type="info">
|
||||
Want a specific name in Slack? Change `display_information.name` and `features.bot_user.display_name` (e.g. to your agent's name) before creating, or edit it later under **App Home**. Slack shows the bot by its **bot display name**, which can differ from the app name.
|
||||
</Callout>
|
||||
|
||||
### 2. Install the app and copy the Bot token
|
||||
|
||||
1. In the app's left nav, open **Install App** (or **OAuth & Permissions**).
|
||||
2. Click **Install to Workspace** and approve.
|
||||
3. Copy the **Bot User OAuth Token** — it starts with `xoxb-`. This is your **Bot token**.
|
||||
|
||||
### 3. Create the App-level token
|
||||
|
||||
The app-level token authorizes the Socket Mode connection. It can only be created in the console (it isn't part of OAuth).
|
||||
|
||||
1. Open **Basic Information → App-Level Tokens** and click **Generate Token and Scopes**.
|
||||
2. Give it any name.
|
||||
3. Click **Add Scope** and pick **`connections:write`** from the list (it's a picker — select it, don't type it).
|
||||
4. Click **Generate**, then copy the token — it starts with `xapp-`. This is your **App-level token**.
|
||||
|
||||
### 4. Connect it in Multica
|
||||
|
||||
1. Open the agent in **Agents → _your agent_** → the **Integrations** tab (or the **Integrations** section in the left sidebar).
|
||||
2. Click **Connect Slack**.
|
||||
3. Paste the **Bot token** (`xoxb-`) and the **App-level token** (`xapp-`), then click **Connect**.
|
||||
4. The agent shows **Connected to Slack**. The bot is now listening over its own Socket Mode connection.
|
||||
|
||||
<Callout type="warning">
|
||||
The two tokens must be from the **same** Slack app, and that app maps to exactly **one** agent. Connecting an app that's already connected to a different agent or workspace is refused. To move an app to another agent, disconnect it first; re-connecting an agent with a **new** app updates that agent's bot in place.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Setting this up for **multiple agents**? Repeat the whole flow once per agent — each agent gets its own Slack app and its own pair of tokens, and they show up as separate bots in your Slack workspace.
|
||||
</Callout>
|
||||
|
||||
## What the integration does
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| **Agent → Integrations** | Owners and admins see **Connect Slack**; once connected it flips to a **Connected to Slack** badge with a **Disconnect** control. |
|
||||
| **DM the bot** | A workspace member messages the bot directly. The conversation becomes a Multica [chat](/chat) session with the agent; every DM message is read. |
|
||||
| **@-mention in a channel** | Invite the bot (`/invite @your-bot`) and @-mention it. Only the mentioning message is read — the bot does not listen to the whole channel. Each @bot **thread** is its own session. |
|
||||
| **`/issue` command** | Starting a message with `/issue <title>` (optionally with a body on the next lines) creates a new Multica issue in the workspace, attributed to you. |
|
||||
| **Reply** | The agent's answer is posted back into the same DM or thread. |
|
||||
|
||||
## Use the bot (members)
|
||||
|
||||
### First message: link your account
|
||||
|
||||
The first time you @-mention or DM the bot, it replies with a **link your account** prompt. Tap the link, sign in to Multica, and your Slack identity is bound to your Multica membership — this is what lets the agent act as you (e.g. `/issue` files under your name). The link is single-use and expires in about 15 minutes; just message the bot again for a fresh one.
|
||||
|
||||
<Callout type="warning">
|
||||
Only **members of the workspace** can use the bot. If you aren't a member, or you skip the identity link, the bot won't run — your message is dropped (recorded for audit, without its contents).
|
||||
</Callout>
|
||||
|
||||
### Chat and `/issue`
|
||||
|
||||
- **In a channel** — the bot isn't auto-joined. Run `/invite @your-bot` once, then `@your-bot <your message>`. Re-mention it for each follow-up (the bot only reads messages that mention it).
|
||||
- **In a DM** — open the bot from the Slack sidebar's **Apps** section and message it directly; no mention needed.
|
||||
- **File an issue** — send `/issue Fix the login redirect`; add more lines after the title for a description.
|
||||
|
||||
## Manage and disconnect
|
||||
|
||||
Workspace-wide management lives in **Settings → Integrations**:
|
||||
|
||||
- **Connected bots** lists every bot in the workspace and the agent each is bound to (visible to all members).
|
||||
- **Disconnect** is **owner / admin only**. It stops the bot from receiving Slack messages and tears down its connection; the installation record is kept for audit, and you can re-connect later.
|
||||
|
||||
## Permissions
|
||||
|
||||
- **Connect / disconnect** require workspace **owner** or **admin**.
|
||||
- **Talking to the bot** requires being a workspace member with a linked Slack identity. Everyone else is dropped.
|
||||
- Message bodies for dropped messages are never stored — only a drop reason, for audit.
|
||||
|
||||
## Self-host setup
|
||||
|
||||
On Multica Cloud the integration is already available — skip this section.
|
||||
|
||||
For self-host, Slack is **off until you set an at-rest encryption key**. The key encrypts each app's bot + app-level tokens before they touch the database. BYO needs **no** OAuth client id/secret and **no** deployment-level app token — each installation uses the tokens the admin pastes.
|
||||
|
||||
1. Generate a 32-byte key and set it on the API server:
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
For example: `openssl rand -base64 32`.
|
||||
|
||||
2. Restart the API. Until the key is set, **Settings → Integrations** shows a "Slack integration not enabled" notice and the **Connect Slack** entry points stay hidden.
|
||||
|
||||
<Callout type="info">
|
||||
The key must decode to exactly 32 bytes — `openssl rand -base64 32` does this. Treat it as a long-lived secret: rotating or losing it makes already-stored tokens undecryptable, forcing every bot to reconnect. The "link your account" link is built from your web app URL (`MULTICA_APP_URL`, falling back to `FRONTEND_ORIGIN`) — a normal deployment already sets this, so there's nothing extra to configure.
|
||||
</Callout>
|
||||
|
||||
## Next
|
||||
|
||||
- [Chat integrations](/channels) — how the channel engine, sessions, and authorization work
|
||||
- [Agents](/agents) · [Chat](/chat) · [Issues](/issues)
|
||||
- [Environment variables](/environment-variables) — full self-host configuration reference
|
||||
175
apps/docs/content/docs/slack-bot-integration.zh.mdx
Normal file
175
apps/docs/content/docs/slack-bot-integration.zh.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Slack Bot 接入
|
||||
description: 把 Multica 智能体接入你自己的 Slack app——用 manifest 创建 app、安装它、粘贴 bot token 与 app-level token,然后就能在 Slack 里 @ 它、私聊它,或输入 /issue。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
把任意[智能体](/agents)接入一个 Slack Bot,团队就能在 Slack 里直接使用它——私聊 Bot、在频道里 @ 它,或者输入 `/issue` 直接创建一个 [Multica issue](/issues),不用打开应用。
|
||||
|
||||
Slack 走的是**自带应用(bring-your-own-app,BYO)**模式:workspace 管理员创建一个 Slack app,把它安装到自己的 Slack workspace,再把它的 token 粘贴进 Multica。每个智能体都有**它自己的** Slack app——所以多个智能体可以在同一个 Slack workspace 里各自拥有一个独立、可单独 @ 的 Bot。(这一点和 [Lark](/lark-bot-integration) 不同,Lark 的绑定是扫码安装流程。)
|
||||
|
||||
整个设置流程在下面,大约五分钟。最后你会得到两个 token 粘贴进 Multica:
|
||||
|
||||
- 一个 **Bot token** —— 以 `xoxb-` 开头
|
||||
- 一个 **App-level token** —— 以 `xapp-` 开头
|
||||
|
||||
## 设置你的 Slack app
|
||||
|
||||
### 1. 用 manifest 创建 app
|
||||
|
||||
1. 打开 [https://api.slack.com/apps](https://api.slack.com/apps),点击 **Create New App**。
|
||||
2. 选择 **From a manifest**。
|
||||
3. 选定要把 app 安装进去的那个 Slack workspace。
|
||||
4. 切到 **YAML** tab,粘贴下面的 manifest,检查一遍,然后创建这个 app。
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Multica
|
||||
features:
|
||||
app_home:
|
||||
home_tab_enabled: false
|
||||
messages_tab_enabled: true
|
||||
messages_tab_read_only_enabled: false
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- app_mentions:read
|
||||
- channels:history
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- chat:write
|
||||
- users:read
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
- app_mention
|
||||
- message.im
|
||||
- message.channels
|
||||
- message.groups
|
||||
- message.mpim
|
||||
interactivity:
|
||||
is_enabled: false
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
这个 manifest 已经把 Multica 所需的一切都配好了,所以你不用手动设置任何东西:
|
||||
|
||||
| 配置项 | 为什么需要它 |
|
||||
|---|---|
|
||||
| `app_home.messages_tab_enabled: true` | 让成员能打开 Bot 并**私聊**它。没有它,Bot 就无法被直接发消息。 |
|
||||
| `bot_user` | 创建被 @ 和发回复用的那个 Bot 身份。 |
|
||||
| `chat:write` | 把智能体的回复发回 Slack。 |
|
||||
| `app_mentions:read` + `app_mention` 事件 | 接收频道里的 @ 提及。 |
|
||||
| `im:history` + `message.im` | 接收发给 Bot 的**私聊**(每一条私聊消息都会被读取)。 |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 对应的 `message.*` 事件 | 接收公开频道、私有频道和群组私聊里的消息。在这些场景里,Bot 只对 **@ 了**它的消息做出响应。 |
|
||||
| `users:read` | 必需,这样 Multica 才能(通过 `bots.info`)核实你的两个 token 属于同一个 app。 |
|
||||
| `socket_mode_enabled: true` | Bot 通过 Socket Mode 向外连接——**无需任何公网 URL / request URL**。 |
|
||||
| `interactivity.is_enabled: false` | Multica 的提示是纯链接,不是按钮,所以不需要交互性。 |
|
||||
|
||||
这里**没有 OAuth 重定向 URL**,因为 BYO 不使用 OAuth。
|
||||
|
||||
<Callout type="info">
|
||||
想在 Slack 里用一个特定的名字?在创建之前改 `display_information.name` 和 `features.bot_user.display_name`(比如改成你智能体的名字),或者之后在 **App Home** 里编辑。Slack 是按 Bot 的**显示名(bot display name)**来展示它的,这个名字可以和 app 名不一样。
|
||||
</Callout>
|
||||
|
||||
### 2. 安装 app 并复制 Bot token
|
||||
|
||||
1. 在 app 的左侧导航里,打开 **Install App**(或 **OAuth & Permissions**)。
|
||||
2. 点击 **Install to Workspace** 并批准。
|
||||
3. 复制 **Bot User OAuth Token**——它以 `xoxb-` 开头。这就是你的 **Bot token**。
|
||||
|
||||
### 3. 创建 App-level token
|
||||
|
||||
App-level token 用来授权 Socket Mode 连接。它只能在控制台里创建(它不属于 OAuth)。
|
||||
|
||||
1. 打开 **Basic Information → App-Level Tokens**,点击 **Generate Token and Scopes**。
|
||||
2. 随便起个名字。
|
||||
3. 点击 **Add Scope**,从列表里选 **`connections:write`**(这是一个选择器——选中它,不要手打)。
|
||||
4. 点击 **Generate**,然后复制这个 token——它以 `xapp-` 开头。这就是你的 **App-level token**。
|
||||
|
||||
### 4. 在 Multica 里连接它
|
||||
|
||||
1. 在 **Agents → _你的智能体_** 打开该智能体 → **Integrations** tab(或左侧栏的 **Integrations** 区块)。
|
||||
2. 点击 **Connect Slack**。
|
||||
3. 粘贴 **Bot token**(`xoxb-`)和 **App-level token**(`xapp-`),然后点击 **Connect**。
|
||||
4. 智能体显示 **Connected to Slack**。Bot 现在通过它自己的 Socket Mode 连接在监听了。
|
||||
|
||||
<Callout type="warning">
|
||||
这两个 token 必须来自**同一个** Slack app,而那个 app 恰好对应**一个**智能体。连接一个已经连到别的智能体或 workspace 的 app 会被拒绝。要把一个 app 挪到另一个智能体,先断开它;用一个**新的** app 重新连接某个智能体,会就地更新那个智能体的 Bot。
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
要给**多个智能体**做这套设置?每个智能体都把整套流程走一遍——每个智能体都有自己的 Slack app 和自己的一对 token,它们会在你的 Slack workspace 里显示成各自独立的 Bot。
|
||||
</Callout>
|
||||
|
||||
## 这个集成能做什么
|
||||
|
||||
| 入口 | 行为 |
|
||||
|---|---|
|
||||
| **智能体 → Integrations** | 所有者和管理员能看到 **Connect Slack**;连接后它会变成一个 **Connected to Slack** 徽标,并带一个 **Disconnect** 操作。 |
|
||||
| **私聊 Bot** | 工作区成员直接给 Bot 发消息。这段对话会成为该智能体的一个 Multica [chat](/chat) 会话;每一条私聊消息都会被读取。 |
|
||||
| **频道里 @ 它** | 把 Bot 邀请进来(`/invite @your-bot`)再 @ 它。只有 @ 它的那条消息会被读取——Bot 不会监听整个频道。每个 @bot 的 **thread** 都是它自己的会话。 |
|
||||
| **`/issue` 命令** | 以 `/issue <标题>` 开头的消息(可在后面几行附上正文)会在工作区创建一个新的 Multica issue,记在你名下。 |
|
||||
| **回复** | 智能体的答复会被发回同一段私聊或 thread 里。 |
|
||||
|
||||
## 使用 Bot(成员)
|
||||
|
||||
### 第一条消息:绑定你的账号
|
||||
|
||||
第一次 @ 或私聊 Bot 时,它会回一条 **绑定你的账号** 提示。点开链接、登录 Multica,你的 Slack 身份就会绑定到你的 Multica 成员身份——正是这一步让智能体能以你的身份行事(比如 `/issue` 会把 issue 记在你名下)。这个链接是一次性的,大约 15 分钟后过期;再给 Bot 发条消息就能拿到一个新的。
|
||||
|
||||
<Callout type="warning">
|
||||
只有**工作区成员**才能使用 Bot。如果你不是成员,或者跳过了身份绑定,Bot 不会运行——你的消息会被丢弃(仅出于审计目的记录,不保存消息内容)。
|
||||
</Callout>
|
||||
|
||||
### 对话与 `/issue`
|
||||
|
||||
- **在频道里** —— Bot 不会自动加入。先运行一次 `/invite @your-bot`,然后 `@your-bot <你的消息>`。每次追问都要重新 @ 它一下(Bot 只读取 @ 了它的消息)。
|
||||
- **在私聊里** —— 从 Slack 侧栏的 **Apps** 区块打开 Bot 并直接给它发消息;不用 @。
|
||||
- **创建 issue** —— 发送 `/issue Fix the login redirect`;在标题后面再加几行就是描述。
|
||||
|
||||
## 管理与断开
|
||||
|
||||
工作区级别的管理在 **Settings → Integrations**:
|
||||
|
||||
- **Connected bots** 列出工作区里每个 Bot 以及它各自绑定的智能体(所有成员都能看到)。
|
||||
- **Disconnect** 仅限 **所有者 / 管理员**。它会让 Bot 停止接收 Slack 消息并拆掉它的连接;安装记录会保留以便审计,之后你可以重新连接。
|
||||
|
||||
## 权限
|
||||
|
||||
- **连接 / 断开** 需要工作区**所有者**或**管理员**。
|
||||
- **和 Bot 对话** 需要你是工作区成员且已绑定 Slack 身份。其余的人一律被丢弃。
|
||||
- 对于被丢弃的消息,绝不保存消息内容——只记录一个丢弃原因,用于审计。
|
||||
|
||||
## 自部署配置
|
||||
|
||||
在 Multica Cloud 上这个集成已经可用——可跳过本节。
|
||||
|
||||
自部署时,**在你设置好静态加密密钥之前,Slack 是关闭的**。这个密钥会在每个 app 的 bot token + app-level token 落库之前对其加密。BYO **不需要** OAuth client id/secret,也**不需要**部署级的 app token——每个安装用的都是管理员粘贴进来的那对 token。
|
||||
|
||||
1. 生成一个 32 字节的密钥并设置到 API 服务器:
|
||||
|
||||
```dotenv
|
||||
MULTICA_SLACK_SECRET_KEY=<base64-encoded 32-byte key>
|
||||
```
|
||||
|
||||
例如:`openssl rand -base64 32`。
|
||||
|
||||
2. 重启 API。在密钥设置好之前,**Settings → Integrations** 会显示一条「Slack integration not enabled」提示,**Connect Slack** 入口也会保持隐藏。
|
||||
|
||||
<Callout type="info">
|
||||
这个密钥必须正好解码出 32 字节——`openssl rand -base64 32` 就能做到。把它当成一个长期有效的密钥:轮换或丢失它会让已存储的 token 无法解密,迫使每个 Bot 重新连接。「绑定你的账号」链接是用你的 Web 应用地址(`MULTICA_APP_URL`,未设置时回退到 `FRONTEND_ORIGIN`)拼出来的——正常部署里这个值本来就有,不需要额外配置。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [聊天集成](/channels) —— channel 引擎、会话与授权是怎么运作的
|
||||
- [智能体](/agents) · [Chat](/chat) · [Issues](/issues)
|
||||
- [环境变量](/environment-variables) —— 完整的自部署配置参考
|
||||
23
apps/web/app/slack/bind/page.tsx
Normal file
23
apps/web/app/slack/bind/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { SlackBindPage } from "@multica/views/slack";
|
||||
|
||||
// /slack/bind?token=<raw> is the bot's "link your account" destination. Suspense
|
||||
// wraps useSearchParams per Next.js 15's CSR-bailout rule; the loading text
|
||||
// never paints in practice because the redemption page itself renders the
|
||||
// "redeeming…" state immediately.
|
||||
function SlackBindPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
return <SlackBindPage token={token} />;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SlackBindPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -293,6 +293,103 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.32",
|
||||
date: "2026-06-29",
|
||||
title: "Detach sub-Issues, sturdier daemon reconnects, and friendlier attachment previews",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issues now have a Remove parent action, so you can detach a sub-Issue without first having to pick a different parent.",
|
||||
],
|
||||
improvements: [
|
||||
"The local daemon reconnects to Multica through a more resilient WebSocket flow with bounded backoff, so brief network drops recover smoothly instead of stalling.",
|
||||
"The daemon now bounds each runtime probe with its own timeout, so a single wedged CLI can no longer block every other runtime from coming online.",
|
||||
],
|
||||
fixes: [
|
||||
"Scheduled autopilots advance their next-run time the moment a run is dispatched, so a slow runner can no longer cause back-to-back duplicate dispatches.",
|
||||
"Attachment previews open correctly whether the URL redirects inside a frame, comes back from the same origin, or was uploaded locally — and local upload URLs are now preferred when available.",
|
||||
"When the failed-task handler unsticks an Issue, the Issue view refreshes immediately instead of waiting for a manual reload.",
|
||||
"Sticky Issue comment headers share the same background fade as the highlight, so settling on a comment no longer looks out of sync.",
|
||||
"Chat conversations refresh their message cache when reconnecting, so you no longer see stale messages right after coming back online.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.31",
|
||||
date: "2026-06-26",
|
||||
title: "Cross-workspace unread dot, Composio toolkit foundation, and a friendlier editor",
|
||||
changes: [],
|
||||
features: [
|
||||
"The workspace switcher shows a dot when another workspace has unread inbox items.",
|
||||
"New Composio toolkit foundation that prepares the upcoming third-party integrations.",
|
||||
"You can run desktop dev on multiple checkouts side by side without them clashing.",
|
||||
"The Chinese docs homepage now opens with a short intro video.",
|
||||
],
|
||||
improvements: [
|
||||
"Contributor docs note that the desktop dev command isolates per checkout.",
|
||||
],
|
||||
fixes: [
|
||||
"Tab now reliably indents selected list items in the Issue editor and keeps focus in place.",
|
||||
"Squad leaders boot with the full squad briefing when you @-mention them in a comment, and replies that inherit the parent mention no longer trigger them again.",
|
||||
"Code-block selections in Issues stay put while the page re-renders.",
|
||||
"Assigning an Issue directly to an agent opens the handoff note instantly instead of waiting on a check.",
|
||||
"The workspace switcher's unread dot now matches what you actually see in your inbox.",
|
||||
"The edit-comment save button shows a loading state until the change is saved.",
|
||||
"Search results load reliably again.",
|
||||
"Self-hosting fails fast with a clear hint when Docker Compose v2 is missing.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.30",
|
||||
date: "2026-06-25",
|
||||
title: "Slack Channel Integration, a Smoother Editor, and Many Reliability Fixes",
|
||||
changes: [],
|
||||
features: [
|
||||
"Slack conversations now run on the new unified collaboration channel, putting Slack on the same reliable footing as Feishu and Lark",
|
||||
"The Issue composer now accepts the highlighted @mention or suggestion when you press Tab, so picking the right teammate or Issue is a single keypress",
|
||||
"Task list items can be toggled from a one-click button in the editor's floating menu",
|
||||
],
|
||||
improvements: [
|
||||
"Frontend continuous integration now skips automatically when a pull request does not touch frontend code, freeing up build time for the changes that actually need it",
|
||||
"Command line subcommands have broader automated test coverage so everyday workflows stay stable across releases",
|
||||
"Provider-specific default agent arguments now have explicit documentation, and a one-time Lark cutover flag was retired now that the unified channel adapter is fully in production",
|
||||
],
|
||||
fixes: [
|
||||
"OpenClaw is more forgiving about config file mismatches and supports the newer 2026.6.x agents schema, keeping existing OpenClaw runtimes connected",
|
||||
"Moving an Issue between projects now removes it from the old project list right away, and board column counts stay accurate when an Issue's status changes off-screen",
|
||||
"Attachment previews open correctly even when files are served from a different origin",
|
||||
"Command line agents wait for the daemon to be ready before falling back to a personal access token, and the self-host setup flow now respects existing configuration and surfaces server URL changes",
|
||||
"Lark messages now link to the configured app URL instead of falling back to a generic web address",
|
||||
"Codex runs clean up correctly even when their output overflows, Kiro runs preserve their goal completion state through close errors, and agent shutdown now terminates the entire opencode process group before closing",
|
||||
"Quick-create reliably keeps every uploaded file attached when several uploads happen at the same time",
|
||||
"Redis webhook rate limiting no longer throttles unrelated webhooks together, and daemon skill bundles load reliably even for large skill libraries",
|
||||
"Issue label names now reject control characters so labels stay readable everywhere",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.29",
|
||||
date: "2026-06-24",
|
||||
title: "Feishu Channel Upgrade, Feature Rollout Controls, and More Reliable Autopilots",
|
||||
changes: [],
|
||||
features: [
|
||||
"Feishu conversations now run on a new unified collaboration channel, making message handling more stable and consistent and laying the groundwork for more chat platforms",
|
||||
"New feature rollout controls cover both the app and the daemon, so teams can open up risky changes gradually and to a limited audience",
|
||||
"When agents read long Issue discussions, resolved threads now fold down to their key conclusion to keep the context focused",
|
||||
"Feishu users can start a fresh conversation with the `/new` command, and Feishu WebSocket connections can use a configured proxy",
|
||||
],
|
||||
improvements: [
|
||||
"Scheduled autopilots are more dependable: even with missed schedules, retries, or several runners working at once, they settle on the intended single run",
|
||||
"Agent runtime briefings can switch to a slimmer version that drops redundant detail, with the full version still available as a fallback",
|
||||
"Runtime provider docs now match the current provider list, with Qoder, CodeBuddy, and Antigravity guidance added and the outdated Gemini CLI runtime removed",
|
||||
"The branch or version pinned in a project's repository settings now takes effect during local agent work, so agents no longer end up on the wrong branch",
|
||||
],
|
||||
fixes: [
|
||||
"Sub-Issues now stay in stable creation order inside a parent Issue",
|
||||
"Attachment previews now open correctly inside Issues",
|
||||
"The @mention picker now selects the highlighted person or Issue even when search results reorder",
|
||||
"Cancelled chat drafts stay deleted after you navigate away and come back",
|
||||
"Autopilot cold starts, the agent status in the Issue header, and Antigravity provider errors now report more accurately",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.28",
|
||||
date: "2026-06-23",
|
||||
|
||||
@@ -269,6 +269,103 @@ export function createJaDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "バグ修正",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.32",
|
||||
date: "2026-06-29",
|
||||
title: "サブ Issue の切り離し、より堅牢なデーモン再接続、どこからでも開ける添付プレビュー",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue のアクションに「親 Issue を解除」が追加され、別の親を選び直さなくても子 Issue を直接切り離せます。",
|
||||
],
|
||||
improvements: [
|
||||
"ローカル デーモンの WebSocket 再接続が、上限付きのバックオフを備えたより堅牢な流れに見直され、瞬断にもスムーズに復帰します。",
|
||||
"デーモンはランタイムのバージョン確認に個別のタイムアウトを設けるようになり、応答しない 1 つの CLI が他のランタイム起動を巻き込んで止めることがなくなりました。",
|
||||
],
|
||||
fixes: [
|
||||
"予約オートパイロットはディスパッチ直後に次回実行時刻を進めるようになり、遅いランナーが同じ実行を続けて送り出すことがなくなりました。",
|
||||
"添付プレビューは、フレーム内リダイレクト、同一オリジン、ローカル アップロードのいずれの場合も正しく開き、ローカル アップロード URL があるときはそちらを優先します。",
|
||||
"失敗タスク ハンドラーが詰まった Issue を解除すると、Issue 表示が即座に更新され、手動リロードが不要になりました。",
|
||||
"Issue コメントの sticky ヘッダーがハイライトのフェードと同じ背景遷移を共有し、固定切り替えの違和感がなくなりました。",
|
||||
"Chat の会話は再接続時にメッセージ キャッシュを更新するため、オンラインに戻った直後に古いメッセージが残らなくなりました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.31",
|
||||
date: "2026-06-26",
|
||||
title: "ワークスペース横断の未読ドット、Composio ツールキット基盤、より使いやすいエディター",
|
||||
changes: [],
|
||||
features: [
|
||||
"ワークスペース切替メニューで、別のワークスペースに未読のインボックスがあるとドットが表示されます。",
|
||||
"これから提供するサードパーティ ツールキット連携のための Composio 基盤が組み込まれました。",
|
||||
"複数のチェックアウトでデスクトップ開発環境を並列に起動しても衝突しなくなりました。",
|
||||
"中国語ドキュメントのトップに短いイントロ動画を追加しました。",
|
||||
],
|
||||
improvements: [
|
||||
"コントリビューター ドキュメントに、デスクトップ開発コマンドがチェックアウトごとに自動で隔離されることを明記しました。",
|
||||
],
|
||||
fixes: [
|
||||
"Issue エディター内のリストで Tab を押すと、選択した項目が安定して字下げされ、カーソルがリストの外に飛ばなくなりました。",
|
||||
"コメントの @メンションでスクワッド リーダーに依頼すると、スクワッドのブリーフィングを携えて起動し、親メンションを引き継いだ返信が再度トリガーすることもありません。",
|
||||
"Issue やコメント内のコード ブロックで選択したテキストが、画面の別領域が再描画されても解除されなくなりました。",
|
||||
"Issue を特定のエージェントに直接アサインすると、ハンドオフ メモ欄がそのまますぐに開きます。",
|
||||
"ワークスペース切替メニューの未読ドットが、実際のインボックス表示と一致するようになりました。",
|
||||
"Issue のコメント編集時、保存ボタンに明確なローディング表示が出るようになりました。",
|
||||
"検索結果が再び安定して読み込まれます。",
|
||||
"セルフホストで Docker Compose v2 が見つからないときは、すぐに分かりやすい案内とともに停止します。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.30",
|
||||
date: "2026-06-25",
|
||||
title: "Slack 連携チャネルの追加、より使いやすいエディター、多数の安定性修正",
|
||||
changes: [],
|
||||
features: [
|
||||
"Slack の会話が新しい統合連携チャネル上で動くようになり、Feishu や Lark と同じ安定感でメッセージをやり取りできます。",
|
||||
"Issue エディター上で Tab を押すと、ハイライト中の @メンションや候補がそのまま挿入され、相手や Issue を 1 回のキー操作で選べます。",
|
||||
"エディターのフローティングメニューに追加されたワンクリックボタンで、段落をタスクリストに素早く切り替えられます。",
|
||||
],
|
||||
improvements: [
|
||||
"フロントエンドのコードを変更していないプルリクエストはフロントエンド CI を自動的にスキップし、本当に検証が必要な変更にビルド時間を回せます。",
|
||||
"コマンドラインのサブコマンドに対する自動テストの範囲が広がり、リリースを重ねても日常の作業フローが安定して動きます。",
|
||||
"プロバイダーごとのデフォルト エージェント引数を制御する環境変数が公式に文書化され、統合連携チャネルが完全に定着したことで使われなくなった Lark 切り替えスイッチを整理しました。",
|
||||
],
|
||||
fixes: [
|
||||
"OpenClaw が設定ファイルの差異により寛容になり、新しい 2026.6.x の agents スキーマに対応したため、既存の OpenClaw ランタイムが切断されにくくなりました。",
|
||||
"Issue を別プロジェクトに移すと旧プロジェクトの一覧からすぐに外れ、ボード表示外でステータスが変わってもカラムの件数が正しく揃います。",
|
||||
"添付ファイルが別オリジンから配信されている場合でも、プレビューが正しく開けます。",
|
||||
"コマンドライン エージェントはデーモンの準備完了を待ってから個人アクセストークンへフォールバックするため、認証が静かにダウングレードしなくなり、セルフホスティングのセットアップも既存設定を尊重しつつサーバー URL の変更をはっきり知らせます。",
|
||||
"Lark メッセージの Web リンクは汎用 URL ではなく、設定したアプリ URL を使うようになりました。",
|
||||
"Codex の実行は出力があふれても適切にクリーンアップされて止まらなくなり、Kiro の実行は終了時にエラーが出ても目標達成状態を保持し、エージェント終了時には opencode プロセスグループ全体を先に終了させてから出力を閉じます。",
|
||||
"Issue のクイック作成で複数ファイルを同時にアップロードしても、すべての添付が確実に残ります。",
|
||||
"Redis ベースの Webhook レート制限が無関係な Webhook を巻き込まなくなり、大きなスキルパックを含めてデーモンが安定して読み込めるようになりました。",
|
||||
"Issue ラベル名は制御文字を受け付けなくなり、どの画面でもラベルが読みやすく保たれます。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.29",
|
||||
date: "2026-06-24",
|
||||
title: "Feishu 連携チャネルの刷新、機能ロールアウト、オートパイロットの信頼性向上",
|
||||
changes: [],
|
||||
features: [
|
||||
"Feishu の会話が新しい統合連携チャネル上で動くようになり、メッセージの送受信がより安定・一貫し、今後さらに多くのチャットプラットフォームを追加しやすくなりました。",
|
||||
"機能ロールアウトの制御がアプリとデーモンの両方に広がり、チームは影響の大きい変更を段階的かつ限定的に有効化できます。",
|
||||
"エージェントが長い Issue 議論を読むとき、解決済みスレッドが要点となる結論まで自動で折りたたまれ、コンテキストがより集中します。",
|
||||
"Feishu では `/new` コマンドで新しい会話を始められ、Feishu の WebSocket 接続には指定のプロキシを使えます。",
|
||||
],
|
||||
improvements: [
|
||||
"スケジュールされたオートパイロットの信頼性が向上しました。取り逃し、再試行、複数ランナーの同時処理があっても、意図したとおり 1 回だけ実行されます。",
|
||||
"エージェントのランタイム説明は、より簡潔な版に切り替えて冗長な内容を省けます。必要なときは従来の詳しい版に戻せます。",
|
||||
"ランタイムプロバイダーのドキュメントを現在の対応プロバイダーに合わせ、Qoder・CodeBuddy・Antigravity の案内を追加し、古い Gemini CLI ランタイムを削除しました。",
|
||||
"プロジェクトのリポジトリ設定で指定したブランチ / バージョンが、ローカルエージェントの作業時に正しく反映され、誤ったブランチを取得しなくなります。",
|
||||
],
|
||||
fixes: [
|
||||
"親 Issue 内の子 Issue が作成順で安定して表示されます。",
|
||||
"Issue 内の添付ファイルプレビューが正しく開けるようになりました。",
|
||||
"@メンション候補は、検索結果の並びが変わってもハイライト中の人または Issue を正しく選びます。",
|
||||
"キャンセルしたチャット下書きを削除すると、画面を移動して戻っても再表示されなくなりました。",
|
||||
"オートパイロットのコールドスタート、Issue ヘッダーのエージェント状態、Antigravity のプロバイダーエラー表示がより正確になりました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.28",
|
||||
date: "2026-06-23",
|
||||
|
||||
@@ -268,6 +268,103 @@ export function createKoDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "버그 수정",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.32",
|
||||
date: "2026-06-29",
|
||||
title: "하위 Issue 분리, 더 견고한 데몬 재연결, 어디서나 열리는 첨부 미리보기",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 액션에 '상위 Issue 해제'가 추가되어, 다른 상위를 먼저 고르지 않고도 하위 Issue를 즉시 분리할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"로컬 데몬이 더 견고한 WebSocket 흐름과 상한이 있는 백오프로 재연결해, 짧은 네트워크 단절에도 매끄럽게 복구됩니다.",
|
||||
"데몬이 각 런타임의 버전 점검에 별도 타임아웃을 두어, 멈춰 버린 단 하나의 CLI가 다른 런타임의 기동을 막지 못합니다.",
|
||||
],
|
||||
fixes: [
|
||||
"예약 오토파일럿은 디스패치되자마자 다음 실행 시각을 앞당겨, 느린 러너가 같은 실행을 중복으로 내보내지 않습니다.",
|
||||
"첨부 미리보기는 프레임 내 리다이렉트, 동일 출처, 로컬 업로드 어떤 경우에도 정상적으로 열리며, 로컬 업로드 URL이 있으면 그쪽을 우선 사용합니다.",
|
||||
"실패 작업 핸들러가 멈춘 Issue를 풀어 줄 때 화면이 즉시 갱신되어, 수동 새로고침이 필요 없습니다.",
|
||||
"Issue 댓글의 sticky 헤더가 하이라이트 페이드와 같은 배경 전환을 공유해, 고정 표시 전환이 더 이상 어색하지 않습니다.",
|
||||
"Chat 대화가 재연결 시 메시지 캐시를 새로 받아, 오프라인에서 돌아왔을 때 오래된 메시지가 남지 않습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.31",
|
||||
date: "2026-06-26",
|
||||
title: "워크스페이스 간 미확인 점, Composio 툴킷 기반, 더 편한 에디터",
|
||||
changes: [],
|
||||
features: [
|
||||
"워크스페이스 전환기에서 다른 워크스페이스에 미확인 인박스가 있으면 점이 표시됩니다.",
|
||||
"곧 도입될 서드파티 툴킷 연동을 위한 Composio 기반이 추가되었습니다.",
|
||||
"여러 체크아웃에서 데스크톱 개발 환경을 동시에 실행해도 충돌이 없습니다.",
|
||||
"중국어 문서 홈에 짧은 소개 영상이 추가되었습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"기여자 문서가 데스크톱 개발 명령이 체크아웃별로 자동 격리된다는 점을 안내합니다.",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 에디터 목록에서 Tab을 누르면 선택한 항목이 안정적으로 들여쓰기되고, 커서가 목록 밖으로 빠지지 않습니다.",
|
||||
"댓글에서 @멘션으로 스쿼드 리더에게 작업을 맡기면 전체 스쿼드 브리핑과 함께 시작하며, 부모 멘션을 그대로 이어받은 답글은 리더를 다시 트리거하지 않습니다.",
|
||||
"Issue와 댓글의 코드 블록에서 선택한 텍스트가 페이지의 다른 부분이 다시 렌더링되어도 풀리지 않습니다.",
|
||||
"Issue를 특정 에이전트에 바로 할당하면 핸드오프 메모가 기다림 없이 곧바로 열립니다.",
|
||||
"워크스페이스 전환기의 미확인 점이 실제 인박스 화면과 일치합니다.",
|
||||
"Issue 댓글 편집 시 저장 버튼에 로딩 상태가 표시됩니다.",
|
||||
"검색 결과가 다시 안정적으로 로드됩니다.",
|
||||
"자체 호스팅에서 Docker Compose v2가 없으면 곧바로 명확한 안내와 함께 멈춥니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.30",
|
||||
date: "2026-06-25",
|
||||
title: "Slack 협업 채널 추가, 더 편한 에디터, 다수의 안정성 개선",
|
||||
changes: [],
|
||||
features: [
|
||||
"Slack 대화가 새로운 통합 협업 채널 위에서 동작해 Feishu·Lark와 동일한 안정성으로 메시지를 주고받을 수 있습니다.",
|
||||
"Issue 작성기에서 Tab을 누르면 현재 강조된 @멘션이나 추천 항목이 바로 입력되어, 동료나 Issue를 한 번의 키 입력으로 고를 수 있습니다.",
|
||||
"에디터의 플로팅 메뉴에 추가된 원클릭 버튼으로 단락을 할 일 목록으로 빠르게 전환할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"프런트엔드 코드를 건드리지 않은 풀 리퀘스트는 프런트엔드 CI를 자동으로 건너뛰어, 실제로 검증이 필요한 변경에 빌드 시간이 돌아갑니다.",
|
||||
"명령줄 서브커맨드의 자동화 테스트 범위가 넓어져서, 릴리스를 거듭해도 일상적인 작업 흐름이 안정적으로 유지됩니다.",
|
||||
"제공자별 기본 에이전트 인자 환경 변수에 대한 공식 문서가 추가되었고, 통합 협업 채널이 완전히 정착함에 따라 일회성 Lark 전환 스위치를 정리했습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"OpenClaw가 설정 파일 차이에 더 너그러워졌고, 새로운 2026.6.x agents 스키마를 지원해 기존 OpenClaw 런타임이 끊기지 않습니다.",
|
||||
"Issue를 다른 프로젝트로 옮기면 이전 프로젝트 목록에서 즉시 빠지고, 보드 화면 밖에서 상태가 바뀌어도 컬럼 카운트가 정확하게 맞춰집니다.",
|
||||
"파일이 다른 출처에서 제공되더라도 첨부 파일 미리보기가 정상적으로 열립니다.",
|
||||
"명령줄 에이전트는 데몬이 준비된 뒤에야 개인 액세스 토큰으로 폴백하므로 인증이 조용히 다운그레이드되지 않고, 자체 호스팅 설정도 기존 구성을 존중하면서 서버 URL 변경을 분명히 보여 줍니다.",
|
||||
"Lark 메시지의 웹 링크는 일반 주소로 떨어지지 않고, 구성된 앱 URL을 사용합니다.",
|
||||
"Codex 실행은 출력이 넘쳐도 깔끔하게 정리되어 더 이상 멈추지 않고, Kiro 실행은 종료 중 오류가 발생해도 목표 완료 상태를 유지하며, 에이전트 종료 시에는 opencode 프로세스 그룹 전체를 먼저 끝낸 뒤 출력을 닫습니다.",
|
||||
"Issue 빠른 생성 시 동시에 업로드되는 여러 파일이 안정적으로 모두 첨부됩니다.",
|
||||
"Redis 기반 Webhook 속도 제한이 더 이상 서로 무관한 Webhook을 한꺼번에 제한하지 않으며, 데몬은 큰 스킬 묶음도 안정적으로 로드합니다.",
|
||||
"Issue 라벨 이름은 제어 문자를 거부해 모든 화면에서 라벨이 깔끔하게 표시됩니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.29",
|
||||
date: "2026-06-24",
|
||||
title: "Feishu 협업 채널 개선, 기능 출시 제어, 더 안정적인 오토파일럿",
|
||||
changes: [],
|
||||
features: [
|
||||
"Feishu 대화가 새로운 통합 협업 채널에서 동작해 메시지 송수신이 더 안정적이고 일관되며, 앞으로 더 많은 채팅 플랫폼을 붙이기 쉬워졌습니다.",
|
||||
"기능 출시 제어가 앱과 데몬 양쪽으로 확장되어, 팀이 영향이 큰 변경을 단계적으로 그리고 제한된 범위로 켤 수 있습니다.",
|
||||
"에이전트가 긴 Issue 토론을 읽을 때 해결된 스레드가 핵심 결론으로 자동으로 접혀 컨텍스트가 더 집중됩니다.",
|
||||
"Feishu 사용자는 `/new` 명령으로 새 대화를 시작할 수 있고, Feishu WebSocket 연결은 지정한 프록시를 사용할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"예약 오토파일럿의 안정성이 향상되었습니다. 놓친 일정, 재시도, 여러 러너의 동시 처리가 있어도 의도한 대로 한 번만 실행됩니다.",
|
||||
"에이전트 런타임 안내를 더 간결한 버전으로 전환해 불필요한 내용을 줄일 수 있으며, 필요하면 기존의 자세한 버전으로 되돌릴 수 있습니다.",
|
||||
"런타임 제공자 문서를 현재 지원 목록에 맞추고 Qoder·CodeBuddy·Antigravity 안내를 추가했으며, 오래된 Gemini CLI 런타임을 제거했습니다.",
|
||||
"프로젝트 저장소 설정에서 지정한 브랜치 / 버전이 로컬 에이전트 작업에 올바르게 반영되어, 더 이상 잘못된 브랜치를 가져오지 않습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"부모 Issue 안의 하위 Issue가 생성 순서대로 안정적으로 표시됩니다.",
|
||||
"Issue 안의 첨부 파일 미리보기가 올바르게 열립니다.",
|
||||
"@멘션 선택기는 검색 결과 순서가 바뀌어도 현재 강조된 사람이나 Issue를 정확히 선택합니다.",
|
||||
"취소한 채팅 초안을 삭제하면 화면을 이동했다가 돌아와도 다시 나타나지 않습니다.",
|
||||
"오토파일럿 콜드 스타트, Issue 헤더의 에이전트 상태, Antigravity 제공자 오류 표시가 더 정확해졌습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.28",
|
||||
date: "2026-06-23",
|
||||
|
||||
@@ -293,6 +293,103 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.32",
|
||||
date: "2026-06-29",
|
||||
title: "支持解除父子 Issue、守护进程重连更稳,附件预览处处可开",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 操作菜单新增「移除父级 Issue」,可以直接断开父子关系,不用先去挑一个新的父级。",
|
||||
],
|
||||
improvements: [
|
||||
"本地守护进程的 WebSocket 重连改为带上限的退避策略,短暂断网时恢复更顺滑,不再原地空转。",
|
||||
"守护进程在探测各个智能体运行时版本时加上了独立超时,单个卡死的 CLI 不会再连累其他运行时。",
|
||||
],
|
||||
fixes: [
|
||||
"定时 Autopilot 调度后会立即推进下一次运行时间,避免慢节点造成重复触发。",
|
||||
"附件预览在框架内重定向、同源资源、本地上传等场景下都能正常打开;有本地上传 URL 时会优先使用本地链接。",
|
||||
"失败任务处理器解开卡住的 Issue 时,前端视图会立即刷新,无需手动重新加载。",
|
||||
"Issue 评论吸顶头与高亮渐隐使用了同一套背景过渡,吸顶切换不再有错位感。",
|
||||
"Chat 在重新连上后会刷新消息缓存,掉线再回来时不再看到陈旧消息。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.31",
|
||||
date: "2026-06-26",
|
||||
title: "跨工作区未读小圆点、Composio 工具集底座、更顺手的编辑器",
|
||||
changes: [],
|
||||
features: [
|
||||
"工作区切换器里,其他工作区有未读 Inbox 时会亮起小圆点。",
|
||||
"新增 Composio 工具集底座,为后续第三方工具对接做好准备。",
|
||||
"现在可以在多个本地检出里并行启动桌面端 dev,互不打架。",
|
||||
"中文文档首页新增一段中文介绍视频,可点击播放。",
|
||||
],
|
||||
improvements: [
|
||||
"贡献者文档明确说明桌面端 dev 命令会按检出自动隔离。",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 编辑器列表里按 Tab 现在能稳定缩进所选项,光标也不会跑出列表。",
|
||||
"通过 @ 提及让小队 Leader 接手时,会带上完整的小队 Briefing;继承父级提及的回复也不会再次触发 Leader。",
|
||||
"Issue 和评论里代码块的选区,在页面其他位置刷新时不再丢失。",
|
||||
"把 Issue 直接交给某个智能体时,运行确认弹窗会立刻展开 Handoff 备注。",
|
||||
"工作区切换器上的未读小圆点会和你看到的 Inbox 保持一致。",
|
||||
"编辑 Issue 评论时,保存按钮会显示加载状态,直到保存完成。",
|
||||
"搜索结果能够稳定加载。",
|
||||
"自托管缺少 Docker Compose v2 时会立刻给出明确的安装提示。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.30",
|
||||
date: "2026-06-25",
|
||||
title: "Slack 协作通道接入,编辑器更顺手,多项稳定性修复",
|
||||
changes: [],
|
||||
features: [
|
||||
"Slack 对话接入全新的统一协作通道,与飞书、Lark 一样稳定,消息收发更可靠",
|
||||
"在 Issue 编辑器里按 Tab,可以直接选中当前高亮的 @ 提及或建议项,挑选同事或 Issue 一键完成",
|
||||
"在编辑器的浮动菜单里新增一键开关,能够快速把段落切换成任务清单",
|
||||
],
|
||||
improvements: [
|
||||
"前端持续集成会自动跳过没有改动前端代码的 PR,把构建时间留给真正需要的改动",
|
||||
"命令行子命令的自动化测试覆盖更广,让日常工作流在每次发版后依然稳定",
|
||||
"为每个服务商默认的智能体启动参数补齐说明文档,并下线了一次性的飞书切换开关——统一协作通道已经在生产环境完全接管",
|
||||
],
|
||||
fixes: [
|
||||
"OpenClaw 对配置文件差异更宽容,并且支持新版 2026.6.x 的 agents 配置格式,已有的 OpenClaw 运行时不会因此掉线",
|
||||
"把 Issue 移动到其他项目时,会立刻从原来的项目列表里消失;并且在 Issue 状态从看板视野外切换时,看板列上的数字也会正确同步",
|
||||
"当附件由不同来源的资源服务器提供时,预览也可以正常打开",
|
||||
"命令行智能体会等待守护进程就绪后再决定鉴权来源,避免悄悄回落到个人访问令牌;自托管环境配置流程也会沿用现有设置并清晰展示服务地址的变化",
|
||||
"飞书消息中的网页链接现在会指向你配置的应用 URL,而不是回退到通用网址",
|
||||
"Codex 任务在输出过载时也能正常清理,不会再卡住;Kiro 任务即便关闭过程中出现错误,也能保留目标完成状态;智能体退出时会先终止整组 opencode 子进程,再关闭输出",
|
||||
"在快速创建 Issue 时同时上传多个文件,所有附件都会稳定地保留下来",
|
||||
"Redis 上的 Webhook 限流不会再把无关的 Webhook 合并计算,避免被一起误伤;守护进程加载多个 skill 包时,即便 skill 体积较大也能稳定完成",
|
||||
"Issue 标签名不再接受控制字符,标签在各端展示都更整洁可读",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.29",
|
||||
date: "2026-06-24",
|
||||
title: "飞书协作通道升级,新增功能灰度发布,定时自动化更可靠",
|
||||
changes: [],
|
||||
features: [
|
||||
"飞书对话升级到全新的统一协作通道,消息收发更稳定一致,也为后续接入更多聊天平台打下基础",
|
||||
"新增功能灰度能力,覆盖应用和守护进程两侧,团队可以分阶段、小范围地开放高风险改动",
|
||||
"智能体阅读很长的 Issue 讨论时,会自动把已解决的讨论折叠到关键结论,让上下文更聚焦",
|
||||
"飞书用户可以用 `/new` 开启新会话,飞书的 WebSocket 连接也支持配置代理",
|
||||
],
|
||||
improvements: [
|
||||
"定时自动化更可靠:遇到漏跑、重试或多个执行端同时处理时,也能稳定地只按预期执行一次",
|
||||
"智能体运行的开场说明可以切换到更精简的版本,去掉冗余内容,必要时仍可切回完整版本",
|
||||
"运行时服务商文档已更新到当前支持的服务商,新增 Qoder、CodeBuddy、Antigravity 说明,并移除过时的 Gemini CLI 信息",
|
||||
"项目仓库设置里指定的分支 / 版本,现在会在本地智能体工作时正确生效,不会再拿到错误的分支",
|
||||
],
|
||||
fixes: [
|
||||
"父 Issue 下的子 Issue 现在会按创建顺序稳定展示",
|
||||
"Issue 内的附件预览现在可以正常打开",
|
||||
"@ 提及时即使搜索结果重新排序,也会准确选中当前高亮的人或 Issue",
|
||||
"删除已取消的聊天草稿后,切换页面再回来不会再次出现",
|
||||
"自动化冷启动、Issue 顶部智能体状态和 Antigravity 服务商错误提示更准确",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.28",
|
||||
date: "2026-06-23",
|
||||
|
||||
252
docs/feature-flags.md
Normal file
252
docs/feature-flags.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Feature Flags
|
||||
|
||||
Multica ships a framework-level feature flag implementation:
|
||||
|
||||
- **Backend**: `server/pkg/featureflag` — Go package.
|
||||
- **Frontend**: `@multica/core/feature-flags` — TypeScript module with React hooks.
|
||||
|
||||
Both sides share the same vocabulary (`Decision`, `EvalContext`, `Rule`, `PercentRollout`) and the same FNV-1a percent bucketing, so a flag evaluated on the server and on the client lands in the same bucket for the same user.
|
||||
|
||||
The package is designed so new features can adopt feature flags without writing any infrastructure code — drop a rule into the static config, call `Service.IsEnabled` / `useFlag`, done.
|
||||
|
||||
---
|
||||
|
||||
## Core concepts
|
||||
|
||||
```
|
||||
[Toggle Point] --query--> [Service / Router] --read--> [Provider / Configuration]
|
||||
business code static / env / chain
|
||||
```
|
||||
|
||||
- A **Toggle Point** is the single `if` in business code. It always calls the Service, never the provider directly.
|
||||
- The **Service** (`Service` in Go, `FeatureFlagService` in TS) is the router. Business code never depends on which provider is behind it.
|
||||
- A **Provider** is the configuration backend. Today we ship `StaticProvider` (in-memory rules), `EnvProvider` (Go only — env-var override), and `ChainProvider` (composition). A future DB or LaunchDarkly provider plugs in without changing any caller.
|
||||
- A **Decision** is the structured result: `{ enabled, variant, reason, source }`. `IsEnabled` is the boolean projection, `Variant` is the raw string. Use `Decision` for diagnostic endpoints.
|
||||
|
||||
Four flag categories (Martin Fowler):
|
||||
|
||||
| Category | Lifetime | Owner | Example |
|
||||
|---|---|---|---|
|
||||
| **Release** | Days–weeks | Engineering | Hide a half-finished page behind `flags_release_v2` |
|
||||
| **Experiment** | Hours–weeks | Product / Data | A/B test `checkout_algo` between `control` and `experiment-v2` |
|
||||
| **Ops** | Short or evergreen | SRE | Kill switch `billing_disable_invoice_pdf` |
|
||||
| **Permission** | Years | Product | `plan_gate_enterprise_dashboard` |
|
||||
|
||||
Manage them in the same provider but treat them differently: Release flags get deleted; Ops flags need fast override paths (`FF_<KEY>` env var); Permission flags use `Allow` lists; Experiment flags use `PercentRollout`.
|
||||
|
||||
---
|
||||
|
||||
## Backend (Go)
|
||||
|
||||
### Wiring at startup
|
||||
|
||||
The server constructs a `featureflag.Service` once in `cmd/server/main.go` via the standard helper:
|
||||
|
||||
```go
|
||||
flags, err := featureflag.NewServiceFromEnv(featureflag.WithLogger(slog.Default()))
|
||||
if err != nil {
|
||||
slog.Error("feature flag configuration failed to load", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
`NewServiceFromEnv` reads two env vars — both follow the same `MULTICA_*_FILE` / `FF_*` conventions documented in `.env.example`:
|
||||
|
||||
| Env var | Role |
|
||||
|---|---|
|
||||
| `MULTICA_FEATURE_FLAGS_FILE` | Path to the YAML rule set (optional; absent = no static rules). |
|
||||
| `FF_<FLAG_KEY>` | Per-flag runtime override. `FF_BILLING_NEW_INVOICE_EMAIL=false` / `25%` / `experiment-v2`. Beats the YAML, no redeploy. |
|
||||
|
||||
The provider chain is `EnvProvider → YAML StaticProvider`. The server can boot with zero flag config — every `IsEnabled` call falls back to the caller's default until someone authors a rule.
|
||||
|
||||
### Daemon-bound flags
|
||||
|
||||
Daemon-bound flags are evaluated by the server and delivered to local daemons
|
||||
over the daemon heartbeat ack. This is for process-level daemon behavior where
|
||||
operators need one rollout and kill-switch path across cloud runtimes, Desktop
|
||||
embedded daemons, and user-run CLI daemons.
|
||||
|
||||
Only flags listed in `server/internal/featureflagdispatch/registry.go` are sent
|
||||
to daemons. The registry is intentionally short:
|
||||
|
||||
```go
|
||||
var DaemonBoundFlags = []string{
|
||||
"runtime_brief_slim",
|
||||
}
|
||||
```
|
||||
|
||||
On each HTTP or WebSocket heartbeat, the server evaluates every registered key
|
||||
as a daemon/process-level decision. The snapshot EvalContext exposes
|
||||
`daemon_id` only; workspace/runtime/task/user scoped rollout is intentionally
|
||||
not part of this channel because the daemon stores one process-global snapshot.
|
||||
The heartbeat ack carries a full snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"feature_flags": {
|
||||
"version": 1,
|
||||
"flags": {
|
||||
"runtime_brief_slim": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The daemon installs that snapshot into its process-level feature flag service.
|
||||
The daemon provider order is:
|
||||
|
||||
1. `EnvProvider` (`FF_*`) for local emergency overrides.
|
||||
2. `ServerSnapshotProvider` from the latest heartbeat ack.
|
||||
3. local YAML `StaticProvider` as a fallback for old servers or self-hosted rescue.
|
||||
4. the toggle point's caller-supplied default.
|
||||
|
||||
That means `FF_RUNTIME_BRIEF_SLIM=false` always suppresses a server snapshot
|
||||
that enables `runtime_brief_slim`. New daemons talking to old servers receive no
|
||||
`feature_flags` field and automatically fall back to local env/YAML behavior.
|
||||
Old daemons talking to new servers ignore the unknown JSON field.
|
||||
|
||||
To add another daemon-bound process-level flag, add its key to the registry and
|
||||
use the existing daemon feature flag service at the toggle point. Do not add
|
||||
workspace percent rollout, task payload fields, or task-scoped readers for
|
||||
daemon-bound flags unless a separate design explicitly introduces scoped daemon
|
||||
flag evaluation.
|
||||
|
||||
### YAML schema
|
||||
|
||||
```yaml
|
||||
# /etc/multica/feature-flags.yaml
|
||||
billing_new_invoice_email:
|
||||
default: true
|
||||
|
||||
checkout_algo:
|
||||
default: false
|
||||
variant: experiment-v2
|
||||
percent:
|
||||
percent: 25
|
||||
by: user_id
|
||||
|
||||
ops_disable_recommendations:
|
||||
default: false
|
||||
allow: ["user-internal-1", "user-internal-2"]
|
||||
allow_by: user_id
|
||||
```
|
||||
|
||||
Every field except `default` is optional. `variant` is the on-variant — see the multi-arm note below. An empty file is a valid "no flags yet" state. Malformed YAML fails startup the same way `DATABASE_URL` parse errors do, so misconfig surfaces loudly.
|
||||
|
||||
### Attaching evaluation context to the request
|
||||
|
||||
```go
|
||||
func middleware(flags *featureflag.Service, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ec := featureflag.EvalContext{
|
||||
UserID: currentUserID(r),
|
||||
WorkspaceID: currentWorkspaceID(r),
|
||||
Attributes: map[string]string{"plan": currentPlan(r)},
|
||||
}
|
||||
ctx := featureflag.WithEvalContext(r.Context(), ec)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Toggle point in business code
|
||||
|
||||
```go
|
||||
if flags.IsEnabled(ctx, "billing_new_invoice_email", false) {
|
||||
return s.sendNewInvoiceEmail(ctx, invoice)
|
||||
}
|
||||
return s.sendLegacyInvoiceEmail(ctx, invoice)
|
||||
```
|
||||
|
||||
For multi-arm flags:
|
||||
|
||||
```go
|
||||
switch flags.Variant(ctx, "checkout_algo", "control") {
|
||||
case "experiment-v2":
|
||||
return checkoutV2(ctx, order)
|
||||
case "experiment-v3":
|
||||
return checkoutV3(ctx, order)
|
||||
default:
|
||||
return checkoutControl(ctx, order)
|
||||
}
|
||||
```
|
||||
|
||||
`Rule.Variant` is the **on-variant**: it is only returned when the rule evaluates to enabled=true (allow hit, percent hit, default-on). When the rule evaluates to disabled (deny hit, percent miss, default-off) the Service returns `"off"` so callers branching on `Variant()` cannot route control users into the experiment arm. This is exercised by `TestStaticProviderVariantOnlyWhenEnabled` and is the same on the TS side.
|
||||
|
||||
The Service is nil-safe and missing-key-safe: `(*Service)(nil).IsEnabled(ctx, "any", true)` returns `true`. Business code never needs to guard against a missing flag.
|
||||
|
||||
---
|
||||
|
||||
## Frontend (TypeScript / React)
|
||||
|
||||
### Mounting once at the root
|
||||
|
||||
```tsx
|
||||
// apps/web/app/_providers.tsx (or the equivalent root)
|
||||
import {
|
||||
FeatureFlagsProvider,
|
||||
FeatureFlagService,
|
||||
StaticProvider,
|
||||
} from "@multica/core/feature-flags";
|
||||
|
||||
const service = new FeatureFlagService(
|
||||
new StaticProvider({
|
||||
billing_v2_dashboard: { default: false, allow: ["user-internal"] },
|
||||
checkout_algo: { default: true, variant: "experiment-v2",
|
||||
percent: { percent: 25 } },
|
||||
}),
|
||||
);
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const userId = useCurrentUserId();
|
||||
return (
|
||||
<FeatureFlagsProvider service={service} context={{ userId }}>
|
||||
{children}
|
||||
</FeatureFlagsProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
When the backend pushes a fresh rule set (via an API response or WebSocket), call `service.setProvider(new StaticProvider(remoteRules))` and the whole tree re-evaluates.
|
||||
|
||||
### Toggle point in a component
|
||||
|
||||
```tsx
|
||||
import { useFlag, useVariant } from "@multica/core/feature-flags";
|
||||
|
||||
function BillingPage() {
|
||||
const showV2 = useFlag("billing_v2_dashboard", false);
|
||||
return showV2 ? <BillingV2 /> : <BillingV1 />;
|
||||
}
|
||||
|
||||
function Checkout() {
|
||||
const variant = useVariant("checkout_algo", "control");
|
||||
switch (variant) {
|
||||
case "experiment-v2": return <CheckoutV2 />;
|
||||
case "experiment-v3": return <CheckoutV3 />;
|
||||
default: return <CheckoutControl />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Outside a `FeatureFlagsProvider` (Storybook, unit tests, error pages) `useFlag` / `useVariant` return the supplied default. You never have to mount the provider just to render a component in isolation.
|
||||
|
||||
### Security note: never rely on the frontend alone
|
||||
|
||||
A frontend feature flag controls what the user *sees*. It does NOT enforce access. Any API route exposing the same capability MUST evaluate the matching backend flag independently. The two flags can share a key but they live in two `Service` instances and the backend value is the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Best-practice checklist
|
||||
|
||||
Adopted from Martin Fowler, ConfigCat and Octopus.
|
||||
|
||||
- **Naming**: `{team}_{area}_{behavior}`, e.g. `billing_checkout_new_payment_flow`. No `enable_` / `disable_` prefixes (redundant).
|
||||
- **One flag, one purpose**: never repurpose an old flag for a new feature. Add a new flag and delete the old one.
|
||||
- **Plan the death of the flag at birth**: open a follow-up issue to remove the flag when the rollout completes. Release flags should live days, not quarters.
|
||||
- **Convention**: `Off` is the legacy / safe state, `On` is the new behavior. Lets CI test "all-off (today)" and "all-on (tomorrow)".
|
||||
- **Kill switch fast path**: ops-critical flags should be exposed via `EnvProvider` so SREs can flip them without a deploy.
|
||||
- **Backend protection**: anything controlling access goes through the backend Service; the frontend flag is presentation only.
|
||||
- **No secrets in flags**: variant values are not Secrets Manager / KMS. Use those for tokens, keys, and passwords.
|
||||
|
||||
See `docs/design.md` and `docs/timezone-architecture-rfc.md` for prior examples of how this pattern is used across the codebase.
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
CreateRuntimeProfileRequest,
|
||||
UpdateRuntimeProfileRequest,
|
||||
InboxItem,
|
||||
InboxWorkspaceUnread,
|
||||
IssueSubscriber,
|
||||
Comment,
|
||||
CommentTriggerPreview,
|
||||
@@ -99,6 +100,7 @@ import type {
|
||||
UpdateAutopilotTriggerRequest,
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
AutopilotCollaboratorsResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
WebhookDelivery,
|
||||
@@ -111,6 +113,10 @@ import type {
|
||||
BeginLarkInstallResponse,
|
||||
LarkInstallStatusResponse,
|
||||
RedeemLarkBindingTokenResponse,
|
||||
SlackInstallation,
|
||||
ListSlackInstallationsResponse,
|
||||
RegisterSlackBYORequest,
|
||||
RedeemSlackBindingTokenResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
@@ -159,6 +165,8 @@ import {
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
EMPTY_GROUPED_ISSUES_RESPONSE,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_SEARCH_ISSUES_RESPONSE,
|
||||
EMPTY_SEARCH_PROJECTS_RESPONSE,
|
||||
EMPTY_SQUAD,
|
||||
EMPTY_SQUAD_LIST,
|
||||
EMPTY_SQUAD_MEMBER_STATUS_LIST,
|
||||
@@ -177,6 +185,8 @@ import {
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
RuntimeUsageListSchema,
|
||||
SearchIssuesResponseSchema,
|
||||
SearchProjectsResponseSchema,
|
||||
SquadSchema,
|
||||
SquadListSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
@@ -201,6 +211,8 @@ import {
|
||||
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
|
||||
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
|
||||
EMPTY_CANCEL_TASK_RESPONSE,
|
||||
InboxUnreadSummarySchema,
|
||||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
@@ -551,7 +563,13 @@ export class ApiClient {
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||||
if (params.include_closed) search.set("include_closed", "true");
|
||||
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/issues/search?${search}`,
|
||||
params.signal ? { signal: params.signal } : undefined,
|
||||
);
|
||||
return parseWithFallback(raw, SearchIssuesResponseSchema, EMPTY_SEARCH_ISSUES_RESPONSE, {
|
||||
endpoint: "GET /api/issues/search",
|
||||
});
|
||||
}
|
||||
|
||||
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
|
||||
@@ -559,7 +577,13 @@ export class ApiClient {
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||||
if (params.include_closed) search.set("include_closed", "true");
|
||||
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/projects/search?${search}`,
|
||||
params.signal ? { signal: params.signal } : undefined,
|
||||
);
|
||||
return parseWithFallback(raw, SearchProjectsResponseSchema, EMPTY_SEARCH_PROJECTS_RESPONSE, {
|
||||
endpoint: "GET /api/projects/search",
|
||||
});
|
||||
}
|
||||
|
||||
async getIssue(id: string): Promise<Issue> {
|
||||
@@ -1459,6 +1483,17 @@ export class ApiClient {
|
||||
return this.fetch("/api/inbox/unread-count");
|
||||
}
|
||||
|
||||
// Cross-workspace unread summary: one entry per workspace the user belongs
|
||||
// to that has unread inbox items. Backs the workspace-switcher dot for
|
||||
// OTHER workspaces. Schema-guarded so a contract drift hides the dot rather
|
||||
// than crashing the sidebar.
|
||||
async getInboxUnreadSummary(): Promise<InboxWorkspaceUnread[]> {
|
||||
const raw = await this.fetch<unknown>("/api/inbox/unread-summary");
|
||||
return parseWithFallback(raw, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, {
|
||||
endpoint: "GET /api/inbox/unread-summary",
|
||||
});
|
||||
}
|
||||
|
||||
async markAllInboxRead(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
|
||||
}
|
||||
@@ -2077,6 +2112,22 @@ export class ApiClient {
|
||||
await this.fetch(`/api/autopilots/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Grant a workspace member explicit write access to the autopilot. Both
|
||||
// grant and revoke return the full updated collaborator list so callers can
|
||||
// refresh without a second round-trip.
|
||||
async grantAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
|
||||
return this.fetch(`/api/autopilots/${id}/collaborators`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
|
||||
return this.fetch(`/api/autopilots/${id}/collaborators/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async triggerAutopilot(id: string): Promise<AutopilotRun> {
|
||||
return this.fetch(`/api/autopilots/${id}/trigger`, { method: "POST" });
|
||||
}
|
||||
@@ -2240,4 +2291,37 @@ export class ApiClient {
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
// Slack integration (MUL-3666)
|
||||
async listSlackInstallations(workspaceId: string): Promise<ListSlackInstallationsResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/slack/installations`);
|
||||
}
|
||||
|
||||
// registerSlackBYO performs a bring-your-own-app install: the admin pastes the
|
||||
// bot token (xoxb-) + app-level token (xapp-) of the Slack app they created,
|
||||
// and the backend validates + persists it, returning the new installation.
|
||||
async registerSlackBYO(
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
body: RegisterSlackBYORequest,
|
||||
): Promise<SlackInstallation> {
|
||||
const search = new URLSearchParams({ agent_id: agentId });
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/slack/install/byo?${search.toString()}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSlackInstallation(workspaceId: string, installationId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}/slack/installations/${installationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async redeemSlackBindingToken(token: string): Promise<RedeemSlackBindingTokenResponse> {
|
||||
return this.fetch(`/api/slack/binding/redeem`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,24 @@ describe("ApiClient schema fallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchIssues", () => {
|
||||
it("falls back to an empty result when the response is malformed", async () => {
|
||||
stubFetchJson({ issues: "not-an-array", total: 0 });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.searchIssues({ q: "bug" });
|
||||
expect(res).toEqual({ issues: [], total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchProjects", () => {
|
||||
it("falls back to an empty result when the response is malformed", async () => {
|
||||
stubFetchJson({ projects: "not-an-array", total: 0 });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.searchProjects({ q: "roadmap" });
|
||||
expect(res).toEqual({ projects: [], total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAutopilots", () => {
|
||||
const baseAutopilot = {
|
||||
id: "ap-1",
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
DuplicateIssueErrorBodySchema,
|
||||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||||
EMPTY_USER,
|
||||
InboxUnreadSummarySchema,
|
||||
IssueTriggerPreviewSchema,
|
||||
ListIssuesResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
@@ -415,3 +417,43 @@ describe("AppConfigSchema cdn_signed drift", () => {
|
||||
expect(parsed.cdn_signed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InboxUnreadSummarySchema", () => {
|
||||
const ENDPOINT = { endpoint: "GET /api/inbox/unread-summary" };
|
||||
|
||||
it("parses a well-formed summary and tolerates extra fields", () => {
|
||||
const parsed = parseWithFallback(
|
||||
[
|
||||
{ workspace_id: "ws-1", count: 2 },
|
||||
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
|
||||
],
|
||||
InboxUnreadSummarySchema,
|
||||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||||
ENDPOINT,
|
||||
);
|
||||
expect(parsed).toEqual([
|
||||
{ workspace_id: "ws-1", count: 2 },
|
||||
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the empty fallback (dot hidden) for a non-array body", () => {
|
||||
expect(
|
||||
parseWithFallback({ rows: [] }, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
|
||||
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
|
||||
expect(
|
||||
parseWithFallback(null, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
|
||||
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
|
||||
});
|
||||
|
||||
it("returns the empty fallback when an entry has a wrong-typed count", () => {
|
||||
expect(
|
||||
parseWithFallback(
|
||||
[{ workspace_id: "ws-1", count: "lots" }],
|
||||
InboxUnreadSummarySchema,
|
||||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||||
ENDPOINT,
|
||||
),
|
||||
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,11 @@ import type {
|
||||
CreateBillingCheckoutSessionResponse,
|
||||
CreateBillingPortalSessionResponse,
|
||||
GroupedIssuesResponse,
|
||||
InboxWorkspaceUnread,
|
||||
ListIssuesResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
SearchIssuesResponse,
|
||||
SearchProjectsResponse,
|
||||
Squad,
|
||||
TimelineEntry,
|
||||
User,
|
||||
@@ -275,6 +278,55 @@ export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const SearchIssueResultSchema = IssueSchema.extend({
|
||||
match_source: z.string(),
|
||||
matched_snippet: z.string().optional(),
|
||||
matched_description_snippet: z.string().optional(),
|
||||
matched_comment_snippet: z.string().optional(),
|
||||
}).loose();
|
||||
|
||||
export const SearchIssuesResponseSchema = z.object({
|
||||
issues: z.array(SearchIssueResultSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_SEARCH_ISSUES_RESPONSE: SearchIssuesResponse = {
|
||||
issues: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const ProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
icon: z.string().nullable(),
|
||||
status: z.string(),
|
||||
priority: z.string(),
|
||||
lead_type: z.string().nullable(),
|
||||
lead_id: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
issue_count: z.number().default(0),
|
||||
done_count: z.number().default(0),
|
||||
resource_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
const SearchProjectResultSchema = ProjectSchema.extend({
|
||||
match_source: z.string(),
|
||||
matched_snippet: z.string().optional(),
|
||||
}).loose();
|
||||
|
||||
export const SearchProjectsResponseSchema = z.object({
|
||||
projects: z.array(SearchProjectResultSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_SEARCH_PROJECTS_RESPONSE: SearchProjectsResponse = {
|
||||
projects: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const IssueAssigneeGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
assignee_type: z.string().nullable(),
|
||||
@@ -788,6 +840,10 @@ const AutopilotListItemSchema = z.object({
|
||||
trigger_kinds: z.array(z.string()).optional(),
|
||||
next_run_at: z.string().nullable().optional(),
|
||||
last_run_status: z.string().nullable().optional(),
|
||||
// Per-caller write capability; absent on older servers (treated as unknown).
|
||||
can_write: z.boolean().optional(),
|
||||
// Narrower per-caller access-management capability (detail endpoint only).
|
||||
can_manage_access: z.boolean().optional(),
|
||||
}).loose();
|
||||
|
||||
export const ListAutopilotsResponseSchema = z.object({
|
||||
@@ -863,6 +919,25 @@ export const EMPTY_USER: User = {
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-workspace unread inbox summary (`/api/inbox/unread-summary` GET).
|
||||
// One entry per workspace the user belongs to that has unread items; the
|
||||
// sidebar derives the workspace-switcher dot from it. Lenient per the usual
|
||||
// rules so a future field addition can't blank the dot — on malformed JSON
|
||||
// parseWithFallback returns the empty list, which simply hides the dot.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const InboxUnreadSummarySchema = z.array(
|
||||
z
|
||||
.object({
|
||||
workspace_id: z.string(),
|
||||
count: z.number(),
|
||||
})
|
||||
.loose(),
|
||||
);
|
||||
|
||||
export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Billing schemas (cloud-billing proxy surface)
|
||||
//
|
||||
|
||||
@@ -104,6 +104,30 @@ export function useTriggerAutopilot() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrantAutopilotAccess() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, userId }: { autopilotId: string; userId: string }) =>
|
||||
api.grantAutopilotAccess(autopilotId, userId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeAutopilotAccess() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, userId }: { autopilotId: string; userId: string }) =>
|
||||
api.revokeAutopilotAccess(autopilotId, userId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAutopilotTrigger() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
@@ -14,13 +14,17 @@ export const chatKeys = {
|
||||
/** Full sessions list (active + archived); the dropdown splits locally. */
|
||||
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
|
||||
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
|
||||
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
|
||||
messagesPage: (sessionId: string) => ["chat", "messages-page", sessionId] as const,
|
||||
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
|
||||
messagesAll: () => ["chat", "messages"] as const,
|
||||
messages: (sessionId: string) => [...chatKeys.messagesAll(), sessionId] as const,
|
||||
messagesPageAll: () => ["chat", "messages-page"] as const,
|
||||
messagesPage: (sessionId: string) => [...chatKeys.messagesPageAll(), sessionId] as const,
|
||||
pendingTaskAll: () => ["chat", "pending-task"] as const,
|
||||
pendingTask: (sessionId: string) => [...chatKeys.pendingTaskAll(), sessionId] as const,
|
||||
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
|
||||
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
|
||||
/** Per-task execution messages — shared with issue agent cards. */
|
||||
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
|
||||
taskMessagesAll: () => ["task-messages"] as const,
|
||||
taskMessages: (taskId: string) => [...chatKeys.taskMessagesAll(), taskId] as const,
|
||||
};
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
31
packages/core/feature-flags/chain-provider.ts
Normal file
31
packages/core/feature-flags/chain-provider.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Decision, EvalContext, Provider } from "./types";
|
||||
|
||||
/**
|
||||
* ChainProvider composes multiple providers and returns the first match.
|
||||
*
|
||||
* Order from most-specific to most-generic: per-request override, server
|
||||
* push, static config. The first provider that returns a Decision wins, so
|
||||
* the chain naturally implements the "ops override beats static config"
|
||||
* pattern callers expect.
|
||||
*
|
||||
* A ChainProvider that wraps zero providers is valid and always returns
|
||||
* undefined, so the Service falls back to the caller's default.
|
||||
*/
|
||||
export class ChainProvider implements Provider {
|
||||
readonly name = "chain";
|
||||
private readonly providers: ReadonlyArray<Provider>;
|
||||
|
||||
constructor(providers: ReadonlyArray<Provider | null | undefined>) {
|
||||
// Filter nullish entries so callers can pass optional providers
|
||||
// directly: `new ChainProvider([envOverride, baseStatic])`.
|
||||
this.providers = providers.filter((p): p is Provider => p != null);
|
||||
}
|
||||
|
||||
lookup(key: string, ctx: EvalContext): Decision | undefined {
|
||||
for (const p of this.providers) {
|
||||
const d = p.lookup(key, ctx);
|
||||
if (d !== undefined) return d;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
68
packages/core/feature-flags/context.test.tsx
Normal file
68
packages/core/feature-flags/context.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FeatureFlagsProvider, useFlag, useVariant } from "./context";
|
||||
import { FeatureFlagService } from "./service";
|
||||
import { StaticProvider } from "./static-provider";
|
||||
|
||||
function FlagBadge({ flagKey, defaultValue }: { flagKey: string; defaultValue: boolean }) {
|
||||
const enabled = useFlag(flagKey, defaultValue);
|
||||
return <span data-testid="flag">{enabled ? "ON" : "OFF"}</span>;
|
||||
}
|
||||
|
||||
function VariantBadge({ flagKey, defaultValue }: { flagKey: string; defaultValue: string }) {
|
||||
const variant = useVariant(flagKey, defaultValue);
|
||||
return <span data-testid="variant">{variant}</span>;
|
||||
}
|
||||
|
||||
describe("FeatureFlagsProvider + hooks", () => {
|
||||
it("useFlag returns provider value inside the tree", () => {
|
||||
const service = new FeatureFlagService(
|
||||
new StaticProvider({ demo: { default: true } }),
|
||||
);
|
||||
render(
|
||||
<FeatureFlagsProvider service={service}>
|
||||
<FlagBadge flagKey="demo" defaultValue={false} />
|
||||
</FeatureFlagsProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("flag").textContent).toBe("ON");
|
||||
});
|
||||
|
||||
it("useFlag falls back to default outside any provider (tests / stories)", () => {
|
||||
render(<FlagBadge flagKey="anything" defaultValue={true} />);
|
||||
expect(screen.getByTestId("flag").textContent).toBe("ON");
|
||||
});
|
||||
|
||||
it("useFlag respects the EvalContext attached to the provider", () => {
|
||||
const service = new FeatureFlagService(
|
||||
new StaticProvider({
|
||||
internal: { default: false, allow: ["user-internal"] },
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<FeatureFlagsProvider service={service} context={{ userId: "user-internal" }}>
|
||||
<FlagBadge flagKey="internal" defaultValue={false} />
|
||||
</FeatureFlagsProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("flag").textContent).toBe("ON");
|
||||
});
|
||||
|
||||
it("useVariant returns the variant identifier", () => {
|
||||
const service = new FeatureFlagService(
|
||||
new StaticProvider({
|
||||
algo: { default: true, variant: "experiment-v2" },
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<FeatureFlagsProvider service={service}>
|
||||
<VariantBadge flagKey="algo" defaultValue="control" />
|
||||
</FeatureFlagsProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("variant").textContent).toBe("experiment-v2");
|
||||
});
|
||||
|
||||
it("useVariant falls back to default outside any provider", () => {
|
||||
render(<VariantBadge flagKey="algo" defaultValue="control" />);
|
||||
expect(screen.getByTestId("variant").textContent).toBe("control");
|
||||
});
|
||||
});
|
||||
108
packages/core/feature-flags/context.tsx
Normal file
108
packages/core/feature-flags/context.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { EvalContext } from "./types";
|
||||
import { FeatureFlagService } from "./service";
|
||||
|
||||
/**
|
||||
* React glue for the FeatureFlagService.
|
||||
*
|
||||
* Two pieces are exported:
|
||||
*
|
||||
* - {@link FeatureFlagsProvider}: wraps a part of the tree with a Service
|
||||
* and an EvalContext. The Service is usually constructed once at the
|
||||
* application root; the EvalContext changes as the user context changes
|
||||
* (e.g. after login).
|
||||
* - {@link useFlag} / {@link useVariant}: the recommended Toggle Points in
|
||||
* UI code. They never throw; if the provider tree is missing they fall
|
||||
* back to the supplied default, which keeps Storybook stories and unit
|
||||
* tests from needing to mount the provider just to render a button.
|
||||
*
|
||||
* Note: we deliberately do NOT expose the underlying FeatureFlagService
|
||||
* through hooks. Components that need raw access can read it via the
|
||||
* exported context object, but at the cost of giving up the always-on
|
||||
* safety guarantee.
|
||||
*/
|
||||
|
||||
interface FeatureFlagContextValue {
|
||||
service: FeatureFlagService;
|
||||
ctx: EvalContext;
|
||||
}
|
||||
|
||||
const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
|
||||
|
||||
export interface FeatureFlagsProviderProps {
|
||||
service: FeatureFlagService;
|
||||
/**
|
||||
* Targeting context for every flag evaluation inside this subtree.
|
||||
* Pass an empty object when the user is anonymous — percent rollouts
|
||||
* and allow/deny lists then evaluate against the empty identifier,
|
||||
* which is the desired behavior for anonymous traffic.
|
||||
*/
|
||||
context?: EvalContext;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a FeatureFlagService and EvalContext into the tree. Replacing the
|
||||
* `service` prop on a re-render is allowed but rare; prefer mutating the
|
||||
* provider on the existing Service via `setProvider`, which avoids forcing
|
||||
* every consumer to re-evaluate.
|
||||
*/
|
||||
export function FeatureFlagsProvider({
|
||||
service,
|
||||
context: ctx = {},
|
||||
children,
|
||||
}: FeatureFlagsProviderProps) {
|
||||
const value = useMemo<FeatureFlagContextValue>(
|
||||
() => ({ service, ctx }),
|
||||
[service, ctx],
|
||||
);
|
||||
return (
|
||||
<FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useFlag returns the boolean state of a feature flag.
|
||||
*
|
||||
* Outside a {@link FeatureFlagsProvider} the hook returns `defaultValue`,
|
||||
* never throws. This keeps tests and stories independent of the provider.
|
||||
*
|
||||
* @example
|
||||
* const showNewBilling = useFlag("billing_v2_dashboard", false);
|
||||
* return showNewBilling ? <BillingV2 /> : <BillingV1 />;
|
||||
*/
|
||||
export function useFlag(key: string, defaultValue: boolean): boolean {
|
||||
const value = useContext(FeatureFlagContext);
|
||||
if (!value) return defaultValue;
|
||||
return value.service.isEnabled(key, value.ctx, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* useVariant returns the raw variant identifier for a multi-arm flag, with
|
||||
* the same out-of-provider safety as {@link useFlag}.
|
||||
*
|
||||
* @example
|
||||
* const variant = useVariant("checkout_algo", "control");
|
||||
* switch (variant) {
|
||||
* case "experiment-v2": return <CheckoutV2 />;
|
||||
* case "experiment-v3": return <CheckoutV3 />;
|
||||
* default: return <CheckoutControl />;
|
||||
* }
|
||||
*/
|
||||
export function useVariant(key: string, defaultValue: string): string {
|
||||
const value = useContext(FeatureFlagContext);
|
||||
if (!value) return defaultValue;
|
||||
return value.service.variant(key, value.ctx, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape hatch for diagnostic overlays that need direct Service access.
|
||||
* Returns `null` outside a provider so callers must guard explicitly —
|
||||
* this is intentional: random component code should use {@link useFlag},
|
||||
* not the raw Service.
|
||||
*/
|
||||
export function useFeatureFlagService(): FeatureFlagService | null {
|
||||
return useContext(FeatureFlagContext)?.service ?? null;
|
||||
}
|
||||
72
packages/core/feature-flags/hash.test.ts
Normal file
72
packages/core/feature-flags/hash.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bucketFor, inPercent } from "./hash";
|
||||
|
||||
describe("feature-flags hash", () => {
|
||||
it("bucketFor returns a value in [0, 100)", () => {
|
||||
for (const id of ["a", "b", "user-1", "user-2", "", "🦄"]) {
|
||||
const b = bucketFor("flag", id);
|
||||
expect(b).toBeGreaterThanOrEqual(0);
|
||||
expect(b).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it("bucketFor is deterministic for the same (key, id)", () => {
|
||||
const first = bucketFor("billing_new_invoice", "user-42");
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(bucketFor("billing_new_invoice", "user-42")).toBe(first);
|
||||
}
|
||||
});
|
||||
|
||||
it("separator prevents key/id boundary collisions", () => {
|
||||
// ("ab","c") and ("a","bc") must not hash to the same bucket.
|
||||
expect(bucketFor("ab", "c")).not.toBe(bucketFor("a", "bc"));
|
||||
});
|
||||
|
||||
// Pinned (key, identifier) -> bucket values that MUST agree with the
|
||||
// Go-side server/pkg/featureflag/hash_test.go::TestPercentBucketCrossLanguageGolden.
|
||||
// The shared golden table is the single source of truth for "same user,
|
||||
// same bucket" across backend and frontend; if either side drifts, both
|
||||
// tests fail and one must be brought back in sync.
|
||||
//
|
||||
// The non-ASCII cases (CJK, accented, emoji) exist on purpose: Go hashes
|
||||
// the UTF-8 byte representation of a string. The TS side must do the
|
||||
// same. A regression that swaps UTF-8 encoding for charCodeAt would
|
||||
// only be caught by these inputs.
|
||||
it("cross-language golden: bucket values match the Go side exactly", () => {
|
||||
const cases: ReadonlyArray<[string, string, number]> = [
|
||||
// ASCII baseline.
|
||||
["billing_new_invoice", "user-42", 97],
|
||||
["feature_a", "user-1", 50],
|
||||
["checkout_algo", "u-7f8a", 11],
|
||||
["ws_rollout", "workspace-1", 62],
|
||||
["empty_id_flag", "", 83],
|
||||
// Non-ASCII: enforces UTF-8 parity (TextEncoder on the TS side).
|
||||
["flag", "é", 53],
|
||||
["flag", "🦄", 82],
|
||||
["实验", "user-1", 90],
|
||||
["flag", "用户-1", 95],
|
||||
["checkout_算法", "user-100", 79],
|
||||
];
|
||||
for (const [key, id, want] of cases) {
|
||||
expect(bucketFor(key, id)).toBe(want);
|
||||
}
|
||||
});
|
||||
|
||||
it("inPercent clamps boundary values", () => {
|
||||
expect(inPercent("any", "any", 0)).toBe(false);
|
||||
expect(inPercent("any", "any", -10)).toBe(false);
|
||||
expect(inPercent("any", "any", 100)).toBe(true);
|
||||
expect(inPercent("any", "any", 999)).toBe(true);
|
||||
});
|
||||
|
||||
it("inPercent splits a 50% rollout roughly in half across 1000 users", () => {
|
||||
// 50% over 1000 distinct users should land near 500; we allow a
|
||||
// generous +/- 100 window so the test isn't flaky.
|
||||
let enabled = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
if (inPercent("split", `user-${i.toString(36)}`, 50)) enabled++;
|
||||
}
|
||||
expect(enabled).toBeGreaterThan(400);
|
||||
expect(enabled).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
76
packages/core/feature-flags/hash.ts
Normal file
76
packages/core/feature-flags/hash.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* FNV-1a 32-bit hash used for deterministic percent-rollout bucketing.
|
||||
*
|
||||
* The same (key, identifier) pair MUST always produce the same bucket;
|
||||
* otherwise users would flip in and out of experiments across requests. The
|
||||
* algorithm matches the Go-side server/pkg/featureflag/hash.go byte-for-byte
|
||||
* so a flag evaluated on the frontend and on the backend lands in the same
|
||||
* bucket for the same user. Cross-language equality is exercised by golden
|
||||
* tests on both sides; see hash.test.ts and hash_test.go.
|
||||
*
|
||||
* The hash operates on the UTF-8 encoding of each input. Go's `[]byte(s)`
|
||||
* conversion is also UTF-8, so the two implementations agree even when
|
||||
* flag keys or identifiers contain non-ASCII characters (Chinese flag
|
||||
* names, user IDs that include accented characters, emoji, ...). Using
|
||||
* `charCodeAt` directly would have hashed UTF-16 code units instead and
|
||||
* silently diverged from Go for any non-ASCII input.
|
||||
*
|
||||
* FNV-1a is used because it is cheap, dependency-free, and well-distributed
|
||||
* enough for sub-100 bucketing. It is NOT cryptographic; do not use it for
|
||||
* anything beyond bucketing.
|
||||
*/
|
||||
|
||||
// One shared TextEncoder per module. TextEncoder is part of the WHATWG
|
||||
// Encoding spec and ships in every evergreen browser, in Node 11+, and in
|
||||
// React Native (Hermes) >= 0.74. We deliberately do not lazy-init it so
|
||||
// failures show up at import time, not the first time a flag is read.
|
||||
const utf8 = new TextEncoder();
|
||||
|
||||
function fnv1a(parts: ReadonlyArray<string>): number {
|
||||
// 32-bit FNV-1a: offset basis 0x811c9dc5, prime 0x01000193.
|
||||
let hash = 0x811c9dc5;
|
||||
for (let p = 0; p < parts.length; p++) {
|
||||
if (p > 0) {
|
||||
// Zero-byte separator BETWEEN parts (not after the last one). This
|
||||
// matches what the Go side writes via h.Write([]byte{0}) between
|
||||
// key and identifier and is what prevents ("ab", "c") and
|
||||
// ("a", "bc") from colliding. A trailing separator would diverge
|
||||
// from Go and silently break cross-tier bucket parity.
|
||||
hash ^= 0;
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
// Encode the part as UTF-8 to match Go's `[]byte(string)`. Using
|
||||
// charCodeAt would hash UTF-16 code units instead and diverge from
|
||||
// Go for any non-ASCII input (Chinese keys, accented user IDs,
|
||||
// emoji, ...). See the package doc above.
|
||||
const bytes = utf8.encode(parts[p]!);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
hash ^= bytes[i]!;
|
||||
// Multiply by FNV prime mod 2^32. Math.imul keeps the result in a
|
||||
// 32-bit integer without slipping into float territory.
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
}
|
||||
// Force unsigned 32-bit before the modulo to match Go's uint32 arithmetic.
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* bucketFor returns a deterministic bucket in [0, 100) for the supplied
|
||||
* (key, identifier) pair. Identical to the Go bucketFor in
|
||||
* server/pkg/featureflag/hash.go.
|
||||
*/
|
||||
export function bucketFor(key: string, identifier: string): number {
|
||||
return fnv1a([key, identifier]) % 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* inPercent reports whether (key, identifier) falls within the first
|
||||
* `percent` buckets. Values outside [0, 100] are clamped: <=0 disables for
|
||||
* everyone, >=100 enables for everyone.
|
||||
*/
|
||||
export function inPercent(key: string, identifier: string, percent: number): boolean {
|
||||
if (percent <= 0) return false;
|
||||
if (percent >= 100) return true;
|
||||
return bucketFor(key, identifier) < percent;
|
||||
}
|
||||
30
packages/core/feature-flags/index.ts
Normal file
30
packages/core/feature-flags/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Public surface for @multica/core/feature-flags.
|
||||
*
|
||||
* Keep this list minimal — every new export becomes a contract we have to
|
||||
* preserve across the monorepo. Add to it only when a real caller appears.
|
||||
*/
|
||||
|
||||
export type {
|
||||
Decision,
|
||||
EvalContext,
|
||||
PercentRollout,
|
||||
Provider,
|
||||
Reason,
|
||||
Rule,
|
||||
} from "./types";
|
||||
|
||||
export { FeatureFlagService } from "./service";
|
||||
export { StaticProvider } from "./static-provider";
|
||||
export { ChainProvider } from "./chain-provider";
|
||||
export {
|
||||
FeatureFlagsProvider,
|
||||
useFeatureFlagService,
|
||||
useFlag,
|
||||
useVariant,
|
||||
} from "./context";
|
||||
|
||||
// Hash helpers are exported for tests and for callers that want to share
|
||||
// the bucketing logic without going through a Provider (rare; usually a
|
||||
// red flag that the caller should be using the Service instead).
|
||||
export { bucketFor, inPercent } from "./hash";
|
||||
69
packages/core/feature-flags/service.test.ts
Normal file
69
packages/core/feature-flags/service.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChainProvider } from "./chain-provider";
|
||||
import { StaticProvider } from "./static-provider";
|
||||
import { FeatureFlagService } from "./service";
|
||||
|
||||
describe("FeatureFlagService", () => {
|
||||
it("returns the default when no provider is configured", () => {
|
||||
const s = new FeatureFlagService(null);
|
||||
expect(s.isEnabled("any", {}, true)).toBe(true);
|
||||
expect(s.isEnabled("any", {}, false)).toBe(false);
|
||||
expect(s.variant("any", {}, "control")).toBe("control");
|
||||
expect(s.decision("any", {}, false).reason).toBe("default");
|
||||
});
|
||||
|
||||
it("returns the default when the provider does not know the key", () => {
|
||||
const s = new FeatureFlagService(new StaticProvider({}));
|
||||
expect(s.isEnabled("missing", {}, true)).toBe(true);
|
||||
expect(s.decision("missing", {}, true).reason).toBe("default");
|
||||
});
|
||||
|
||||
it("uses the provider decision when found", () => {
|
||||
const sp = new StaticProvider({ billing: { default: true } });
|
||||
const s = new FeatureFlagService(sp);
|
||||
const d = s.decision("billing", {}, false);
|
||||
expect(d.enabled).toBe(true);
|
||||
expect(d.reason).toBe("static");
|
||||
expect(d.source).toBe("static");
|
||||
});
|
||||
|
||||
it("echoes the requested key in the decision", () => {
|
||||
const sp = new StaticProvider({ a: { default: true } });
|
||||
const s = new FeatureFlagService(sp);
|
||||
expect(s.decision("a", {}, false).key).toBe("a");
|
||||
});
|
||||
|
||||
it("setProvider swaps the underlying provider", () => {
|
||||
const s = new FeatureFlagService(null);
|
||||
expect(s.isEnabled("k", {}, false)).toBe(false);
|
||||
s.setProvider(new StaticProvider({ k: { default: true } }));
|
||||
expect(s.isEnabled("k", {}, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChainProvider", () => {
|
||||
it("first match wins", () => {
|
||||
const top = new StaticProvider({ shared: { default: true } });
|
||||
const bottom = new StaticProvider({ shared: { default: false } });
|
||||
const chain = new ChainProvider([top, bottom]);
|
||||
expect(chain.lookup("shared", {})?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("falls through to the next provider", () => {
|
||||
const top = new StaticProvider({});
|
||||
const bottom = new StaticProvider({ only_in_bottom: { default: true } });
|
||||
const chain = new ChainProvider([top, bottom]);
|
||||
expect(chain.lookup("only_in_bottom", {})?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("returns undefined when no provider matches", () => {
|
||||
const chain = new ChainProvider([new StaticProvider({})]);
|
||||
expect(chain.lookup("nope", {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips null and undefined entries", () => {
|
||||
const sp = new StaticProvider({ real: { default: true } });
|
||||
const chain = new ChainProvider([null, sp, undefined]);
|
||||
expect(chain.lookup("real", {})?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
84
packages/core/feature-flags/service.ts
Normal file
84
packages/core/feature-flags/service.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Decision, EvalContext, Provider } from "./types";
|
||||
|
||||
/**
|
||||
* FeatureFlagService is the framework-level Toggle Router. UI code asks the
|
||||
* Service for decisions; the Service consults its configured {@link Provider}.
|
||||
*
|
||||
* The class is intentionally side-effect free. Mounting it inside a React
|
||||
* tree is handled by `./context.tsx`; the Service itself works outside of
|
||||
* React (unit tests, web workers, Node CLI tools, ...).
|
||||
*
|
||||
* Always-on safety: every public entry point returns the caller's default
|
||||
* when no provider matches. Business code never has to guard against a
|
||||
* missing flag.
|
||||
*/
|
||||
export class FeatureFlagService {
|
||||
private provider: Provider | null;
|
||||
|
||||
constructor(provider: Provider | null = null) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the underlying provider at runtime. Useful when fresh config
|
||||
* arrives from the backend; the React provider tree re-renders
|
||||
* automatically because the consumer hooks subscribe to the wrapper.
|
||||
*/
|
||||
setProvider(provider: Provider | null): void {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the named flag evaluates to an "on" state. When the
|
||||
* flag is unknown the caller's default is returned.
|
||||
*
|
||||
* @example
|
||||
* if (flags.isEnabled("billing_new_invoice_email", { userId }, false)) {
|
||||
* return <NewInvoiceEmail />;
|
||||
* }
|
||||
* return <LegacyInvoiceEmail />;
|
||||
*/
|
||||
isEnabled(key: string, ctx: EvalContext, defaultValue: boolean): boolean {
|
||||
return this.decision(key, ctx, defaultValue).enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw variant for a multi-arm flag, falling back to
|
||||
* `defaultValue` when nothing matches.
|
||||
*/
|
||||
variant(key: string, ctx: EvalContext, defaultValue: string): string {
|
||||
if (!this.provider) {
|
||||
return defaultValue;
|
||||
}
|
||||
const d = this.provider.lookup(key, ctx);
|
||||
if (!d) return defaultValue;
|
||||
return d.variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full structured decision. Used by diagnostic overlays and tests.
|
||||
*/
|
||||
decision(key: string, ctx: EvalContext, defaultValue: boolean): Decision {
|
||||
if (!this.provider) {
|
||||
return defaultDecision(key, defaultValue);
|
||||
}
|
||||
const d = this.provider.lookup(key, ctx);
|
||||
if (!d) return defaultDecision(key, defaultValue);
|
||||
return { ...d, key };
|
||||
}
|
||||
|
||||
/** Returns the wrapped provider (read-only) for diagnostics. */
|
||||
getProvider(): Provider | null {
|
||||
return this.provider;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultDecision(key: string, value: boolean): Decision {
|
||||
return {
|
||||
key,
|
||||
enabled: value,
|
||||
variant: value ? "on" : "off",
|
||||
reason: "default",
|
||||
source: "default",
|
||||
};
|
||||
}
|
||||
108
packages/core/feature-flags/static-provider.test.ts
Normal file
108
packages/core/feature-flags/static-provider.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StaticProvider } from "./static-provider";
|
||||
|
||||
describe("StaticProvider", () => {
|
||||
it("returns undefined for unknown keys so callers fall through", () => {
|
||||
const sp = new StaticProvider();
|
||||
expect(sp.lookup("missing", {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the rule default for known keys", () => {
|
||||
const sp = new StaticProvider({ on: { default: true }, off: { default: false } });
|
||||
expect(sp.lookup("on", {})?.enabled).toBe(true);
|
||||
expect(sp.lookup("off", {})?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("allow forces ON for matching users", () => {
|
||||
const sp = new StaticProvider({
|
||||
internal_dashboard: { default: false, allow: ["user-internal"] },
|
||||
});
|
||||
expect(sp.lookup("internal_dashboard", { userId: "user-internal" })?.enabled).toBe(true);
|
||||
expect(sp.lookup("internal_dashboard", { userId: "user-random" })?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("deny wins over allow for the same user", () => {
|
||||
const sp = new StaticProvider({
|
||||
conflict: { default: true, allow: ["same"], deny: ["same"] },
|
||||
});
|
||||
expect(sp.lookup("conflict", { userId: "same" })?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("percent rollout is deterministic for a fixed user", () => {
|
||||
const sp = new StaticProvider({ split: { percent: { percent: 50 } } });
|
||||
const first = sp.lookup("split", { userId: "stable" })?.enabled;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(sp.lookup("split", { userId: "stable" })?.enabled).toBe(first);
|
||||
}
|
||||
});
|
||||
|
||||
it("percent rollout with by=workspace_id buckets by workspace", () => {
|
||||
const sp = new StaticProvider({
|
||||
ws_rollout: { percent: { percent: 100, by: "workspace_id" } },
|
||||
});
|
||||
const decision = sp.lookup("ws_rollout", { workspaceId: "w-1" });
|
||||
expect(decision?.enabled).toBe(true);
|
||||
expect(decision?.reason).toBe("percent");
|
||||
});
|
||||
|
||||
it("variant overrides the boolean variant string", () => {
|
||||
const sp = new StaticProvider({
|
||||
checkout: { default: true, variant: "experiment-v2" },
|
||||
});
|
||||
const d = sp.lookup("checkout", { userId: "anyone" });
|
||||
expect(d?.variant).toBe("experiment-v2");
|
||||
expect(d?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
// Regression test for the MUL-3615 review: when a rule sets `variant`
|
||||
// but the rule itself evaluates to enabled=false (deny match, percent
|
||||
// miss, default-off), the decision MUST report variant="off", never
|
||||
// the on-variant. Otherwise a switch on `useVariant()` would route
|
||||
// non-rolled-in users into the experiment arm.
|
||||
it("variant: returns 'off' when the rule evaluates to disabled", () => {
|
||||
const sp = new StaticProvider({
|
||||
exp: {
|
||||
default: false,
|
||||
variant: "experiment-v2",
|
||||
deny: ["banned-user"],
|
||||
percent: { percent: 0 },
|
||||
},
|
||||
});
|
||||
for (const userId of ["banned-user", "random-user", ""]) {
|
||||
const d = sp.lookup("exp", { userId });
|
||||
expect(d?.enabled).toBe(false);
|
||||
expect(d?.variant).toBe("off");
|
||||
}
|
||||
});
|
||||
|
||||
it("variant: returns the on-variant when the rule evaluates to enabled", () => {
|
||||
const sp = new StaticProvider({
|
||||
exp: { default: false, variant: "experiment-v2", allow: ["rolled-in"] },
|
||||
});
|
||||
const d = sp.lookup("exp", { userId: "rolled-in" });
|
||||
expect(d?.enabled).toBe(true);
|
||||
expect(d?.variant).toBe("experiment-v2");
|
||||
});
|
||||
|
||||
it("loadRules replaces, not merges, the rule map", () => {
|
||||
const sp = new StaticProvider({ old: { default: true } });
|
||||
sp.loadRules({ fresh: { default: true } });
|
||||
expect(sp.lookup("old", {})).toBeUndefined();
|
||||
expect(sp.lookup("fresh", {})?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("custom attribute lookup against attributes map", () => {
|
||||
const sp = new StaticProvider({
|
||||
plan_gate: { default: false, allow: ["enterprise"], allowBy: "plan" },
|
||||
});
|
||||
expect(
|
||||
sp.lookup("plan_gate", { attributes: { plan: "enterprise" } })?.enabled,
|
||||
).toBe(true);
|
||||
expect(sp.lookup("plan_gate", { attributes: { plan: "free" } })?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keys returns a sorted snapshot", () => {
|
||||
const sp = new StaticProvider({ zeta: {}, alpha: {}, mu: {} });
|
||||
expect(sp.keys()).toEqual(["alpha", "mu", "zeta"]);
|
||||
});
|
||||
});
|
||||
117
packages/core/feature-flags/static-provider.ts
Normal file
117
packages/core/feature-flags/static-provider.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { Decision, EvalContext, Provider, Rule } from "./types";
|
||||
import { inPercent } from "./hash";
|
||||
|
||||
/**
|
||||
* StaticProvider is an in-memory Provider populated either programmatically
|
||||
* or from a JSON config shipped with the application bundle.
|
||||
*
|
||||
* This is the recommended baseline provider for the frontend: configuration
|
||||
* lives in source control, moves through CD alongside the build, and
|
||||
* changes require a deploy. For dynamic flags fetched from the backend,
|
||||
* wrap a {@link StaticProvider} behind a chain provider that also reads
|
||||
* from API state — the StaticProvider then acts as a safety net for the
|
||||
* very first paint before the API response is available.
|
||||
*/
|
||||
export class StaticProvider implements Provider {
|
||||
readonly name = "static";
|
||||
private rules: Map<string, Rule>;
|
||||
|
||||
constructor(rules: Readonly<Record<string, Rule>> = {}) {
|
||||
this.rules = new Map(Object.entries(rules));
|
||||
}
|
||||
|
||||
/** Replace or install the rule for `key`. */
|
||||
set(key: string, rule: Rule): void {
|
||||
this.rules.set(key, rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every rule atomically. Use when reloading flag config from a
|
||||
* fetch response so consumers never observe a mixed state.
|
||||
*/
|
||||
loadRules(rules: Readonly<Record<string, Rule>>): void {
|
||||
this.rules = new Map(Object.entries(rules));
|
||||
}
|
||||
|
||||
/** Sorted list of known flag keys. Useful for dev overlays. */
|
||||
keys(): string[] {
|
||||
return Array.from(this.rules.keys()).sort();
|
||||
}
|
||||
|
||||
lookup(key: string, ctx: EvalContext): Decision | undefined {
|
||||
const rule = this.rules.get(key);
|
||||
if (!rule) return undefined;
|
||||
return evaluateRule(key, rule, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateRule(key: string, rule: Rule, ctx: EvalContext): Decision {
|
||||
// Deny wins over everything else; a kill switch must remain reachable
|
||||
// even when other targeting matches.
|
||||
const denyBy = rule.denyBy ?? "user_id";
|
||||
if (rule.deny && rule.deny.length > 0) {
|
||||
const v = lookupAttr(ctx, denyBy);
|
||||
if (v && rule.deny.includes(v)) {
|
||||
return decisionFromRule(key, rule, false, "static");
|
||||
}
|
||||
}
|
||||
|
||||
const allowBy = rule.allowBy ?? "user_id";
|
||||
if (rule.allow && rule.allow.length > 0) {
|
||||
const v = lookupAttr(ctx, allowBy);
|
||||
if (v && rule.allow.includes(v)) {
|
||||
return decisionFromRule(key, rule, true, "static");
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.percent) {
|
||||
const by = rule.percent.by ?? "user_id";
|
||||
const ident = lookupAttr(ctx, by) ?? "";
|
||||
const enabled = inPercent(key, ident, rule.percent.percent);
|
||||
return decisionFromRule(key, rule, enabled, "percent");
|
||||
}
|
||||
|
||||
return decisionFromRule(key, rule, rule.default ?? false, "static");
|
||||
}
|
||||
|
||||
function decisionFromRule(
|
||||
key: string,
|
||||
rule: Rule,
|
||||
enabled: boolean,
|
||||
reason: Decision["reason"],
|
||||
): Decision {
|
||||
// Variant policy: rule.variant is the ON-variant. When the rule
|
||||
// evaluates to false we return the canonical "off" so a caller
|
||||
// branching on the variant cannot accidentally enter the experiment
|
||||
// arm for a user that did not roll in.
|
||||
let variant = boolToVariant(enabled);
|
||||
if (enabled && rule.variant && rule.variant.length > 0) {
|
||||
variant = rule.variant;
|
||||
}
|
||||
return {
|
||||
key,
|
||||
enabled,
|
||||
variant,
|
||||
reason,
|
||||
source: "static",
|
||||
};
|
||||
}
|
||||
|
||||
function boolToVariant(b: boolean): string {
|
||||
return b ? "on" : "off";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an attribute name against the EvalContext. The well-known names
|
||||
* "user_id" and "workspace_id" map to the dedicated fields so rules can use
|
||||
* them by name without callers also populating `attributes`.
|
||||
*/
|
||||
function lookupAttr(ctx: EvalContext, name: string): string | undefined {
|
||||
if (name === "user_id") return nonEmpty(ctx.userId);
|
||||
if (name === "workspace_id") return nonEmpty(ctx.workspaceId);
|
||||
return nonEmpty(ctx.attributes?.[name]);
|
||||
}
|
||||
|
||||
function nonEmpty(v: string | undefined): string | undefined {
|
||||
return v && v.length > 0 ? v : undefined;
|
||||
}
|
||||
114
packages/core/feature-flags/types.ts
Normal file
114
packages/core/feature-flags/types.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Public types for the @multica/core/feature-flags module.
|
||||
*
|
||||
* The shape mirrors the Go-side server/pkg/featureflag package on purpose so
|
||||
* a Decision returned by the backend can be marshalled directly into the
|
||||
* frontend Service without translation. Keep them in sync when extending
|
||||
* either side.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reason explains why a Decision returned the value it did. Exposed in
|
||||
* diagnostics endpoints and in development overlays so engineers can tell
|
||||
* "this flag is on because the user is in the allowlist" apart from "this
|
||||
* flag is on because the default kicked in".
|
||||
*/
|
||||
export type Reason =
|
||||
| "static"
|
||||
| "percent"
|
||||
| "override"
|
||||
| "default"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* Structured outcome of a single flag evaluation. Most callers only need
|
||||
* the {@link FeatureFlagService.isEnabled} convenience, but tests and
|
||||
* dev tools want the full record.
|
||||
*/
|
||||
export interface Decision {
|
||||
/** The flag identifier that was evaluated. */
|
||||
key: string;
|
||||
/** Boolean projection. True for any variant except "off" / "" / "false" / "0". */
|
||||
enabled: boolean;
|
||||
/** Raw variant value. Boolean flags use "on" / "off"; variant flags use arbitrary identifiers. */
|
||||
variant: string;
|
||||
/** Why this decision was made. */
|
||||
reason: Reason;
|
||||
/** Name of the provider that produced the decision, or "default" when nothing matched. */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-evaluation context for dynamic targeting (allow/deny lists, percent
|
||||
* rollouts). All fields are optional; a missing field never crashes the
|
||||
* evaluation, it simply skips the rules that depend on it.
|
||||
*/
|
||||
export interface EvalContext {
|
||||
userId?: string;
|
||||
workspaceId?: string;
|
||||
/** Free-form attributes (plan, country, client, ...). Keys are case-sensitive. */
|
||||
attributes?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent rollout descriptor. The bucket for (key, identifier) is computed
|
||||
* with FNV-1a so the same identifier always falls into the same bucket
|
||||
* across processes and tabs.
|
||||
*/
|
||||
export interface PercentRollout {
|
||||
/** Rollout size in [0, 100]. Out-of-range values are clamped. */
|
||||
percent: number;
|
||||
/**
|
||||
* Attribute name used as the bucketing identifier. Defaults to "user_id".
|
||||
* Use "workspace_id" for workspace-scoped rollouts.
|
||||
*/
|
||||
by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule describes how the {@link StaticProvider} evaluates a single flag.
|
||||
*
|
||||
* Evaluation order (first match wins):
|
||||
* 1. Deny: if the EvalContext attribute matches an entry in deny, return OFF.
|
||||
* 2. Allow: if it matches an entry in allow, return ON.
|
||||
* 3. Percent: if the bucket falls inside percent.percent, return ON; else OFF.
|
||||
* 4. Default: return defaultValue.
|
||||
*/
|
||||
export interface Rule {
|
||||
/** Value returned when no targeting rule matches. Defaults to false. */
|
||||
default?: boolean;
|
||||
/**
|
||||
* Variant identifier returned WHEN the rule evaluates to enabled=true.
|
||||
* Use for multi-arm experiments (e.g. "experiment-v2"). When the rule
|
||||
* evaluates to enabled=false the Decision's variant is always "off",
|
||||
* so callers branching on `Variant()` cannot accidentally enter the
|
||||
* experiment arm for users that did not roll in.
|
||||
*/
|
||||
variant?: string;
|
||||
/** Identifier values that force the flag ON. */
|
||||
allow?: ReadonlyArray<string>;
|
||||
/** EvalContext attribute used for allow lookups. Defaults to "user_id". */
|
||||
allowBy?: string;
|
||||
/** Identifier values that force the flag OFF. Deny wins over allow. */
|
||||
deny?: ReadonlyArray<string>;
|
||||
/** EvalContext attribute used for deny lookups. Defaults to "user_id". */
|
||||
denyBy?: string;
|
||||
/** Deterministic percent rollout. */
|
||||
percent?: PercentRollout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider is the configuration backend for the Service. Implementations
|
||||
* MUST be safe for concurrent use; the Service reads providers from many
|
||||
* components without additional synchronization.
|
||||
*
|
||||
* Returning `undefined` (instead of a Decision) tells the Service to fall
|
||||
* through to the next provider in a ChainProvider, or to the caller's
|
||||
* default if there is no next provider.
|
||||
*/
|
||||
export interface Provider {
|
||||
/** Stable, human-readable identifier surfaced in Decision.source. */
|
||||
readonly name: string;
|
||||
/** Evaluate the flag, or return undefined if this provider does not know it. */
|
||||
lookup(key: string, ctx: EvalContext): Decision | undefined;
|
||||
}
|
||||
@@ -100,3 +100,116 @@ describe("useFileUpload — markdownLink picks the durable URL with three-layer
|
||||
expect(api.uploadFile as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// MUL-3339 — `uploading` is an in-flight counter, not a single boolean.
|
||||
// The single-boolean shape silently regressed the quick-create multi-image
|
||||
// attach flow: callers fire N concurrent uploads (drag-drop, multi-image
|
||||
// paste), the first upload's `finally` would flip `uploading` back to false
|
||||
// while N-1 are still in flight, and the submit gate (which only reads
|
||||
// `uploading`) would unblock — `stripBlobUrls` then erased the still-pending
|
||||
// images from the markdown and their attachment ids never reached the
|
||||
// server. The fix tracks an in-flight counter and exposes
|
||||
// `uploading = count > 0`, so callers see "uploading" as long as ANY upload
|
||||
// is in flight.
|
||||
describe("useFileUpload — concurrent uploads (MUL-3339 regression)", () => {
|
||||
it("keeps uploading=true until ALL concurrent uploads resolve", async () => {
|
||||
// Hand-rolled deferreds so the test controls resolve order.
|
||||
const att1 = makeAttachment({ id: "att-1" });
|
||||
const att2 = makeAttachment({ id: "att-2" });
|
||||
let resolve1: (v: Attachment) => void = () => {};
|
||||
let resolve2: (v: Attachment) => void = () => {};
|
||||
const p1 = new Promise<Attachment>((r) => {
|
||||
resolve1 = r;
|
||||
});
|
||||
const p2 = new Promise<Attachment>((r) => {
|
||||
resolve2 = r;
|
||||
});
|
||||
const uploadFile = vi
|
||||
.fn<(file: File) => Promise<Attachment>>()
|
||||
.mockReturnValueOnce(p1)
|
||||
.mockReturnValueOnce(p2);
|
||||
const api = { uploadFile } as unknown as ApiClient;
|
||||
|
||||
const { result } = renderHook(() => useFileUpload(api));
|
||||
expect(result.current.uploading).toBe(false);
|
||||
|
||||
// Fire both uploads concurrently — same shape as the quick-create
|
||||
// drag-drop path (`files.forEach((f) => editorRef.current?.uploadFile(f))`).
|
||||
let pending1: Promise<UploadResult | null> = Promise.resolve(null);
|
||||
let pending2: Promise<UploadResult | null> = Promise.resolve(null);
|
||||
await act(async () => {
|
||||
pending1 = result.current.upload(
|
||||
new File(["1"], "a.png", { type: "image/png" }),
|
||||
);
|
||||
pending2 = result.current.upload(
|
||||
new File(["2"], "b.png", { type: "image/png" }),
|
||||
);
|
||||
});
|
||||
expect(result.current.uploading).toBe(true);
|
||||
|
||||
// Resolve the FIRST upload only. With the old single-boolean shape this
|
||||
// would flip `uploading` back to false — that's the production bug.
|
||||
// With the in-flight counter, `uploading` stays true because upload 2
|
||||
// is still pending.
|
||||
await act(async () => {
|
||||
resolve1(att1);
|
||||
await pending1;
|
||||
});
|
||||
expect(result.current.uploading).toBe(true);
|
||||
|
||||
// Now resolve the second upload — only at this point should the gate open.
|
||||
await act(async () => {
|
||||
resolve2(att2);
|
||||
await pending2;
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("decrements correctly when one of the concurrent uploads throws", async () => {
|
||||
// The `finally` block runs on rejection too — the counter must still
|
||||
// decrement so a failed upload never leaves the flag stuck "uploading".
|
||||
const att = makeAttachment();
|
||||
let resolveOk: (v: Attachment) => void = () => {};
|
||||
let rejectBad: (e: Error) => void = () => {};
|
||||
const ok = new Promise<Attachment>((r) => {
|
||||
resolveOk = r;
|
||||
});
|
||||
const bad = new Promise<Attachment>((_, j) => {
|
||||
rejectBad = j;
|
||||
});
|
||||
const uploadFile = vi
|
||||
.fn<(file: File) => Promise<Attachment>>()
|
||||
.mockReturnValueOnce(ok)
|
||||
.mockReturnValueOnce(bad);
|
||||
const api = { uploadFile } as unknown as ApiClient;
|
||||
|
||||
const { result } = renderHook(() => useFileUpload(api));
|
||||
let okPending: Promise<UploadResult | null> = Promise.resolve(null);
|
||||
let badPending: Promise<UploadResult | null> = Promise.resolve(null);
|
||||
await act(async () => {
|
||||
okPending = result.current.upload(
|
||||
new File(["a"], "a.png", { type: "image/png" }),
|
||||
);
|
||||
// uploadWithToast swallows errors via onError; we test the raw `upload`
|
||||
// so the caller sees the rejection. Wrap in a catch so vitest doesn't
|
||||
// surface an unhandled rejection from the act() boundary.
|
||||
badPending = result.current.upload(
|
||||
new File(["b"], "b.png", { type: "image/png" }),
|
||||
).catch(() => null);
|
||||
});
|
||||
expect(result.current.uploading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
rejectBad(new Error("boom"));
|
||||
await badPending;
|
||||
});
|
||||
// One still in flight — must remain uploading.
|
||||
expect(result.current.uploading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveOk(att);
|
||||
await okPending;
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +84,20 @@ export function useFileUpload(
|
||||
api: ApiClient,
|
||||
onError?: (error: Error) => void,
|
||||
) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
// In-flight counter, NOT a single boolean. Callers fire multiple uploads
|
||||
// concurrently (drag-drop of N files, paste with multiple images) and the
|
||||
// boolean shape would flip false as soon as the FIRST upload's finally ran
|
||||
// — even though N-1 are still mid-request. Surfaces consuming `uploading`
|
||||
// (the quick-create submit gate, the editor's "Uploading…" button label)
|
||||
// would then unblock submit while uploads are still in flight, causing
|
||||
// `stripBlobUrls` to erase the still-pending images from the markdown and
|
||||
// their attachment ids never to be bound (MUL-3339).
|
||||
//
|
||||
// The exposed `uploading: boolean` keeps the existing call-site contract
|
||||
// (`{ uploading } = useFileUpload(api)` everywhere); only the internal
|
||||
// tracking shape changes.
|
||||
const [inFlight, setInFlight] = useState(0);
|
||||
const uploading = inFlight > 0;
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
||||
@@ -92,7 +105,7 @@ export function useFileUpload(
|
||||
throw new Error("File exceeds 100 MB limit");
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setInFlight((n) => n + 1);
|
||||
try {
|
||||
const att: Attachment = await api.uploadFile(file, {
|
||||
issueId: ctx?.issueId,
|
||||
@@ -101,7 +114,7 @@ export function useFileUpload(
|
||||
});
|
||||
return { ...att, link: att.url, markdownLink: pickMarkdownLink(att) };
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setInFlight((n) => n - 1);
|
||||
}
|
||||
},
|
||||
[api],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InboxItem } from "../types";
|
||||
import { deduplicateInboxItems } from "./queries";
|
||||
import type { InboxItem, InboxWorkspaceUnread } from "../types";
|
||||
import { deduplicateInboxItems, hasOtherWorkspaceUnread, inboxKeys, unreadWorkspaceIds } from "./queries";
|
||||
|
||||
function item(overrides: Partial<InboxItem>): InboxItem {
|
||||
return {
|
||||
@@ -72,3 +72,83 @@ describe("deduplicateInboxItems", () => {
|
||||
expect(merged[0]?.details?.comment_id).toBe("comment-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasOtherWorkspaceUnread", () => {
|
||||
const summary = (entries: InboxWorkspaceUnread[]) => entries;
|
||||
|
||||
it("is true when a workspace other than the active one has unread", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([{ workspace_id: "ws-2", count: 3 }]),
|
||||
"ws-1",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes the active workspace's own unread", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([{ workspace_id: "ws-1", count: 5 }]),
|
||||
"ws-1",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores other workspaces whose count is zero", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([{ workspace_id: "ws-2", count: 0 }]),
|
||||
"ws-1",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when at least one non-active workspace has unread", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([
|
||||
{ workspace_id: "ws-1", count: 4 },
|
||||
{ workspace_id: "ws-2", count: 1 },
|
||||
]),
|
||||
"ws-1",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for an empty summary", () => {
|
||||
expect(hasOtherWorkspaceUnread([], "ws-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("counts every workspace as 'other' when there is no active workspace", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([{ workspace_id: "ws-1", count: 2 }]),
|
||||
null,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreadWorkspaceIds", () => {
|
||||
it("collects only workspaces with a non-zero count", () => {
|
||||
const ids = unreadWorkspaceIds([
|
||||
{ workspace_id: "ws-1", count: 0 },
|
||||
{ workspace_id: "ws-2", count: 3 },
|
||||
{ workspace_id: "ws-3", count: 1 },
|
||||
]);
|
||||
expect(ids.has("ws-1")).toBe(false);
|
||||
expect(ids.has("ws-2")).toBe(true);
|
||||
expect(ids.has("ws-3")).toBe(true);
|
||||
expect(ids.size).toBe(2);
|
||||
});
|
||||
|
||||
it("returns an empty set for an empty summary", () => {
|
||||
expect(unreadWorkspaceIds([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inboxKeys.unreadSummary", () => {
|
||||
it("is a stable account-level key independent of any workspace", () => {
|
||||
expect(inboxKeys.unreadSummary()).toEqual(["inbox", "unread-summary"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { queryOptions, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { InboxItem } from "../types";
|
||||
import type { InboxItem, InboxWorkspaceUnread } from "../types";
|
||||
|
||||
export const inboxKeys = {
|
||||
all: (wsId: string) => ["inbox", wsId] as const,
|
||||
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
|
||||
// Account-level (not workspace-scoped): a single shared cache entry that
|
||||
// holds unread counts for every workspace the user belongs to.
|
||||
unreadSummary: () => ["inbox", "unread-summary"] as const,
|
||||
};
|
||||
|
||||
export function inboxListOptions(wsId: string) {
|
||||
@@ -14,6 +17,41 @@ export function inboxListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-workspace unread inbox summary. One cache entry shared across all
|
||||
* workspaces — the data is account-level, so switching workspaces does not
|
||||
* refetch it; only the derived "is this for another workspace" view changes.
|
||||
*/
|
||||
export function inboxUnreadSummaryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: inboxKeys.unreadSummary(),
|
||||
queryFn: () => api.getInboxUnreadSummary(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any workspace OTHER than `currentWsId` has unread inbox items.
|
||||
* Drives the workspace-switcher dot: the active workspace's own unread is
|
||||
* already surfaced by the Inbox nav count, so it is excluded here to avoid a
|
||||
* duplicate signal.
|
||||
*/
|
||||
export function hasOtherWorkspaceUnread(
|
||||
summary: InboxWorkspaceUnread[],
|
||||
currentWsId: string | null | undefined,
|
||||
): boolean {
|
||||
return summary.some((s) => s.workspace_id !== currentWsId && s.count > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of workspace ids that have unread inbox items. Lets the workspace
|
||||
* switcher dropdown mark WHICH workspace a pending message lives in (the
|
||||
* aggregate switcher dot only says "somewhere else"). Workspaces with a zero
|
||||
* count are excluded.
|
||||
*/
|
||||
export function unreadWorkspaceIds(summary: InboxWorkspaceUnread[]): Set<string> {
|
||||
return new Set(summary.filter((s) => s.count > 0).map((s) => s.workspace_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread inbox count for the given workspace, aligned with what the inbox
|
||||
* list UI renders: archived items excluded, then deduplicated by issue so a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { onInboxIssueDeleted, onInboxIssueStatusChanged } from "./ws-updaters";
|
||||
import { onInboxIssueDeleted, onInboxIssueStatusChanged, onInboxSummaryInvalidate } from "./ws-updaters";
|
||||
import { inboxKeys } from "./queries";
|
||||
import type { InboxItem } from "../types";
|
||||
|
||||
@@ -56,6 +56,28 @@ describe("onInboxIssueDeleted", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("onInboxSummaryInvalidate", () => {
|
||||
it("invalidates the account-level summary key regardless of active workspace", () => {
|
||||
const qc = new QueryClient();
|
||||
const spy = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
onInboxSummaryInvalidate(qc);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: inboxKeys.unreadSummary() });
|
||||
});
|
||||
|
||||
it("does not disturb a workspace-scoped inbox list cache", () => {
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), [makeItem("i1", "issue-a")]);
|
||||
|
||||
onInboxSummaryInvalidate(qc);
|
||||
|
||||
// The list cache entry is untouched (different key); only the summary
|
||||
// query is marked stale.
|
||||
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))?.[0]?.id).toBe("i1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onInboxIssueStatusChanged", () => {
|
||||
it("updates issue_status only for items referencing the issue", () => {
|
||||
const qc = new QueryClient();
|
||||
|
||||
@@ -41,3 +41,12 @@ export function onInboxIssueDeleted(
|
||||
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
}
|
||||
|
||||
// Refresh the cross-workspace unread summary (workspace-switcher dot). The
|
||||
// summary spans every workspace, so it is invalidated on ANY inbox event
|
||||
// regardless of which workspace the event came from — including read/archive
|
||||
// events from a workspace other than the active one, which the workspace-
|
||||
// scoped list invalidation cannot reach.
|
||||
export function onInboxSummaryInvalidate(qc: QueryClient) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
|
||||
}
|
||||
|
||||
@@ -431,6 +431,117 @@ describe("useUpdateIssue — optimistic move keeps every bucketed board in sync"
|
||||
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
|
||||
expect(invalidatedKeys).not.toContainEqual(issueKeys.myAll(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates myAll on settle when project_id changes (drops the issue from the old project's list)", async () => {
|
||||
// A project move makes the issue leave the old project's filtered list. The
|
||||
// surgical patch is filter-blind (it never removes a card that no longer
|
||||
// matches the list filter), so onSettled must refetch myAll to drop it —
|
||||
// unlike a status-only move, which deliberately does not (MUL-3669 / #4548).
|
||||
updateIssue.mockResolvedValue(makeIssue(1, { project_id: "project-9" }));
|
||||
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(() => useUpdateIssue(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ id: "issue-1", project_id: "project-9" });
|
||||
});
|
||||
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(invalidatedKeys).toContainEqual(issueKeys.myAll(WS_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUpdateIssue — detaching a sub-issue prunes the old parent's children cache", () => {
|
||||
const PARENT_ID = "parent-1";
|
||||
const childKey = issueKeys.children(WS_ID, PARENT_ID);
|
||||
|
||||
let qc: QueryClient;
|
||||
let updateIssue: ReturnType<typeof vi.fn<(id: string, data: unknown) => Promise<Issue>>>;
|
||||
|
||||
function childIds(): string[] {
|
||||
return (qc.getQueryData<Issue[]>(childKey) ?? []).map((c) => c.id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
updateIssue = vi.fn();
|
||||
setApiInstance({ updateIssue } as unknown as ApiClient);
|
||||
// Seed the detail cache so onMutate resolves the old parent from the
|
||||
// freshest source, plus the parent's children list rendered by the
|
||||
// sub-issues section.
|
||||
const child = makeIssue(1, { parent_issue_id: PARENT_ID, stage: 2 });
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, child.id), child);
|
||||
qc.setQueryData<Issue[]>(childKey, [
|
||||
child,
|
||||
makeIssue(2, { parent_issue_id: PARENT_ID }),
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
qc.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("optimistically removes the issue from the old parent's children array", async () => {
|
||||
let resolve!: (issue: Issue) => void;
|
||||
updateIssue.mockReturnValue(
|
||||
new Promise<Issue>((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateIssue(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ id: "issue-1", parent_issue_id: null, stage: null });
|
||||
});
|
||||
|
||||
// Pruned immediately so the parent's sub-issues list drops it now, not
|
||||
// after the settle refetch; the sibling is untouched.
|
||||
expect(childIds()).toEqual(["issue-2"]);
|
||||
|
||||
await act(async () => {
|
||||
resolve(makeIssue(1, { parent_issue_id: null, stage: null }));
|
||||
});
|
||||
expect(childIds()).not.toContain("issue-1");
|
||||
});
|
||||
|
||||
it("restores the old parent's children when the request fails", async () => {
|
||||
updateIssue.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useUpdateIssue(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current
|
||||
.mutateAsync({ id: "issue-1", parent_issue_id: null, stage: null })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
expect(childIds()).toEqual(["issue-1", "issue-2"]);
|
||||
});
|
||||
|
||||
it("keeps the issue under its parent for a non-reparenting update", async () => {
|
||||
updateIssue.mockResolvedValue(
|
||||
makeIssue(1, { parent_issue_id: PARENT_ID, status: "done" }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateIssue(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ id: "issue-1", status: "done" });
|
||||
});
|
||||
|
||||
// A status-only change patches in place — never prunes the relationship.
|
||||
expect(childIds()).toEqual(["issue-1", "issue-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useBatchUpdateIssues — optimistic patch covers filtered boards too", () => {
|
||||
@@ -541,6 +652,28 @@ describe("useBatchUpdateIssues — optimistic patch covers filtered boards too",
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates myAll on settle when project_id changes (drops moved issues from the old project's list)", async () => {
|
||||
// Mirrors useUpdateIssue: a batch that moves issues between projects must
|
||||
// refetch myAll so they leave the old project's filtered list, even though a
|
||||
// status-only batch deliberately does not (MUL-3669 / #4548).
|
||||
batchUpdateIssues.mockResolvedValue({ updated: 1 });
|
||||
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(() => useBatchUpdateIssues(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
ids: ["issue-1"],
|
||||
updates: { project_id: "project-9" },
|
||||
});
|
||||
});
|
||||
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(invalidatedKeys).toContainEqual(issueKeys.myAll(WS_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useResolveComment", () => {
|
||||
|
||||
@@ -278,10 +278,21 @@ export function useUpdateIssue() {
|
||||
old ? { ...old, ...patch } : old,
|
||||
);
|
||||
if (parentId) {
|
||||
// When the write re-parents this issue away from `parentId` (detach
|
||||
// to standalone, or move under a different parent), prune it from the
|
||||
// old parent's children cache. The parent's sub-issues list renders
|
||||
// that array directly, so a bare patch to parent_issue_id: null would
|
||||
// leave an orphaned row in the list until the settle refetch lands.
|
||||
// onError restores prevChildren, so the prune rolls back on failure.
|
||||
const detachedFromParent =
|
||||
Object.prototype.hasOwnProperty.call(patch, "parent_issue_id") &&
|
||||
patch.parent_issue_id !== parentId;
|
||||
qc.setQueryData<Issue[]>(
|
||||
issueKeys.children(wsId, parentId),
|
||||
(old) =>
|
||||
old?.map((c) => (c.id === id ? { ...c, ...patch } : c)),
|
||||
detachedFromParent
|
||||
? old?.filter((c) => c.id !== id)
|
||||
: old?.map((c) => (c.id === id ? { ...c, ...patch } : c)),
|
||||
);
|
||||
}
|
||||
return { prevLists, prevDetail, prevChildren, parentId, id };
|
||||
@@ -333,6 +344,15 @@ export function useUpdateIssue() {
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
}
|
||||
// Local safety net for a project move. The WS echo now carries
|
||||
// project_changed, but a moved issue must also drop out of the OLD
|
||||
// project's filtered list here in case the echo is delayed or dropped. The
|
||||
// surgical onMutate patch is filter-blind — it never removes a card that no
|
||||
// longer matches the list's project filter — so reconcile by refetching
|
||||
// myAll whenever project_id was part of this update (MUL-3669 / #4548).
|
||||
if (Object.prototype.hasOwnProperty.call(vars, "project_id")) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
}
|
||||
// Refresh the issue's attachments cache when the description editor
|
||||
// bound new uploads — the description editor reads `issueAttachments`
|
||||
// to resolve text-preview Eye gates, and unlike other mutations this
|
||||
@@ -523,6 +543,11 @@ export function useBatchUpdateIssues() {
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
}
|
||||
// Local safety net mirroring useUpdateIssue: drop moved issues from the old
|
||||
// project's filtered list even if the WS echo is delayed (MUL-3669 / #4548).
|
||||
if (Object.prototype.hasOwnProperty.call(_vars.updates, "project_id")) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
}
|
||||
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
||||
for (const parentId of ctx.affectedParentIds) {
|
||||
qc.invalidateQueries({
|
||||
|
||||
@@ -317,11 +317,128 @@ describe("onIssueUpdated — position move is surgical, not a list refetch", ()
|
||||
it("invalidates myAll when the project changes (Project board membership)", () => {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
|
||||
|
||||
// issueA.project_id is null; moving it into a project shifts Project-board membership.
|
||||
// issueA.project_id is null; moving it into a project shifts Project-board
|
||||
// membership. No server flag here — this exercises the legacy cache-diff
|
||||
// fallback that keeps a new frontend working against an older backend.
|
||||
onIssueUpdated(qc, WS_ID, { ...issueA, project_id: "project-9" });
|
||||
|
||||
expectInvalidated(qc, issueKeys.myAll(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates myAll on a server project_changed flag even when the cached project_id already matches (local optimistic move)", () => {
|
||||
// Reproduces the post-optimistic-move state behind MUL-3669: onMutate has
|
||||
// already written the NEW project into detail + list, so a cache diff would
|
||||
// compute projectChanged=false and skip the refetch. The authoritative
|
||||
// server flag must still drive it.
|
||||
const moved: Issue = { ...issueA, project_id: "project-9" };
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, moved.id), moved);
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(moved));
|
||||
|
||||
onIssueUpdated(qc, WS_ID, moved, { projectChanged: true });
|
||||
|
||||
expectInvalidated(qc, issueKeys.myAll(WS_ID));
|
||||
});
|
||||
|
||||
it("does NOT invalidate myAll when the server flag says project_changed=false (flag overrides the legacy diff)", () => {
|
||||
// No detail/list cache for the issue, so the legacy diff would resolve
|
||||
// oldProjectId=null and fire on the non-null incoming project_id. An explicit
|
||||
// false flag from the server is authoritative and must suppress that.
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
|
||||
|
||||
onIssueUpdated(
|
||||
qc,
|
||||
WS_ID,
|
||||
{ ...issueA, project_id: "project-9" },
|
||||
{ projectChanged: false },
|
||||
);
|
||||
|
||||
expect(qc.getQueryState(issueKeys.myAll(WS_ID))?.isInvalidated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// A board column header shows `byStatus[status].total`. On a status change the
|
||||
// surgical patch shifts both bucket totals — but only if it can find the card in
|
||||
// a loaded page. A paginated column loads just its first page, so an off-screen
|
||||
// issue (very common when an agent flips the status of something the viewer
|
||||
// never scrolled to) is absent: patchIssueInBuckets no-ops and the count would
|
||||
// silently drift, with no refetch to recover it. The status-changed no-op has to
|
||||
// fall back to a single-list refetch.
|
||||
describe("onIssueUpdated — off-screen status change reconciles column counts", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
});
|
||||
|
||||
it("refetches the workspace list when a status-changed issue is not in the loaded page", () => {
|
||||
// First page only: the totals say these columns have items, but the issues
|
||||
// arrays are the loaded window — the moved issue lives beyond it.
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
|
||||
byStatus: {
|
||||
in_review: { issues: [], total: 1 },
|
||||
done: { issues: [], total: 60 },
|
||||
},
|
||||
});
|
||||
|
||||
onIssueUpdated(
|
||||
qc,
|
||||
WS_ID,
|
||||
{ id: "off-screen", status: "done" },
|
||||
{ statusChanged: true },
|
||||
);
|
||||
|
||||
expectInvalidated(qc, issueKeys.list(WS_ID));
|
||||
});
|
||||
|
||||
it("refetches the filtered myAll list under the same condition", () => {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), {
|
||||
byStatus: { done: { issues: [], total: 60 } },
|
||||
});
|
||||
|
||||
onIssueUpdated(
|
||||
qc,
|
||||
WS_ID,
|
||||
{ id: "off-screen", status: "done" },
|
||||
{ statusChanged: true },
|
||||
);
|
||||
|
||||
expectInvalidated(qc, issueKeys.myAll(WS_ID));
|
||||
});
|
||||
|
||||
it("does NOT refetch when the status-changed issue is loaded (surgical patch suffices)", () => {
|
||||
const loaded: Issue = { ...baseIssue, id: "loaded", status: "in_review" };
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
|
||||
byStatus: {
|
||||
in_review: { issues: [loaded], total: 1 },
|
||||
done: { issues: [], total: 60 },
|
||||
},
|
||||
});
|
||||
|
||||
onIssueUpdated(
|
||||
qc,
|
||||
WS_ID,
|
||||
{ ...loaded, status: "done" },
|
||||
{ statusChanged: true },
|
||||
);
|
||||
|
||||
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
|
||||
expect(list?.byStatus.in_review?.total).toBe(0);
|
||||
expect(list?.byStatus.done?.total).toBe(61);
|
||||
// Reconciled in place — the no-flicker fast path from #4415 must hold.
|
||||
expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT refetch an absent issue when the status did not change", () => {
|
||||
// A title/label edit of an off-screen issue cannot affect any count, so it
|
||||
// must not trigger a fallback refetch.
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
|
||||
byStatus: { done: { issues: [], total: 60 } },
|
||||
});
|
||||
|
||||
onIssueUpdated(qc, WS_ID, { id: "off-screen", title: "renamed" });
|
||||
|
||||
expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onIssueDeleted", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { issueKeys } from "./queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import { projectKeys } from "../projects/queries";
|
||||
@@ -40,10 +40,16 @@ export function onIssueUpdated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Partial<Issue> & { id: string },
|
||||
// assigneeChanged comes from the server's issue:updated flags. It gates the
|
||||
// filtered-list (myAll) invalidate so a non-membership change keeps those
|
||||
// lists in place instead of refetching.
|
||||
meta: { assigneeChanged?: boolean } = {},
|
||||
// assigneeChanged / statusChanged / projectChanged come from the server's
|
||||
// issue:updated flags. assigneeChanged + projectChanged gate the filtered-list
|
||||
// (myAll) invalidate so a non-membership change keeps those lists in place
|
||||
// instead of refetching. statusChanged gates the off-screen count reconcile
|
||||
// below.
|
||||
meta: {
|
||||
assigneeChanged?: boolean;
|
||||
statusChanged?: boolean;
|
||||
projectChanged?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
// Look up the OLD parent before mutating list state, so we can keep
|
||||
// the parent's children cache in sync (powers the sub-issues list
|
||||
@@ -60,17 +66,41 @@ export function onIssueUpdated(
|
||||
const parentChanged =
|
||||
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
||||
|
||||
// Project-board membership keys on project_id. There is no project_changed
|
||||
// flag on the wire, so diff the incoming project_id against the cached one.
|
||||
// Project board membership keys on project_id. Prefer the server's
|
||||
// project_changed flag (authoritative, set on the wire). Fall back to diffing
|
||||
// the incoming project_id against the cached one only when the flag is absent
|
||||
// (older backend): the diff is unreliable once a local optimistic move has
|
||||
// overwritten the cached project_id, but it still covers remote/agent moves
|
||||
// and keeps a new frontend on an old backend from regressing (MUL-3669 /
|
||||
// #4548). The local move itself is also covered by the onSettled safety net in
|
||||
// useUpdateIssue, which never depends on this flag.
|
||||
const oldProjectId =
|
||||
detailData?.project_id ??
|
||||
(firstListData ? findIssueLocation(firstListData, issue.id)?.issue.project_id : null) ??
|
||||
null;
|
||||
const projectChanged =
|
||||
issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId;
|
||||
meta.projectChanged ??
|
||||
(issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId);
|
||||
|
||||
// A status change shifts two bucket totals (the column header counts).
|
||||
// patchIssueInBuckets does that surgically, but only when it can find the card
|
||||
// in a loaded page; a paginated column holds just its first page, so an issue
|
||||
// outside that window — common when an agent flips the status of something the
|
||||
// viewer never scrolled to — makes the patch a no-op (it returns the same
|
||||
// reference) and the totals silently drift. A status change otherwise never
|
||||
// refetches the list (that refetch was the drag flicker removed by the
|
||||
// optimistic-update work), so recover the one case the patch cannot: on a
|
||||
// status-changed no-op, refetch just that single list to reconcile its counts.
|
||||
const patchOrRefetchCounts = (key: QueryKey, data: ListIssuesCache) => {
|
||||
const next = patchIssueInBuckets(data, issue.id, issue);
|
||||
qc.setQueryData<ListIssuesCache>(key, next);
|
||||
if (next === data && meta.statusChanged) {
|
||||
qc.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
};
|
||||
|
||||
for (const [key, data] of listQueries) {
|
||||
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issue.id, issue));
|
||||
if (data) patchOrRefetchCounts(key, data);
|
||||
}
|
||||
// The workspace board (issueKeys.list) is NOT filtered: an issue is always a
|
||||
// member, so patchIssueInBuckets above is a complete surgical reconcile —
|
||||
@@ -85,9 +115,7 @@ export function onIssueUpdated(
|
||||
// refetch, no flicker — exactly like the workspace board above.
|
||||
const myListQueries = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.myAll(wsId) });
|
||||
for (const [key, data] of myListQueries) {
|
||||
if (data?.byStatus) {
|
||||
qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issue.id, issue));
|
||||
}
|
||||
if (data?.byStatus) patchOrRefetchCounts(key, data);
|
||||
}
|
||||
// Only refetch the filtered lists when the change can actually move an issue
|
||||
// in/out of one. My-Issues / actor-panel membership keys on the assignee (the
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
"./github/queries": "./github/queries.ts",
|
||||
"./lark": "./lark/index.ts",
|
||||
"./lark/queries": "./lark/queries.ts",
|
||||
"./slack": "./slack/index.ts",
|
||||
"./slack/queries": "./slack/queries.ts",
|
||||
"./feedback": "./feedback/index.ts",
|
||||
"./feedback/mutations": "./feedback/mutations.ts",
|
||||
"./realtime": "./realtime/index.ts",
|
||||
@@ -99,6 +101,7 @@
|
||||
"./logger": "./logger.ts",
|
||||
"./utils": "./utils.ts",
|
||||
"./constants/*": "./constants/*.ts",
|
||||
"./feature-flags": "./feature-flags/index.ts",
|
||||
"./platform": "./platform/index.ts",
|
||||
"./analytics": "./analytics/index.ts",
|
||||
"./i18n": "./i18n/index.ts",
|
||||
|
||||
@@ -102,9 +102,9 @@ describe("useRealtimeSync — ws instance change", () => {
|
||||
rerender({ ws: ws2 });
|
||||
|
||||
// Should have called invalidateQueries for all workspace-scoped keys
|
||||
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
|
||||
// = 22 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(22);
|
||||
// (15 workspace-scoped + 6 per-issue prefixes + 4 per-chat prefixes
|
||||
// + 1 workspaceKeys.list() + 1 cross-workspace inbox unread summary = 27 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(27);
|
||||
});
|
||||
|
||||
it("does not re-invalidate when rerendered with the same ws instance", () => {
|
||||
@@ -164,4 +164,26 @@ describe("useRealtimeSync — ws instance change", () => {
|
||||
expect(calls).toContainEqual(["issues", "attachments"]);
|
||||
expect(calls).toContainEqual(["issues", "tasks"]);
|
||||
});
|
||||
|
||||
it("invalidates per-chat-session caches (no wsId in key) on ws instance change", () => {
|
||||
// These keys are not under the ["chat", wsId] prefix, so they need their
|
||||
// own recovery invalidation when reconnecting after missed chat/task events.
|
||||
const ws1 = createMockWs();
|
||||
const { rerender } = renderHook(
|
||||
({ ws }) => useRealtimeSync(ws, stores),
|
||||
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
invalidateSpy.mockClear();
|
||||
rerender({ ws: null });
|
||||
|
||||
const ws2 = createMockWs();
|
||||
rerender({ ws: ws2 });
|
||||
|
||||
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
|
||||
expect(calls).toContainEqual(["chat", "messages"]);
|
||||
expect(calls).toContainEqual(["chat", "messages-page"]);
|
||||
expect(calls).toContainEqual(["chat", "pending-task"]);
|
||||
expect(calls).toContainEqual(["task-messages"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "../agents/queries";
|
||||
import { githubKeys } from "../github/queries";
|
||||
import { larkKeys } from "../lark/queries";
|
||||
import { slackKeys } from "../slack/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
onIssueLabelsChanged,
|
||||
onIssueMetadataChanged,
|
||||
} from "../issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxSummaryInvalidate } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import {
|
||||
notificationPreferenceOptions,
|
||||
@@ -230,6 +231,9 @@ export async function handleInboxNew(
|
||||
): Promise<void> {
|
||||
const sourceWsId = item.workspace_id;
|
||||
if (sourceWsId) onInboxNew(qc, sourceWsId, item);
|
||||
// A new item in ANY workspace can light the workspace-switcher dot, so
|
||||
// refresh the cross-workspace summary regardless of the active workspace.
|
||||
onInboxSummaryInvalidate(qc);
|
||||
// Fire a native OS notification only when the app isn't focused. When
|
||||
// the user is already looking at Multica, the inbox sidebar's unread
|
||||
// styling is enough — no need to interrupt with a banner. `desktopAPI`
|
||||
@@ -320,6 +324,9 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
|
||||
}
|
||||
// Cross-workspace, so outside the wsId guard: a reconnect may have missed
|
||||
// inbox events from any workspace, so re-pull the switcher-dot summary.
|
||||
onInboxSummaryInvalidate(qc);
|
||||
// Per-issue caches are keyed without wsId, so the issueKeys.all(wsId)
|
||||
// prefix above does not reach them. They rely entirely on WS events for
|
||||
// freshness (staleTime: Infinity), so events missed while disconnected
|
||||
@@ -333,6 +340,14 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.usageAll() });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.attachmentsAll() });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.tasksAll() });
|
||||
// Per-chat-session caches are also keyed without wsId, so the
|
||||
// chatKeys.all(wsId) prefix above only reaches session lists / aggregates.
|
||||
// Message streams rely on WS invalidation with staleTime: Infinity; recover
|
||||
// sessions that missed chat/task events while the socket was disconnected.
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messagesAll() });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messagesPageAll() });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTaskAll() });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.taskMessagesAll() });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
}
|
||||
|
||||
@@ -394,6 +409,12 @@ export function useRealtimeSync(
|
||||
inbox: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onInboxInvalidate(qc, wsId);
|
||||
// inbox:read / inbox:archived / batch events arrive here. They can
|
||||
// originate from a workspace other than the active one (personal
|
||||
// events fan out to all the user's connections), so always refresh
|
||||
// the cross-workspace summary — its dot must clear when another
|
||||
// workspace's items are read/archived.
|
||||
onInboxSummaryInvalidate(qc);
|
||||
},
|
||||
agent: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
@@ -472,6 +493,10 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: larkKeys.installations(wsId) });
|
||||
},
|
||||
slack_installation: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: slackKeys.installations(wsId) });
|
||||
},
|
||||
pull_request: () => {
|
||||
// PR list is keyed by issue id, not workspace, so we invalidate all
|
||||
// PR queries — the open issue detail page will refetch its own list.
|
||||
@@ -588,6 +613,8 @@ export function useRealtimeSync(
|
||||
if (wsId) {
|
||||
onIssueUpdated(qc, wsId, issue, {
|
||||
assigneeChanged: payload.assignee_changed,
|
||||
statusChanged: payload.status_changed,
|
||||
projectChanged: payload.project_changed,
|
||||
});
|
||||
if (issue.status) {
|
||||
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkQuickCreateCliVersion } from "./cli-version";
|
||||
import {
|
||||
checkQuickCreateCliVersion,
|
||||
handoffSupported,
|
||||
MIN_HANDOFF_CLI_VERSION,
|
||||
} from "./cli-version";
|
||||
|
||||
describe("checkQuickCreateCliVersion", () => {
|
||||
it("returns ok for a tagged release at or above the minimum", () => {
|
||||
@@ -24,3 +28,30 @@ describe("checkQuickCreateCliVersion", () => {
|
||||
expect(checkQuickCreateCliVersion("0.1.0-1-gabc1234").state).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors server/pkg/agent/handoff_version_test.go so the frontend soft-gate
|
||||
// signal and the server's authoritative one agree by construction.
|
||||
describe("handoffSupported", () => {
|
||||
it("supports a tagged release at or above the minimum", () => {
|
||||
expect(handoffSupported(MIN_HANDOFF_CLI_VERSION)).toBe(true);
|
||||
expect(handoffSupported("0.4.0")).toBe(true);
|
||||
expect(handoffSupported("v0.3.28")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not support a tagged release below the minimum", () => {
|
||||
expect(handoffSupported("0.3.26")).toBe(false);
|
||||
expect(handoffSupported("0.2.21")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on empty or unparsable input", () => {
|
||||
expect(handoffSupported("")).toBe(false);
|
||||
expect(handoffSupported(undefined)).toBe(false);
|
||||
expect(handoffSupported(null)).toBe(false);
|
||||
expect(handoffSupported("garbage")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats git-describe dev builds as supported regardless of base tag", () => {
|
||||
expect(handoffSupported("v0.3.0-5-gabc1234")).toBe(true);
|
||||
expect(handoffSupported("v0.1.0-235-gdaf0e935-dirty")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,3 +72,33 @@ export function readRuntimeCliVersion(metadata: Record<string, unknown> | undefi
|
||||
const v = metadata?.cli_version;
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend mirror of the server's `MinHandoffCLIVersion` soft gate
|
||||
* (`server/pkg/agent/version.go`). The assignment handoff note is only rendered
|
||||
* into the run's opening prompt by daemons at or above this multica CLI version
|
||||
* (MUL-3375); older daemons silently drop it. Unlike the quick-create gate this
|
||||
* never blocks the assignment — the UI just grays out the note box and warns.
|
||||
*
|
||||
* Keep in lockstep with the server constant; the two are enforced independently
|
||||
* (the server is authoritative) but must agree so the warning matches reality.
|
||||
*/
|
||||
export const MIN_HANDOFF_CLI_VERSION = "0.3.28";
|
||||
|
||||
/**
|
||||
* Whether a daemon-reported CLI version is new enough to render a handoff note.
|
||||
* Mirrors server `agent.HandoffSupported`: missing / unparsable / below-minimum
|
||||
* all degrade to `false`, and dev-built daemons (git-describe shape) always
|
||||
* pass — the version string is the shared signal, so frontend and server agree
|
||||
* by construction. Pure and synchronous, so the note box can settle from the
|
||||
* already-warm runtime cache instead of waiting on the trigger-preview
|
||||
* round-trip, exactly like the quick-create version gate.
|
||||
*/
|
||||
export function handoffSupported(detected: string | undefined | null): boolean {
|
||||
const current = (detected ?? "").trim();
|
||||
if (!current) return false;
|
||||
if (DEV_DESCRIBE_RE.test(current)) return true;
|
||||
const parsed = parseSemver(current);
|
||||
if (!parsed) return false;
|
||||
return !lessThan(parsed, parseSemver(MIN_HANDOFF_CLI_VERSION)!);
|
||||
}
|
||||
|
||||
1
packages/core/slack/index.ts
Normal file
1
packages/core/slack/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { slackKeys, slackInstallationsOptions } from "./queries";
|
||||
18
packages/core/slack/queries.ts
Normal file
18
packages/core/slack/queries.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
/** Query key namespace for everything Slack-installation-related. Realtime
|
||||
* sync invalidates `installations(wsId)` on `slack_installation:*` events so
|
||||
* the Settings panel updates without a manual refetch (e.g. after the OAuth
|
||||
* callback lands the install in another tab / the system browser). */
|
||||
export const slackKeys = {
|
||||
all: (wsId: string) => ["slack", wsId] as const,
|
||||
installations: (wsId: string) => [...slackKeys.all(wsId), "installations"] as const,
|
||||
};
|
||||
|
||||
export const slackInstallationsOptions = (wsId: string) =>
|
||||
queryOptions({
|
||||
queryKey: slackKeys.installations(wsId),
|
||||
queryFn: () => api.listSlackInstallations(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
@@ -63,7 +63,6 @@ export const RUNTIME_PROFILE_PROTOCOL_FAMILIES = [
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
"gemini",
|
||||
"pi",
|
||||
"cursor",
|
||||
"kimi",
|
||||
|
||||
@@ -49,6 +49,16 @@ export interface Autopilot {
|
||||
// List endpoint returns []; only the detail endpoint populates this.
|
||||
// Treat undefined as empty on older servers.
|
||||
subscribers?: AutopilotSubscriber[];
|
||||
// Whether the requesting user may edit / delete / trigger / manage this
|
||||
// autopilot (creator, workspace owner/admin, or a granted collaborator).
|
||||
// Present on list and detail responses; absent on older servers — treat
|
||||
// undefined as "unknown" rather than "denied" (the server is the gate).
|
||||
can_write?: boolean;
|
||||
// Whether the requesting user may manage the collaborator (access) list —
|
||||
// narrower than can_write: held only by the creator and workspace
|
||||
// owners/admins, NOT by granted collaborators. Detail-endpoint-only; absent
|
||||
// on older servers (fall back to can_write).
|
||||
can_manage_access?: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookEventFilter {
|
||||
@@ -62,6 +72,19 @@ export interface AutopilotSubscriber {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// A workspace member explicitly granted write access to an autopilot, on top
|
||||
// of the implicit "creator ∪ owner/admin" set. Members-only for now.
|
||||
export interface AutopilotCollaborator {
|
||||
user_type: "member";
|
||||
user_id: string;
|
||||
granted_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AutopilotCollaboratorsResponse {
|
||||
collaborators: AutopilotCollaborator[];
|
||||
}
|
||||
|
||||
export interface AutopilotTrigger {
|
||||
id: string;
|
||||
autopilot_id: string;
|
||||
@@ -164,6 +187,9 @@ export interface ListAutopilotsResponse {
|
||||
export interface GetAutopilotResponse {
|
||||
autopilot: Autopilot;
|
||||
triggers: AutopilotTrigger[];
|
||||
// Members explicitly granted write access. Absent on older servers — treat
|
||||
// undefined as an empty list.
|
||||
collaborators?: AutopilotCollaborator[];
|
||||
}
|
||||
|
||||
export interface ListAutopilotRunsResponse {
|
||||
|
||||
@@ -95,11 +95,17 @@ export interface IssueCreatedPayload {
|
||||
export interface IssueUpdatedPayload {
|
||||
issue: Issue;
|
||||
// The server stamps issue:updated with which fields actually changed
|
||||
// (server/internal/handler/issue.go publish). Only assignee_changed is read
|
||||
// today: it lets the realtime layer keep filtered myList caches in place on a
|
||||
// non-membership change instead of refetching. Other change flags are present
|
||||
// on the wire too and can be surfaced here when needed.
|
||||
// (server/internal/handler/issue.go publish). assignee_changed lets the
|
||||
// realtime layer keep filtered myList caches in place on a non-membership
|
||||
// change instead of refetching; status_changed lets it reconcile board column
|
||||
// counts when a status change lands on an off-screen (unloaded) issue;
|
||||
// project_changed lets it drop a moved issue from the old project's filtered
|
||||
// list (the client-side cache diff is unreliable after an optimistic local
|
||||
// move — MUL-3669 / #4548). Other change flags are present on the wire too and
|
||||
// can be surfaced here when needed.
|
||||
assignee_changed?: boolean;
|
||||
status_changed?: boolean;
|
||||
project_changed?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueDeletedPayload {
|
||||
|
||||
@@ -22,6 +22,17 @@ export type InboxItemType =
|
||||
| "quick_create_done"
|
||||
| "quick_create_failed";
|
||||
|
||||
/**
|
||||
* One workspace's unread inbox count in the cross-workspace summary
|
||||
* (`GET /api/inbox/unread-summary`). The sidebar uses this to light a dot on
|
||||
* the workspace switcher when a workspace OTHER than the active one has
|
||||
* unread items.
|
||||
*/
|
||||
export interface InboxWorkspaceUnread {
|
||||
workspace_id: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
|
||||
@@ -61,7 +61,7 @@ export type {
|
||||
} from "./agent";
|
||||
export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread } from "./inbox";
|
||||
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
|
||||
export type { Comment, CommentType, CommentAuthorType, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, Reaction } from "./comment";
|
||||
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
|
||||
@@ -119,6 +119,12 @@ export type {
|
||||
LarkInstallStatusResponse,
|
||||
RedeemLarkBindingTokenResponse,
|
||||
} from "./lark";
|
||||
export type {
|
||||
SlackInstallation,
|
||||
ListSlackInstallationsResponse,
|
||||
RegisterSlackBYORequest,
|
||||
RedeemSlackBindingTokenResponse,
|
||||
} from "./slack";
|
||||
export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
@@ -126,6 +132,8 @@ export type {
|
||||
AutopilotAssigneeType,
|
||||
AutopilotSubscriber,
|
||||
AutopilotSubscriberInput,
|
||||
AutopilotCollaborator,
|
||||
AutopilotCollaboratorsResponse,
|
||||
AutopilotTrigger,
|
||||
AutopilotTriggerKind,
|
||||
AutopilotRun,
|
||||
|
||||
@@ -52,13 +52,14 @@ export interface ListProjectsResponse {
|
||||
// validateAndNormalizeResourceRef on the server and a renderer in the UI.
|
||||
//
|
||||
// Known types (UI must default-case unknown server-side additions):
|
||||
// - github_repo: cloud-side git checkout, ref = { url, default_branch_hint? }
|
||||
// - github_repo: cloud-side git checkout, ref = { url, ref?, default_branch_hint? }
|
||||
// - local_directory: in-place agent execution on a specific daemon,
|
||||
// ref = { local_path, daemon_id, label? }
|
||||
export type ProjectResourceType = "github_repo" | "local_directory";
|
||||
|
||||
export interface GithubRepoResourceRef {
|
||||
url: string;
|
||||
ref?: string;
|
||||
default_branch_hint?: string;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user