mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
123 Commits
refactor/s
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13e91f1f40 | ||
|
|
50acd48992 | ||
|
|
cb329c5475 | ||
|
|
5e1c7878d6 | ||
|
|
6a665c68a3 | ||
|
|
174b8c62a6 | ||
|
|
768d3f8b0c | ||
|
|
7dfa72465c | ||
|
|
0b969483a6 | ||
|
|
e024ab1232 | ||
|
|
f4eb83bd41 | ||
|
|
dde42ba84a | ||
|
|
9467a8c616 | ||
|
|
cfa38df97b | ||
|
|
4ad0a0b847 | ||
|
|
1fd583ef65 | ||
|
|
286ecf04b1 | ||
|
|
bd82607645 | ||
|
|
365e84b920 | ||
|
|
86e7de3e41 | ||
|
|
936ccce8fa | ||
|
|
49ccd22027 | ||
|
|
e66bd593ea | ||
|
|
7528022355 | ||
|
|
391a4ecd09 | ||
|
|
54d895a210 | ||
|
|
40a984c997 | ||
|
|
9ccaf18479 | ||
|
|
866b901943 | ||
|
|
9baa72cc68 | ||
|
|
576304519b | ||
|
|
f0a3f5ddeb | ||
|
|
22136a55fc | ||
|
|
375534573c | ||
|
|
2a59236575 | ||
|
|
415060e6be | ||
|
|
f745a3bbbe | ||
|
|
a475c17283 | ||
|
|
e4103f6ad7 | ||
|
|
2d9c153695 | ||
|
|
805071b5b1 | ||
|
|
f0c845b777 | ||
|
|
9587a577e2 | ||
|
|
21e3cfaa01 | ||
|
|
01855f6b09 | ||
|
|
03f3180b8f | ||
|
|
6f9e82cecc | ||
|
|
bbe73ade8b | ||
|
|
1845eaf42c | ||
|
|
c366cf2ba1 | ||
|
|
fae108ebdc | ||
|
|
0236e409e4 | ||
|
|
2f793fb6fe | ||
|
|
b2fb39ed21 | ||
|
|
abd69890a8 | ||
|
|
246fcd4ce4 | ||
|
|
9db91e89f5 | ||
|
|
541aaa974d | ||
|
|
81231e06f8 | ||
|
|
6ef711cd35 | ||
|
|
b8f661e006 | ||
|
|
f628e48775 | ||
|
|
f864a07bd5 | ||
|
|
c381d59c7a | ||
|
|
1292ecf71b | ||
|
|
b77acdf642 | ||
|
|
6bd5bbad9c | ||
|
|
4c81fbed2b | ||
|
|
d63e7c1c45 | ||
|
|
dabebe0c12 | ||
|
|
d14265de2a | ||
|
|
bf6509be96 | ||
|
|
6620997503 | ||
|
|
e268ee3e71 | ||
|
|
e9d04ecfc1 | ||
|
|
2e7da8c63f | ||
|
|
04882c2201 | ||
|
|
ba2f19d631 | ||
|
|
7f6776b12f | ||
|
|
8b340fcf21 | ||
|
|
1f770813dd | ||
|
|
29122cc18b | ||
|
|
18524d80d0 | ||
|
|
141c294cdb | ||
|
|
04f813a70f | ||
|
|
c7a2d53f76 | ||
|
|
aca74293dd | ||
|
|
12e6ca9906 | ||
|
|
3c3e3bd330 | ||
|
|
25b393df17 | ||
|
|
6f04a6d26b | ||
|
|
58547faf31 | ||
|
|
9b55b2a9ce | ||
|
|
c7bac0aa6b | ||
|
|
101601a4c3 | ||
|
|
95912243bb | ||
|
|
24e135541b | ||
|
|
2df969cffc | ||
|
|
5eab1dbbe1 | ||
|
|
a89064d693 | ||
|
|
68a312c297 | ||
|
|
683ff132ca | ||
|
|
93fe324bb9 | ||
|
|
74593fdb88 | ||
|
|
60fdc82824 | ||
|
|
c3ae212b40 | ||
|
|
d17b2bfb8c | ||
|
|
13d9d7df1b | ||
|
|
71b2032174 | ||
|
|
f7fe0829f2 | ||
|
|
9e1e3981fb | ||
|
|
c7e725ef66 | ||
|
|
fe84e29b64 | ||
|
|
4f40f70ea7 | ||
|
|
99154d97b9 | ||
|
|
7067d8f125 | ||
|
|
9ed1fa95fc | ||
|
|
147fb2ee66 | ||
|
|
9c177562e2 | ||
|
|
5bab95ad26 | ||
|
|
0bd6ba9354 | ||
|
|
40cea8454d | ||
|
|
d54daa62c5 |
33
.env.example
33
.env.example
@@ -11,17 +11,21 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# DATABASE_MIN_CONNS=5
|
||||
|
||||
# Server
|
||||
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
|
||||
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
|
||||
# "production" by default, so 888888 is DISABLED — a public instance can't
|
||||
# be logged into with any email + 888888.
|
||||
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
|
||||
# - Docker self-host on a private network you fully control, or evaluation
|
||||
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
|
||||
# enable on a publicly reachable instance.
|
||||
# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to
|
||||
# "production" by default. Local dev can leave it unset.
|
||||
# See SELF_HOSTING.md for the full login setup.
|
||||
APP_ENV=
|
||||
# Optional local/testing shortcut. Empty by default, so there is no fixed
|
||||
# verification code. Without RESEND_API_KEY, generated codes print to stdout.
|
||||
# If you need deterministic local automation, set a 6-digit value such as
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
PORT=8080
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
# HTTP request metrics start accumulating only when this listener is enabled.
|
||||
# METRICS_ADDR=127.0.0.1:9090
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
@@ -45,8 +49,7 @@ MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
|
||||
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
|
||||
# master code 888888 works (only when APP_ENV != "production"; see above).
|
||||
# For local/dev use, leave RESEND_API_KEY empty — generated codes print to stdout.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
@@ -85,6 +88,16 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
|
||||
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
|
||||
# callers with no forwarding headers and returns 404 to everything else —
|
||||
# safe for local dev. Any deployment behind a reverse proxy (Caddy / Nginx
|
||||
# terminating TLS in front of localhost:8080) MUST set this token, since
|
||||
# proxied requests look like loopback at the Go layer; with no token, those
|
||||
# requests are refused with 404. Pass the token as
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -56,6 +56,12 @@ jobs:
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
# Only run on the canonical upstream repo. Forks don't have the
|
||||
# HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to
|
||||
# `multica-ai/homebrew-tap` anyway. Without this guard, every fork's
|
||||
# tag push fails this job (401 against the upstream tap), which makes
|
||||
# downstream CI go red without affecting the actual artifact pipeline.
|
||||
if: github.repository_owner == 'multica-ai'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -136,6 +136,17 @@ make start-worktree # Start using .env.worktree
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
|
||||
### Backend Handler UUID Parsing Convention
|
||||
|
||||
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
|
||||
|
||||
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
|
||||
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
|
||||
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
|
||||
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
|
||||
|
||||
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
These are hard constraints. Violating them breaks the cross-platform architecture:
|
||||
|
||||
@@ -146,6 +146,8 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
| Gemini | `gemini` | Google's coding agent |
|
||||
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
|
||||
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
|
||||
| Kimi | `kimi` | Moonshot coding agent |
|
||||
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -166,6 +168,7 @@ Daemon behavior is configured via flags or environment variables:
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
@@ -178,8 +181,10 @@ Agent-specific overrides:
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
@@ -192,6 +197,12 @@ Agent-specific overrides:
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
|
||||
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
|
||||
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
|
||||
@@ -373,7 +373,8 @@ done
|
||||
|
||||
#### 2. Create a test user and token (automated auth)
|
||||
|
||||
In non-production environments the verification code is fixed at `888888`:
|
||||
For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888`
|
||||
in your env file before starting the backend:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$SERVER/auth/send-code" \
|
||||
@@ -476,7 +477,9 @@ This automatically:
|
||||
3. Starts and manages its own daemon instance
|
||||
4. Connects to the local backend
|
||||
|
||||
Login in the Desktop UI with `dev@localhost` and code `888888`.
|
||||
Login in the Desktop UI with `dev@localhost` and the generated code from the
|
||||
backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting
|
||||
the backend, you can use `888888` instead.
|
||||
|
||||
If the backend runs on a non-default port (worktree), create
|
||||
`apps/desktop/.env.development.local`:
|
||||
|
||||
@@ -15,7 +15,7 @@ COPY server/ ./server/
|
||||
# Build binaries
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
|
||||
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
# Architecture Audit — Workspace & Realtime Cache
|
||||
|
||||
> 基于代码审计整理的 4 个任务。优先级:P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1 — [P0] 空闲后列表数据陈旧
|
||||
|
||||
**关联 issue**:[#951](https://github.com/multica-ai/multica/issues/951)
|
||||
|
||||
### 问题
|
||||
|
||||
用户登录后静置一段时间,Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
|
||||
|
||||
### 根因
|
||||
|
||||
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
|
||||
|
||||
- `packages/core/query-client.ts:7` — `staleTime: Infinity`,cache 永不主动过期
|
||||
- `packages/core/query-client.ts:9` — `refetchOnWindowFocus: false`,tab 重新获得焦点也不 refetch
|
||||
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
|
||||
|
||||
但 WS 层存在一个**不对称**:
|
||||
|
||||
- **服务端**:`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait,会清理死连接
|
||||
- **客户端**:`packages/core/api/ws-client.ts`(142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
|
||||
|
||||
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS,所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
|
||||
|
||||
1. 浏览器 `readyState` 仍是 `OPEN`
|
||||
2. `onclose` 不触发
|
||||
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
|
||||
4. `packages/core/realtime/use-realtime-sync.ts:462-487` 的 `onReconnect` 全量 invalidate 不跑
|
||||
5. 期间的 WS 事件进黑洞
|
||||
6. cache 保持旧快照
|
||||
|
||||
### 复现
|
||||
|
||||
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
|
||||
|
||||
**方法 A(推荐,最接近真实场景)**:macOS 用 pfctl 丢包
|
||||
|
||||
```bash
|
||||
# 假设后端在 8080
|
||||
sudo pfctl -E
|
||||
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
|
||||
|
||||
# 观察:
|
||||
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
|
||||
# - Network 里 WS 连接仍显示 Pending / 101
|
||||
# 用另一个账号/CLI 创建一个 issue
|
||||
# 回到原客户端: 列表不更新
|
||||
# 登出再登入: 列表恢复完整
|
||||
|
||||
sudo pfctl -d # 解除
|
||||
```
|
||||
|
||||
**方法 B(不动网络)**:临时修改代码,在 `packages/core/api/ws-client.ts:52` 的 `onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
|
||||
|
||||
### 修复方案(三个选项,推荐 C)
|
||||
|
||||
#### 选项 A — 浏览器端心跳探活(治本,改动大)
|
||||
|
||||
在 `ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
|
||||
|
||||
- 优点:从根本上解决半开问题
|
||||
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
|
||||
|
||||
#### 选项 B — Page Visibility API 触发 invalidate(治标,改动小)
|
||||
|
||||
在 `packages/core/platform/core-provider.tsx` 加 `visibilitychange` 监听,tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key)。
|
||||
|
||||
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab)
|
||||
- 缺点:treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
|
||||
|
||||
#### 选项 C — **A + B 组合**(推荐)
|
||||
|
||||
- 短期上 B,立刻止血
|
||||
- 中期上 A,把 cache 新鲜度从"只信 WS"改成"WS 是优化,Visibility 是兜底"
|
||||
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min),作为第三层保险
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 方案 | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
|
||||
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
|
||||
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
|
||||
|
||||
### 验证
|
||||
|
||||
修完之后:
|
||||
|
||||
1. 跑方法 A 复现流程,确认数据不再丢失
|
||||
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
|
||||
|
||||
---
|
||||
|
||||
## 任务 2 — [P1] Workspace 不在 URL 路径中
|
||||
|
||||
**关联 issue**:MUL-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
|
||||
|
||||
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
|
||||
|
||||
### 问题
|
||||
|
||||
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
### 根因
|
||||
|
||||
**数据库和 API 已经支持 slug**:
|
||||
|
||||
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
|
||||
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
|
||||
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
|
||||
|
||||
**但前端路由和导航层没用它**:
|
||||
|
||||
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
|
||||
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
|
||||
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
|
||||
|
||||
**workspace 切换只靠 sidebar UI**(`packages/views/layout/app-sidebar.tsx:284-286`):
|
||||
|
||||
```tsx
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues"); // 硬跳 /issues(workspace-implicit!)
|
||||
switchWorkspace(ws); // 然后改 store
|
||||
}
|
||||
```
|
||||
|
||||
这种设计使得:
|
||||
|
||||
- 手机端因为没 sidebar UI,也没 URL 层切换入口,**完全切不了 workspace**(MUL-509)
|
||||
- 把 `/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列)
|
||||
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
|
||||
|
||||
### 复现
|
||||
|
||||
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
|
||||
2. **MUL-43**:
|
||||
- 加入两个 workspace A 和 B
|
||||
- 在 A 中打开某个 issue `/issues/abc123`
|
||||
- 切到 B,URL 不变 → 访问失败 / 显示错数据
|
||||
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
|
||||
|
||||
### 修复方案(三个选项,推荐 A)
|
||||
|
||||
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
|
||||
|
||||
所有路径加上 workspace slug 前缀。例如 `/issues/abc123` → `/ws/my-team/issues/abc123`。
|
||||
|
||||
**要改的地方**:
|
||||
|
||||
1. **Web 路由目录结构**:`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`(~25 个文件)
|
||||
2. **Desktop 路由**:`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
|
||||
3. **Navigation 适配器**:
|
||||
- `apps/web/platform/navigation.tsx` — `push(path)` 内部前置 `/ws/${workspace.slug}`,`pathname` 读取时去掉前缀
|
||||
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
|
||||
4. **Sidebar 切换逻辑**:`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
|
||||
5. **服务端中间件**:`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
|
||||
|
||||
**预计改动**:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
|
||||
|
||||
**不改也能工作的部分**:
|
||||
- `packages/core/api/client.ts` — 仍旧走 header,不用改
|
||||
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
|
||||
|
||||
**风险**:
|
||||
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
|
||||
- E2E 测试需要更新所有 URL 断言
|
||||
|
||||
#### 选项 B — `?ws=slug` query param(折中)
|
||||
|
||||
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
|
||||
|
||||
#### 选项 C — 只修症状不动架构
|
||||
|
||||
在 `switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。**不推荐**。
|
||||
|
||||
### 改动范围(选项 A)
|
||||
|
||||
| 模块 | 文件数 | 备注 |
|
||||
|---|---|---|
|
||||
| Web routes | ~25 | 目录搬迁 |
|
||||
| Desktop routes | 1 | 路径前缀 |
|
||||
| Navigation adapters | 2 | 前缀逻辑 |
|
||||
| Server middleware | 1-2 | slug → ID 解析 |
|
||||
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
|
||||
| E2E tests | 20-30 | URL 断言更新 |
|
||||
|
||||
---
|
||||
|
||||
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
|
||||
|
||||
**关联 issue**:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
|
||||
|
||||
> 同上,这两个编号建议交接时核对症状。
|
||||
|
||||
### 问题
|
||||
|
||||
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`(key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
|
||||
|
||||
### 根因
|
||||
|
||||
**`packages/core/navigation/store.ts:15-31`**:
|
||||
|
||||
```typescript
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
// ← 没有调 registerForWorkspaceRehydration
|
||||
```
|
||||
|
||||
**对比:其他 store 都是正确的**:
|
||||
|
||||
| Store | 是否 workspace-aware | 是否注册 rehydration |
|
||||
|---|---|---|
|
||||
| useNavigationStore | ❌ | ❌ |
|
||||
| useIssuesScopeStore | ✅ | ✅ |
|
||||
| useIssueDraftStore | ✅ | ✅ |
|
||||
| useRecentIssuesStore | ✅ | ✅ |
|
||||
| useIssueViewStore | ✅ | ✅ |
|
||||
| myIssuesViewStore | ✅ | ✅ |
|
||||
| useChatStore | ✅(手动用 wsKey)| ✅ |
|
||||
|
||||
另外 `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`。
|
||||
|
||||
**现有的 workaround**:`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
|
||||
|
||||
### 复现
|
||||
|
||||
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
|
||||
2. 切到 workspace B
|
||||
3. 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B,导致 404 或错误
|
||||
|
||||
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
|
||||
|
||||
### 修复方案(推荐 Option C:组合)
|
||||
|
||||
**三处改动**:
|
||||
|
||||
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
|
||||
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
|
||||
3. `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
|
||||
|
||||
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285` 的 `push("/issues")` workaround(改完之后不再需要)。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
|
||||
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
|
||||
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
|
||||
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
|
||||
|
||||
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4 — [P2] Workspace 生命周期副作用散落
|
||||
|
||||
**关联 issue**:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
|
||||
|
||||
### 问题
|
||||
|
||||
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug:
|
||||
|
||||
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
|
||||
|
||||
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
|
||||
|
||||
- `packages/core/workspace/mutations.ts:7-21` 的 `useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand,`/issues` 路由开始用新 workspace 渲染
|
||||
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
|
||||
|
||||
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`。
|
||||
|
||||
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx` 的 `onSuccess` 里先 `switchWorkspace` 再 `push`,保证同一个微任务里完成。
|
||||
|
||||
**文件**:`packages/core/workspace/mutations.ts`、`packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
|
||||
|
||||
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
|
||||
|
||||
**核查结果**:`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
|
||||
|
||||
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
|
||||
|
||||
**修复**:在 `handleDeleteWorkspace` 的 `onConfirm` 成功分支里加 `push("/issues")`。
|
||||
|
||||
**文件**:`packages/views/settings/components/workspace-tab.tsx`(加一行)
|
||||
|
||||
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
|
||||
|
||||
**核查结果**:有两条路径:
|
||||
|
||||
- ✅ `/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**:accept → switchWorkspace → push("/issues")
|
||||
- ❌ **Sidebar 下拉里的 "Join" 按钮**(`packages/views/layout/app-sidebar.tsx:203-209, 321-324`)**是错的**:只 invalidate cache,不切也不跳
|
||||
|
||||
**修复(推荐 Option 2)**:Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
|
||||
{inv.workspace_name}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**文件**:`packages/views/layout/app-sidebar.tsx`(~10 行)
|
||||
|
||||
### 复现
|
||||
|
||||
| Issue | 步骤 |
|
||||
|---|---|
|
||||
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
|
||||
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面(BUG: 没有自动跳走) |
|
||||
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切)|
|
||||
|
||||
### 长期架构建议(可选)
|
||||
|
||||
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| Issue | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
|
||||
| MUL-728 | workspace-tab.tsx | ~1 行 |
|
||||
| MUL-820 | app-sidebar.tsx | ~10 行 |
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行;Option C ~1-2 天 | 低 |
|
||||
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中(影响面大、e2e 要改)|
|
||||
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
|
||||
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
|
||||
|
||||
### 建议推进顺序
|
||||
|
||||
1. **立刻做**:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
|
||||
2. **同步开始**:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
|
||||
3. **规划立项**:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
|
||||
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
|
||||
|
||||
### 重要澄清
|
||||
|
||||
- **Issue 编号核对**:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
|
||||
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
|
||||
- **MUL-820 实际状态**:`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
|
||||
|
||||
### 所有关键代码位置索引
|
||||
|
||||
```
|
||||
packages/core/query-client.ts:7-10 # staleTime: Infinity
|
||||
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
|
||||
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
|
||||
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
|
||||
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
|
||||
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
|
||||
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
|
||||
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
|
||||
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
|
||||
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
|
||||
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
|
||||
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
|
||||
|
||||
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
|
||||
server/internal/middleware/workspace.go:41-46 # wsId resolution
|
||||
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
|
||||
```
|
||||
6
Makefile
6
Makefile
@@ -91,7 +91,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo " or read the generated code from backend logs when Resend is unset."; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
@@ -130,7 +130,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo " or read the generated code from backend logs when Resend is unset."; \
|
||||
echo ""; \
|
||||
echo "Built images locally via docker-compose.selfhost.build.yml."; \
|
||||
echo "Local tags: multica-backend:dev and multica-web:dev."; \
|
||||
@@ -277,7 +277,7 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
build: ## Build the server, CLI, and migrate binaries into server/bin
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -30,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
@@ -98,7 +98,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -108,7 +108,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
@@ -162,7 +162,8 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
│ Agent Daemon │ runs on your machine
|
||||
└──────────────┘ (Claude Code, Codex, OpenCode,
|
||||
OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent)
|
||||
Pi, Cursor Agent, Kimi,
|
||||
Kiro CLI)
|
||||
```
|
||||
|
||||
| Layer | Stack |
|
||||
@@ -170,7 +171,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
| Frontend | Next.js 16 (App Router) |
|
||||
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| Database | PostgreSQL 17 with pgvector |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ multica setup self-host
|
||||
|
||||
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
|
||||
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
>
|
||||
@@ -67,15 +67,15 @@ Once ready:
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
|
||||
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
@@ -98,6 +98,8 @@ You also need at least one AI agent CLI installed:
|
||||
- Gemini (`gemini` on PATH)
|
||||
- [Pi](https://pi.dev/) (`pi` on PATH)
|
||||
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
|
||||
- Kimi (`kimi` on PATH)
|
||||
- Kiro CLI (`kiro-cli` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
|
||||
> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
@@ -79,6 +79,7 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
@@ -290,14 +291,45 @@ HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
The backend exposes public health endpoints:
|
||||
|
||||
```
|
||||
```text
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
|
||||
GET /readyz
|
||||
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
|
||||
GET /healthz
|
||||
→ same response as /readyz
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
|
||||
dependency-aware readiness probes and external monitoring that should fail when
|
||||
the database is unavailable or migrations are not fully applied. `/healthz` is
|
||||
kept as an alias for operator familiarity.
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
The backend can expose Prometheus metrics on a separate management listener:
|
||||
|
||||
```bash
|
||||
METRICS_ADDR=127.0.0.1:9090 ./server/bin/server
|
||||
curl http://127.0.0.1:9090/metrics
|
||||
```
|
||||
|
||||
`METRICS_ADDR` is empty by default, so no metrics listener is started. The
|
||||
public API port does not serve `/metrics`; keep it that way for internet-facing
|
||||
deployments. HTTP request metrics start accumulating only after the metrics
|
||||
listener is enabled. Metrics can reveal internal routes, traffic volume,
|
||||
dependency state, and runtime health.
|
||||
|
||||
For Docker or Kubernetes deployments, prefer a private scrape path: bind the
|
||||
metrics listener to an internal interface and protect it with private
|
||||
networking, allowlists, NetworkPolicy, or proxy authentication. If you bind
|
||||
`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a
|
||||
trusted network, for example a host-local mapping such as
|
||||
`127.0.0.1:9090:9090`.
|
||||
|
||||
## Upgrading
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ multica setup self-host
|
||||
|
||||
The `multica setup self-host` command will:
|
||||
1. Configure CLI to connect to localhost:8080 / localhost:3000
|
||||
2. Open a browser for login — use verification code `888888` with any email
|
||||
2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset
|
||||
3. Discover workspaces automatically
|
||||
4. Start the daemon in the background
|
||||
|
||||
@@ -73,4 +73,4 @@ If the default ports (8080/3000) are in use:
|
||||
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
|
||||
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
|
||||
- **Daemon issues:** `multica daemon logs`
|
||||
- **Health check:** `curl http://localhost:8080/health`
|
||||
- **Health checks:** `curl http://localhost:8080/health` for liveness, `curl http://localhost:8080/readyz` for dependency-aware readiness
|
||||
|
||||
@@ -37,6 +37,14 @@ linux:
|
||||
- deb
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
rpm:
|
||||
# Disable RPM build-id symlinks. Electron apps embed the upstream Electron
|
||||
# binary, whose GNU build-id is identical across every app shipping the same
|
||||
# Electron version (Slack, VS Code, Discord, ...). Without this, our RPM
|
||||
# would own /usr/lib/.build-id/<hash> paths and collide with any other
|
||||
# Electron RPM already installed, breaking `dnf install` on Fedora/RHEL.
|
||||
fpm:
|
||||
- "--rpm-rpmbuild-define=_build_id_links none"
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
|
||||
33
apps/desktop/src/main/app-version.ts
Normal file
33
apps/desktop/src/main/app-version.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { app } from "electron";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Resolve the running app version. In packaged builds this is the value
|
||||
* `electron-builder` baked into package.json via `extraMetadata.version`
|
||||
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
|
||||
* `app.getVersion()` matches the GitHub Release tag exactly.
|
||||
*
|
||||
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
|
||||
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
|
||||
* the Settings → Updates panel and any other UI surfacing the version
|
||||
* would mislead developers into thinking they're running ancient builds.
|
||||
* Fall back to `git describe --tags --always --dirty` (same source the
|
||||
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
|
||||
* unavailable for whatever reason, we just return the package.json value.
|
||||
*/
|
||||
export function getAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
try {
|
||||
const raw = execSync("git describe --tags --always --dirty", {
|
||||
cwd: app.getAppPath(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (!raw) return app.getVersion();
|
||||
return raw.replace(/^v/, "");
|
||||
} catch {
|
||||
return app.getVersion();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, ipcMain, BrowserWindow } from "electron";
|
||||
import { app, ipcMain, BrowserWindow, shell } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import {
|
||||
readFile,
|
||||
@@ -914,6 +914,20 @@ export function setupDaemonManager(
|
||||
stopLogTail();
|
||||
});
|
||||
|
||||
// Reveal the daemon's log file in the user's default editor / Console
|
||||
// app. Acts as the escape hatch when the in-app log viewer isn't enough
|
||||
// (full history, complex search, copy-to-clipboard at scale).
|
||||
ipcMain.handle("daemon:open-log-file", async () => {
|
||||
const active = await ensureActiveProfile();
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
return { success: false, error: "Log file not found yet" };
|
||||
}
|
||||
// shell.openPath returns "" on success, error string on failure.
|
||||
const error = await shell.openPath(logPath);
|
||||
return error === "" ? { success: true } : { success: false, error };
|
||||
});
|
||||
|
||||
// First-run CLI install kicks off here. Status bar shows "Setting up…"
|
||||
// until the managed binary is on disk (instant on subsequent launches).
|
||||
currentState = "installing_cli";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -203,7 +204,7 @@ if (!gotTheLock) {
|
||||
ipcMain.on("app:get-info", (event) => {
|
||||
const p = process.platform;
|
||||
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
event.returnValue = { version: app.getVersion(), os };
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
@@ -214,6 +215,64 @@ if (!gotTheLock) {
|
||||
mainWindow?.setWindowButtonVisibility(!immersive);
|
||||
});
|
||||
|
||||
// IPC: show a native OS notification for a new inbox item. The renderer
|
||||
// only fires this when the app is unfocused (it gates on
|
||||
// `document.hasFocus()`), so we don't fight macOS foreground suppression
|
||||
// here. Clicking the banner focuses the main window and routes to the
|
||||
// inbox item via a renderer-side listener.
|
||||
ipcMain.on(
|
||||
"notification:show",
|
||||
(
|
||||
_event,
|
||||
{
|
||||
slug,
|
||||
itemId,
|
||||
issueKey,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
},
|
||||
) => {
|
||||
if (!Notification.isSupported()) return;
|
||||
const notification = new Notification({ title, body });
|
||||
notification.on("click", () => {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
// Ship the full context back — the renderer pins the route to the
|
||||
// source workspace (slug), marks the row read (itemId), and uses
|
||||
// issueKey as the ?issue=<…> selector.
|
||||
mainWindow.webContents.send("inbox:open", {
|
||||
slug,
|
||||
itemId,
|
||||
issueKey,
|
||||
});
|
||||
});
|
||||
notification.show();
|
||||
},
|
||||
);
|
||||
|
||||
// IPC: update the dock / taskbar unread badge. Values above 99 render as
|
||||
// "99+". macOS is the primary target (user-visible dock badge); Linux
|
||||
// Unity launchers also respect `setBadgeCount`. Windows' taskbar overlay
|
||||
// needs a pre-rendered PNG and is deferred — the OS notification + the
|
||||
// in-app inbox sidebar cover the core UX there for now.
|
||||
ipcMain.on("badge:set", (_event, rawCount: number) => {
|
||||
const count = Math.max(0, Math.floor(rawCount));
|
||||
if (process.platform === "darwin") {
|
||||
const label = count === 0 ? "" : count > 99 ? "99+" : String(count);
|
||||
app.dock?.setBadge(label);
|
||||
} else {
|
||||
app.setBadgeCount(count);
|
||||
}
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
|
||||
19
apps/desktop/src/preload/index.d.ts
vendored
19
apps/desktop/src/preload/index.d.ts
vendored
@@ -14,6 +14,24 @@ interface DesktopAPI {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
setImmersiveMode: (immersive: boolean) => Promise<void>;
|
||||
/** Show a native OS notification for a new inbox item. */
|
||||
showNotification: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => void;
|
||||
/** Update the OS dock / taskbar unread badge. Pass 0 to clear. */
|
||||
setUnreadBadge: (count: number) => void;
|
||||
/** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */
|
||||
onInboxOpen: (
|
||||
callback: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -50,6 +68,7 @@ interface DaemonAPI {
|
||||
startLogStream: () => void;
|
||||
stopLogStream: () => void;
|
||||
onLogLine: (callback: (line: string) => void) => () => void;
|
||||
openLogFile: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
|
||||
@@ -50,6 +50,50 @@ const desktopAPI = {
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
setImmersiveMode: (immersive: boolean) =>
|
||||
ipcRenderer.invoke("window:setImmersive", immersive),
|
||||
/**
|
||||
* Show a native OS notification for a new inbox item. Fired from the
|
||||
* renderer only when the app is unfocused — in-focus feedback is the
|
||||
* inbox sidebar's unread styling. `slug`, `itemId`, and `issueKey` are
|
||||
* all round-tripped on click: slug pins routing to the source workspace
|
||||
* (the user may switch workspaces before clicking the banner), itemId
|
||||
* lets the renderer mark the row read, issueKey maps to the inbox URL
|
||||
* param.
|
||||
*/
|
||||
showNotification: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => ipcRenderer.send("notification:show", payload),
|
||||
/**
|
||||
* Update the OS dock / taskbar unread badge. Pass 0 to clear. Values
|
||||
* above 99 render as "99+" (capping is handled in the main process).
|
||||
*/
|
||||
setUnreadBadge: (count: number) =>
|
||||
ipcRenderer.send("badge:set", Math.max(0, Math.floor(count))),
|
||||
/**
|
||||
* Subscribe to "open this inbox row" requests sent by the main process
|
||||
* when the user clicks an OS notification banner. Returns an unsubscribe
|
||||
* function. The payload echoes the `slug`, `itemId`, and `issueKey` that
|
||||
* were passed to `showNotification`.
|
||||
*/
|
||||
onInboxOpen: (
|
||||
callback: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: { slug: string; itemId: string; issueKey: string },
|
||||
) => callback(payload);
|
||||
ipcRenderer.on("inbox:open", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("inbox:open", handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -101,6 +145,8 @@ const daemonAPI = {
|
||||
ipcRenderer.on("daemon:log-line", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:log-line", handler);
|
||||
},
|
||||
openLogFile: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:open-log-file"),
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
|
||||
@@ -7,13 +7,14 @@ import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { PageviewTracker } from "./components/pageview-tracker";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
@@ -99,6 +100,16 @@ function AppContent() {
|
||||
const wsCount = workspaces.length;
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Bridge local daemon IPC status into the runtimes cache so this user's
|
||||
// own daemon flips to offline/online sub-second instead of waiting on the
|
||||
// server's 75s sweeper. Resolves wsId from the active tab so workspace
|
||||
// switches automatically rebind the subscription.
|
||||
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
|
||||
const activeWsId = activeWorkspaceSlug
|
||||
? workspaces.find((w) => w.slug === activeWorkspaceSlug)?.id
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Onboarding and zero-workspace both resolve to an overlay, but
|
||||
// onboarding wins: a user who hasn't completed it gets the onboarding
|
||||
// overlay regardless of how many workspaces already exist.
|
||||
|
||||
@@ -1,150 +1,261 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Copy as CopyIcon,
|
||||
Search,
|
||||
Server,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet";
|
||||
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { parseLogLine, type LogLevel, type ParsedLogLine } from "./parse-daemon-log";
|
||||
|
||||
interface DaemonPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
status: DaemonStatus;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_COLORS: Record<string, string> = {
|
||||
INFO: "text-info",
|
||||
WARN: "text-warning",
|
||||
ERROR: "text-destructive",
|
||||
DEBUG: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function colorizeLogLine(line: string): { level: string; className: string } {
|
||||
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
|
||||
if (line.includes(level)) return { level, className };
|
||||
}
|
||||
return { level: "", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-right text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ state }: { state: DaemonState }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
line: string;
|
||||
/** Number of runtimes this local daemon has registered (for the context badge). */
|
||||
runtimeCount: number;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
let logIdCounter = 0;
|
||||
const LEVELS: readonly LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
|
||||
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const LEVEL_BADGE_CLASS: Record<LogLevel, string> = {
|
||||
DEBUG: "border-muted-foreground/25 text-muted-foreground/70",
|
||||
INFO: "border-foreground/15 text-foreground/80",
|
||||
WARN: "border-warning/40 text-warning",
|
||||
ERROR: "border-destructive/40 text-destructive",
|
||||
};
|
||||
|
||||
// What gets rendered in the viewport — a single line or a folded group of
|
||||
// consecutive lines that share the same `message`. The group form is what
|
||||
// turns a wall of `DBG poll: no tasks` into a single placeholder.
|
||||
type DisplayItem =
|
||||
| { kind: "line"; line: ParsedLogLine }
|
||||
| { kind: "group"; first: ParsedLogLine; rest: ParsedLogLine[] };
|
||||
|
||||
export function DaemonPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
status,
|
||||
runtimeCount,
|
||||
}: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<ParsedLogLine[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
// Each level chip is an independent toggle. DEBUG is off by default so
|
||||
// poll-loop noise doesn't drown out real events when the panel opens —
|
||||
// users opt in if they want to see it.
|
||||
const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
|
||||
() => new Set<LogLevel>(["INFO", "WARN", "ERROR"]),
|
||||
);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [expandedFields, setExpandedFields] = useState<Set<number>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
||||
|
||||
const idCounterRef = useRef(0);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// --- Log stream subscription ---
|
||||
// Active only while the modal is open. On open we replay the file's tail
|
||||
// (~200 lines) so users have context for "what just happened"; on close
|
||||
// we tear down the watcher so the main process isn't doing work for a
|
||||
// hidden UI.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
idCounterRef.current = 0;
|
||||
|
||||
window.daemonAPI.startLogStream();
|
||||
const unsub = window.daemonAPI.onLogLine((line) => {
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, { id: ++logIdCounter, line }];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
const id = ++idCounterRef.current;
|
||||
const parsed = parseLogLine(line, id);
|
||||
const next =
|
||||
prev.length >= MAX_LOG_LINES
|
||||
? [...prev.slice(prev.length - MAX_LOG_LINES + 1), parsed]
|
||||
: [...prev, parsed];
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
window.daemonAPI.stopLogStream();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
// --- Derived: counts per level (for filter chip badges) ---
|
||||
const levelCounts = useMemo(() => {
|
||||
const counts: Record<LogLevel, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 0,
|
||||
WARN: 0,
|
||||
ERROR: 0,
|
||||
};
|
||||
for (const l of logs) {
|
||||
if (l.level) counts[l.level] += 1;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
return counts;
|
||||
}, [logs]);
|
||||
|
||||
const handleLogScroll = useCallback(() => {
|
||||
// --- Derived: filtered list (level toggle + search) ---
|
||||
// Lines that didn't parse (level = null) always pass — they're typically
|
||||
// panic stack traces / partial writes; never silently drop them.
|
||||
const filtered = useMemo(() => {
|
||||
let result = logs;
|
||||
result = result.filter((l) => {
|
||||
if (!l.level) return true;
|
||||
return enabledLevels.has(l.level);
|
||||
});
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((l) => l.raw.toLowerCase().includes(q));
|
||||
}
|
||||
return result;
|
||||
}, [logs, enabledLevels, search]);
|
||||
|
||||
// --- Derived: collapse runs of consecutive lines that share the same
|
||||
// message into a single group placeholder. The most common case is the
|
||||
// 1-min `DBG poll: no tasks` heartbeat that otherwise pushes real events
|
||||
// off-screen. Grouping happens AFTER filtering so toggling DEBUG off
|
||||
// doesn't strand groups.
|
||||
const displayed = useMemo<DisplayItem[]>(() => {
|
||||
const out: DisplayItem[] = [];
|
||||
for (const line of filtered) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last) {
|
||||
out.push({ kind: "line", line });
|
||||
continue;
|
||||
}
|
||||
const lastMessage =
|
||||
last.kind === "line" ? last.line.message : last.first.message;
|
||||
if (lastMessage && lastMessage === line.message) {
|
||||
if (last.kind === "line") {
|
||||
out[out.length - 1] = {
|
||||
kind: "group",
|
||||
first: last.line,
|
||||
rest: [line],
|
||||
};
|
||||
} else {
|
||||
last.rest.push(line);
|
||||
}
|
||||
} else {
|
||||
out.push({ kind: "line", line });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [filtered]);
|
||||
|
||||
// --- Auto-scroll: pin to bottom while live; release on user scroll ---
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [displayed, autoScroll]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
setAutoScroll(atBottom);
|
||||
// Only flip auto-scroll OFF on user-initiated scroll-up; never flip ON
|
||||
// here. Re-enabling lives in the "Jump to latest" footer button so a
|
||||
// burst of lines doesn't yank a reading user back to the bottom.
|
||||
if (!atBottom && autoScroll) setAutoScroll(false);
|
||||
}, [autoScroll]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
setAutoScroll(true);
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = filtered.map((l) => l.raw).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Failed to copy", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, [filtered]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
const toggleLevel = useCallback((lv: LogLevel) => {
|
||||
setEnabledLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(lv)) next.delete(lv);
|
||||
else next.add(lv);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
const toggleFields = useCallback((id: number) => {
|
||||
setExpandedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
const toggleGroup = useCallback((id: number) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const hasActiveFilter = !!search || enabledLevels.size < LEVELS.length;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col sm:max-w-md"
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||
showCloseButton={false}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
Local Daemon
|
||||
</SheetTitle>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Server className="size-4 shrink-0 text-muted-foreground" />
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
Local daemon logs
|
||||
</DialogTitle>
|
||||
<ContextBadge status={status} runtimeCount={runtimeCount} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -153,157 +264,412 @@ export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</SheetHeader>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
|
||||
<div className="shrink-0 space-y-4">
|
||||
{/* Status info */}
|
||||
<div className="rounded-lg border p-3 space-y-0.5">
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot state={status.state} />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2">
|
||||
{/* Search */}
|
||||
<div className="relative w-56">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="h-7 w-full rounded-md border bg-background pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
|
||||
<InfoRow label="Profile" value={status.profile || "default"} />
|
||||
{status.serverUrl && (
|
||||
<InfoRow
|
||||
label="Server"
|
||||
value={
|
||||
<span className="font-mono text-xs" title={status.serverUrl}>
|
||||
{status.serverUrl}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.agents && status.agents.length > 0 && (
|
||||
<InfoRow label="Agents" value={status.agents.join(", ")} />
|
||||
)}
|
||||
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
|
||||
{status.daemonId && (
|
||||
<InfoRow
|
||||
label="Daemon ID"
|
||||
value={<span className="font-mono text-xs">{status.daemonId}</span>}
|
||||
/>
|
||||
)}
|
||||
{typeof status.workspaceCount === "number" && (
|
||||
<InfoRow label="Workspaces" value={status.workspaceCount} />
|
||||
)}
|
||||
{status.pid && (
|
||||
<InfoRow
|
||||
label="PID"
|
||||
value={<span className="font-mono text-xs">{status.pid}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{status.state === "installing_cli" ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
Setting up the local runtime… this only happens the first time.
|
||||
</div>
|
||||
) : status.state === "cli_not_found" ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
|
||||
<p className="text-sm">
|
||||
Couldn't download the local runtime. Check your network
|
||||
connection and try again.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
{/* Level toggle chips. Each chip is independent — click to
|
||||
show/hide that level. DEBUG starts hidden because the
|
||||
poll-loop heartbeat dominates otherwise. */}
|
||||
<div className="flex items-center gap-1">
|
||||
{LEVELS.map((lv) => (
|
||||
<FilterChip
|
||||
key={lv}
|
||||
active={enabledLevels.has(lv)}
|
||||
onClick={() => toggleLevel(lv)}
|
||||
label={lv}
|
||||
count={levelCounts[lv]}
|
||||
variant={lv}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right-aligned actions */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleCopy}
|
||||
disabled={filtered.length === 0}
|
||||
>
|
||||
<CopyIcon className="size-3.5 mr-1.5" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleClear}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs viewport */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-muted/20 px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{displayed.length === 0 ? (
|
||||
<EmptyState
|
||||
hasLogs={logs.length > 0}
|
||||
hasFilter={hasActiveFilter}
|
||||
isRunning={status.state === "running"}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
{status.state === "stopped" ? (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
<div className="flex flex-col">
|
||||
{displayed.map((item) =>
|
||||
item.kind === "line" ? (
|
||||
<LogLineRow
|
||||
key={item.line.id}
|
||||
line={item.line}
|
||||
expanded={expandedFields.has(item.line.id)}
|
||||
onToggle={() => toggleFields(item.line.id)}
|
||||
search={search}
|
||||
/>
|
||||
) : (
|
||||
<GroupRows
|
||||
key={item.first.id}
|
||||
first={item.first}
|
||||
rest={item.rest}
|
||||
expanded={expandedGroups.has(item.first.id)}
|
||||
onToggle={() => toggleGroup(item.first.id)}
|
||||
expandedFields={expandedFields}
|
||||
onToggleFields={toggleFields}
|
||||
search={search}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Logs — fills remaining vertical space down to the sheet bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h3 className="text-sm font-medium">Logs</h3>
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="size-3 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleLogScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground/50 text-center py-8">
|
||||
{status.state === "running"
|
||||
? "Waiting for logs…"
|
||||
: "Start the daemon to see logs"}
|
||||
</p>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const { className } = colorizeLogLine(entry.line);
|
||||
return (
|
||||
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
|
||||
{entry.line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Status bar — count only. The "is the user following" state is
|
||||
communicated implicitly by the presence of the Jump-to-latest
|
||||
button below; an explicit "Paused" word read as "log stream is
|
||||
paused" (it isn't — data keeps flowing into the buffer). */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
Showing {filtered.length} of {logs.length}
|
||||
{logs.length === MAX_LOG_LINES && (
|
||||
<span className="ml-1 text-muted-foreground/60">
|
||||
(buffer full)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!autoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResume}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowDown className="size-3" />
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function ContextBadge({
|
||||
status,
|
||||
runtimeCount,
|
||||
}: {
|
||||
status: DaemonStatus;
|
||||
runtimeCount: number;
|
||||
}) {
|
||||
const isRunning = status.state === "running";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
{isRunning && runtimeCount > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
· {runtimeCount} runtime{runtimeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
variant,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count: number;
|
||||
variant?: LogLevel;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs transition-colors hover:bg-accent",
|
||||
active
|
||||
? variant
|
||||
? LEVEL_BADGE_CLASS[variant]
|
||||
: "bg-accent text-accent-foreground"
|
||||
: "border-dashed text-muted-foreground/50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
active ? "text-current/80" : "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelBadge({ level }: { level: LogLevel }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 shrink-0 items-center rounded border px-1 text-[10px] font-medium uppercase tracking-wide",
|
||||
LEVEL_BADGE_CLASS[level],
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLineRow({
|
||||
line,
|
||||
expanded,
|
||||
onToggle,
|
||||
search,
|
||||
}: {
|
||||
line: ParsedLogLine;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
search: string;
|
||||
}) {
|
||||
const fieldEntries = Object.entries(line.fields);
|
||||
const hasFields = fieldEntries.length > 0;
|
||||
|
||||
// Unparseable line — render the raw text so nothing is hidden. Common
|
||||
// for panic stack traces and partial writes during log rotation.
|
||||
if (!line.timestamp || !line.level) {
|
||||
return (
|
||||
<div className="break-all whitespace-pre-wrap px-2 py-0.5 text-muted-foreground/70">
|
||||
{highlight(line.raw, search)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_auto_minmax(0,1fr)] items-baseline gap-2 rounded px-2 py-0.5 hover:bg-accent/30",
|
||||
hasFields && "cursor-pointer",
|
||||
)}
|
||||
onClick={hasFields ? onToggle : undefined}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/60">
|
||||
{line.timestamp}
|
||||
</span>
|
||||
<LevelBadge level={line.level} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="break-words">{highlight(line.message, search)}</span>
|
||||
{hasFields && !expanded && (
|
||||
<span className="min-w-0 truncate text-muted-foreground/60">
|
||||
{fieldEntries
|
||||
.map(([k, v]) => `${k}=${truncateValue(v)}`)
|
||||
.join(" ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expanded && hasFields && (
|
||||
<div className="ml-1 mt-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-muted-foreground">
|
||||
{fieldEntries.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<span className="text-muted-foreground/70">{k}</span>
|
||||
<span className="break-all text-foreground/85">{v}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRows({
|
||||
first,
|
||||
rest,
|
||||
expanded,
|
||||
onToggle,
|
||||
expandedFields,
|
||||
onToggleFields,
|
||||
search,
|
||||
}: {
|
||||
first: ParsedLogLine;
|
||||
rest: ParsedLogLine[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedFields: Set<number>;
|
||||
onToggleFields: (id: number) => void;
|
||||
search: string;
|
||||
}) {
|
||||
// Folded: show the first occurrence so the user still sees a sample
|
||||
// (timestamp, level, message), then a click-to-expand placeholder for
|
||||
// the suppressed run. The placeholder uses a dashed border + italics
|
||||
// so the eye reads it as "not a real line".
|
||||
if (!expanded) {
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 bg-muted/30 px-2 py-0.5 text-[11px] italic text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>
|
||||
{rest.length} more “{truncateValue(first.message, 48)}
|
||||
” — click to expand
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Unfolded: render every line, then a small "collapse" affordance at
|
||||
// the end so the user can put the toothpaste back in the tube.
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
{rest.map((l) => (
|
||||
<LogLineRow
|
||||
key={l.id}
|
||||
line={l}
|
||||
expanded={expandedFields.has(l.id)}
|
||||
onToggle={() => onToggleFields(l.id)}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 px-2 py-0.5 text-[11px] italic text-muted-foreground/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>collapse {rest.length + 1} repeated</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
hasLogs,
|
||||
hasFilter,
|
||||
isRunning,
|
||||
}: {
|
||||
hasLogs: boolean;
|
||||
hasFilter: boolean;
|
||||
isRunning: boolean;
|
||||
}) {
|
||||
let title: string;
|
||||
let subtitle: string;
|
||||
if (hasFilter) {
|
||||
title = "No matching log lines";
|
||||
subtitle = "Try a different search or level toggle.";
|
||||
} else if (!isRunning) {
|
||||
title = "Daemon isn't running";
|
||||
subtitle = "Start the daemon to see logs here.";
|
||||
} else if (!hasLogs) {
|
||||
title = "Waiting for logs…";
|
||||
subtitle = "New entries will appear in real time.";
|
||||
} else {
|
||||
title = "";
|
||||
subtitle = "";
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 text-center text-muted-foreground/70">
|
||||
<p className="text-sm">{title}</p>
|
||||
<p className="text-xs text-muted-foreground/50">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
function truncateValue(value: string, max = 32): string {
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
function highlight(text: string, query: string): ReactNode {
|
||||
if (!query) return text;
|
||||
const q = query.toLowerCase();
|
||||
const lower = text.toLowerCase();
|
||||
const idx = lower.indexOf(q);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="rounded bg-warning/30 px-0.5 text-foreground">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,94 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
*/
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
runtimes
|
||||
.filter((r) => r.daemon_id === status.daemonId)
|
||||
.map((r) => r.id),
|
||||
);
|
||||
}, [runtimes, status.daemonId]);
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
(t) =>
|
||||
localRuntimeIds.has(t.runtime_id) &&
|
||||
(t.status === "running" || t.status === "dispatched"),
|
||||
),
|
||||
[snapshot, localRuntimeIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then((s) => setStatus(s));
|
||||
@@ -36,7 +108,10 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
if (!result.success) {
|
||||
@@ -44,112 +119,214 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
} else {
|
||||
setConfirmStop(true);
|
||||
}
|
||||
}, [affectedTasks.length, performStop]);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRetryInstall = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const isRunning = status.state === "running";
|
||||
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
|
||||
const isStopped = status.state === "stopped";
|
||||
const isCliMissing = status.state === "cli_not_found";
|
||||
const isTransitioning =
|
||||
status.state === "starting" || status.state === "stopping";
|
||||
const isInstalling = status.state === "installing_cli";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setPanelOpen(true);
|
||||
}
|
||||
}}
|
||||
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Local Daemon</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
|
||||
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
|
||||
{isRunning && status.uptime && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
|
||||
</>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
{isRunning && status.agents && status.agents.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
|
||||
</>
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 shrink-0"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading || status.state === "cli_not_found"}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRestart}
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isTransitioning && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
onOpenChange={setPanelOpen}
|
||||
status={status}
|
||||
runtimeCount={runtimeCount}
|
||||
/>
|
||||
|
||||
<StopConfirmDialog
|
||||
open={confirmStop}
|
||||
onOpenChange={setConfirmStop}
|
||||
affectedCount={affectedTasks.length}
|
||||
onConfirm={() => {
|
||||
setConfirmStop(false);
|
||||
void performStop();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function StopConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
affectedCount,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
affectedCount: number;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const plural = affectedCount === 1 ? "" : "s";
|
||||
const verb = affectedCount === 1 ? "is" : "are";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
Stop daemon with {affectedCount} active task{plural}?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed">
|
||||
{affectedCount} task{plural} {verb} currently running on this
|
||||
device. Stopping now will interrupt {affectedCount === 1 ? "it" : "them"}{" "}
|
||||
— affected tasks get marked <strong>failed</strong> once the
|
||||
timeout hits. The daemon won't auto-restart.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Stop daemon
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import type { DaemonPrefs } from "../../../shared/daemon-types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
@@ -10,7 +16,7 @@ function SettingRow({
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
@@ -23,14 +29,44 @@ function SettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
// One row inside the diagnostics block. Values that are likely to be
|
||||
// long IDs / URLs render as monospaced + truncated with a tooltip.
|
||||
function DiagnosticsRow({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-baseline gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm",
|
||||
mono && "font-mono text-xs",
|
||||
)}
|
||||
title={typeof value === "string" ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DaemonSettingsTab() {
|
||||
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
|
||||
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
window.daemonAPI.isCliInstalled().then(setCliInstalled);
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
@@ -98,6 +134,68 @@ export function DaemonSettingsTab() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostics — moved out of the logs panel so the panel can focus
|
||||
on logs. These fields matter for support tickets and bug reports,
|
||||
not for everyday use. */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-semibold">Diagnostics</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Identification and connection details. Useful when filing a bug
|
||||
report or investigating why a runtime isn't showing up.
|
||||
</p>
|
||||
<div className="mt-3 rounded-lg border bg-muted/20 px-4 py-2">
|
||||
<DiagnosticsRow
|
||||
label="State"
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Uptime"
|
||||
value={status.uptime ? formatUptime(status.uptime) : "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="PID"
|
||||
value={status.pid ?? "—"}
|
||||
mono={!!status.pid}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Daemon ID"
|
||||
value={status.daemonId ?? "—"}
|
||||
mono={!!status.daemonId}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Profile"
|
||||
value={status.profile || "default"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Server URL"
|
||||
value={status.serverUrl ?? "—"}
|
||||
mono={!!status.serverUrl}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Device name"
|
||||
value={status.deviceName ?? "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Workspaces"
|
||||
value={
|
||||
typeof status.workspaceCount === "number"
|
||||
? status.workspaceCount
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { useDesktopUnreadBadge } from "@multica/views/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
@@ -96,6 +98,38 @@ function useInternalLinkHandler() {
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge between the renderer and the Electron main process for inbox-level
|
||||
* OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the
|
||||
* current workspace's id for the badge hook.
|
||||
*
|
||||
* Two responsibilities:
|
||||
* 1. Mirror the unread inbox count onto the dock/taskbar badge.
|
||||
* 2. When the user clicks an OS notification, open the notified
|
||||
* workspace's inbox focused on that item. The route uses the `slug`
|
||||
* that the notification was *emitted* with — not the currently active
|
||||
* workspace — so a notification from workspace A always opens A's
|
||||
* inbox even if the user has since switched to workspace B. Marking
|
||||
* the row read is handled by InboxPage's selected-item effect, which
|
||||
* covers both click-to-select and URL-param-select paths.
|
||||
*/
|
||||
function DesktopInboxBridge() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
useDesktopUnreadBadge(workspace?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
|
||||
if (!slug) return;
|
||||
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
@@ -117,15 +151,18 @@ export function DesktopShell() {
|
||||
users see the window-level overlay (new-workspace flow)
|
||||
triggered by IndexRedirect, not a route. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<DesktopInboxBridge />
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling */}
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLogLine } from "./parse-daemon-log";
|
||||
|
||||
// All sample lines below are taken verbatim from real daemon output (Go
|
||||
// `slog` + `lmittmann/tint` v1.1.3 with NoColor=true). The parser must
|
||||
// stay aligned with what tint actually writes — not what we assume.
|
||||
|
||||
describe("parseLogLine", () => {
|
||||
it("parses tint's 3-letter INF level", () => {
|
||||
const line =
|
||||
"17:52:35.587 INF task completed component=daemon task=c45266e5 status=completed";
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.timestamp).toBe("17:52:35.587");
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("task completed");
|
||||
expect(r.fields).toEqual({
|
||||
component: "daemon",
|
||||
task: "c45266e5",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 3-letter DBG / WRN / ERR levels", () => {
|
||||
expect(parseLogLine("17:53:06.644 DBG agent component=daemon", 1).level).toBe("DEBUG");
|
||||
expect(parseLogLine("07:48:09.391 WRN claim task failed component=daemon", 1).level).toBe("WARN");
|
||||
expect(parseLogLine("12:00:00.000 ERR something bad component=daemon", 1).level).toBe("ERROR");
|
||||
});
|
||||
|
||||
it("still accepts 4-letter level names (defensive against config changes)", () => {
|
||||
const r = parseLogLine("12:00:00.000 INFO regular component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("regular");
|
||||
});
|
||||
|
||||
it("tolerates the +N / -N delta tint appends for non-standard slog levels", () => {
|
||||
// tint emits e.g. "INF+1" when slog.Log is called with LevelInfo+1.
|
||||
// We treat the base level as canonical and drop the delta from the UI.
|
||||
const r = parseLogLine("12:00:00.000 INF+1 unusual delta component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("unusual delta");
|
||||
});
|
||||
|
||||
it("preserves message text containing colons and special chars", () => {
|
||||
// Real sample: "tool #1: Skill component=daemon task=..."
|
||||
const r = parseLogLine(
|
||||
"17:52:54.578 INF tool #1: Skill component=daemon task=8791b717",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("tool #1: Skill");
|
||||
expect(r.fields).toEqual({ component: "daemon", task: "8791b717" });
|
||||
});
|
||||
|
||||
it("unquotes a double-quoted value containing escaped quotes", () => {
|
||||
// Real sample with escaped quotes inside the agent's emitted text.
|
||||
const line =
|
||||
'17:53:06.644 DBG agent component=daemon task=8791b717 text="The issue is just \\"ping\\" with no description."';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent");
|
||||
expect(r.fields.text).toBe('The issue is just "ping" with no description.');
|
||||
expect(r.fields.task).toBe("8791b717");
|
||||
});
|
||||
|
||||
it("handles a quoted value containing a URL with embedded escaped quotes and a colon", () => {
|
||||
// Real sample: error="Post \"http://...\": dial tcp ..."
|
||||
const line =
|
||||
'07:48:09.391 WRN claim task failed component=daemon runtime_id=03f8ff17-276d error="Post \\"http://localhost:8080/api/daemon/runtimes/abc/tasks/claim\\": dial tcp [::1]:8080: connect: connection refused"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.level).toBe("WARN");
|
||||
expect(r.message).toBe("claim task failed");
|
||||
expect(r.fields.runtime_id).toBe("03f8ff17-276d");
|
||||
expect(r.fields.error).toBe(
|
||||
'Post "http://localhost:8080/api/daemon/runtimes/abc/tasks/claim": dial tcp [::1]:8080: connect: connection refused',
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a quoted value with internal whitespace (e.g. args array)", () => {
|
||||
const line =
|
||||
'17:52:48.757 INF agent command component=daemon exec=claude args="[-p --output-format stream-json --verbose]"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent command");
|
||||
expect(r.fields.exec).toBe("claude");
|
||||
expect(r.fields.args).toBe("[-p --output-format stream-json --verbose]");
|
||||
});
|
||||
|
||||
it("handles message words ending with characters before the field block", () => {
|
||||
// 'execenv:' is part of the message — the colon shouldn't confuse parsing.
|
||||
const r = parseLogLine(
|
||||
"17:52:48.757 INF execenv: prepared env component=daemon repos_available=0",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("execenv: prepared env");
|
||||
expect(r.fields).toEqual({ component: "daemon", repos_available: "0" });
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for non-matching lines (panic stack frame)", () => {
|
||||
const r = parseLogLine("\tat github.com/multica/foo (line 42)", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.message).toBe("\tat github.com/multica/foo (line 42)");
|
||||
expect(r.fields).toEqual({});
|
||||
expect(r.raw).toBe("\tat github.com/multica/foo (line 42)");
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for unrecognised level tokens", () => {
|
||||
// If tint ever emits something we don't know, never crash; show raw.
|
||||
const r = parseLogLine("12:00:00.000 TRACE something exotic", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.raw).toBe("12:00:00.000 TRACE something exotic");
|
||||
});
|
||||
|
||||
it("attaches an id to every parsed line for stable React keys", () => {
|
||||
const a = parseLogLine("17:52:35.587 INF first component=daemon", 7);
|
||||
const b = parseLogLine("17:52:35.588 INF second component=daemon", 8);
|
||||
expect(a.id).toBe(7);
|
||||
expect(b.id).toBe(8);
|
||||
});
|
||||
|
||||
it("returns empty fields object when there are no key=value pairs", () => {
|
||||
const r = parseLogLine("17:52:35.587 INF a bare message with no fields", 1);
|
||||
expect(r.message).toBe("a bare message with no fields");
|
||||
expect(r.fields).toEqual({});
|
||||
});
|
||||
});
|
||||
96
apps/desktop/src/renderer/src/components/parse-daemon-log.ts
Normal file
96
apps/desktop/src/renderer/src/components/parse-daemon-log.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Pure parser for daemon log lines. The daemon writes via Go's slog with
|
||||
// the `tint` handler in NoColor mode (the file isn't a TTY), so each line
|
||||
// has a stable shape:
|
||||
//
|
||||
// HH:MM:SS.mmm LEVEL message text key=value key2="quoted value"
|
||||
//
|
||||
// We split it into structured pieces so the UI can render timestamp,
|
||||
// level, message and structured fields in separate columns and let users
|
||||
// filter / search across them. Anything that doesn't match (panic stack
|
||||
// traces, third-party prints, partial writes during log rotation) falls
|
||||
// back to a raw view — we never drop input.
|
||||
|
||||
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface ParsedLogLine {
|
||||
/** Monotonic id assigned at receive time; stable across re-renders. */
|
||||
id: number;
|
||||
/** "HH:MM:SS.mmm" or null when the line didn't match the standard shape. */
|
||||
timestamp: string | null;
|
||||
level: LogLevel | null;
|
||||
/** Human-readable message body, with structured fields stripped off. */
|
||||
message: string;
|
||||
/** key/value pairs trailing the message. Empty if there were none. */
|
||||
fields: Record<string, string>;
|
||||
/** The original line, kept for fallback rendering and copy-to-clipboard. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
// `tint` v1.x emits the 3-letter short form (DBG / INF / WRN / ERR) and,
|
||||
// for non-standard slog levels, appends a signed delta (e.g. "INF+1",
|
||||
// "DBG-2"). We accept both the short and 4-letter long forms (defensive
|
||||
// against future config changes) and normalize them to a canonical
|
||||
// 4-letter LogLevel. The optional `[+-]\d+` suffix is captured into the
|
||||
// regex and discarded — surfacing `INF+1` to the UI doesn't help users
|
||||
// and complicates the level filter chips.
|
||||
const HEADER_RE =
|
||||
/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+(DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR)(?:[+-]\d+)?\s+(.+)$/;
|
||||
|
||||
const LEVEL_NORMALIZE: Record<string, LogLevel> = {
|
||||
DEBUG: "DEBUG",
|
||||
DBG: "DEBUG",
|
||||
INFO: "INFO",
|
||||
INF: "INFO",
|
||||
WARN: "WARN",
|
||||
WRN: "WARN",
|
||||
ERROR: "ERROR",
|
||||
ERR: "ERROR",
|
||||
};
|
||||
// Anchored to the END of the remaining string so we peel one field at a
|
||||
// time from the right. `value` is either a double-quoted string (which may
|
||||
// contain escaped chars) or any non-whitespace run.
|
||||
const TRAILING_FIELD_RE = /\s+([a-zA-Z_][a-zA-Z0-9_.]*)=("(?:[^"\\]|\\.)*"|\S+)$/;
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
||||
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function extractTrailingFields(rest: string): {
|
||||
message: string;
|
||||
fields: Record<string, string>;
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
let work = rest;
|
||||
while (true) {
|
||||
const match = work.match(TRAILING_FIELD_RE);
|
||||
if (!match || match.index === undefined) break;
|
||||
fields[match[1]!] = unquote(match[2]!);
|
||||
work = work.slice(0, match.index);
|
||||
}
|
||||
return { message: work.trim(), fields };
|
||||
}
|
||||
|
||||
export function parseLogLine(raw: string, id: number): ParsedLogLine {
|
||||
const match = raw.match(HEADER_RE);
|
||||
if (!match) {
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const [, timestamp, level, rest] = match;
|
||||
const normalized = LEVEL_NORMALIZE[level!];
|
||||
if (!normalized) {
|
||||
// Unknown level token — keep raw shape so we don't mis-categorize.
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const { message, fields } = extractTrailingFields(rest!);
|
||||
return {
|
||||
id,
|
||||
timestamp: timestamp!,
|
||||
level: normalized,
|
||||
message,
|
||||
fields,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
@@ -40,7 +39,6 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "up-to-date"; currentVersion: string }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "available"; latestVersion: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
const currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
@@ -22,7 +23,7 @@ export function UpdatesSettingsTab() {
|
||||
setState(
|
||||
result.available
|
||||
? { status: "available", latestVersion: result.latestVersion }
|
||||
: { status: "up-to-date", currentVersion: result.currentVersion },
|
||||
: { status: "up-to-date" },
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -35,6 +36,15 @@ export function UpdatesSettingsTab() {
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Current version</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
|
||||
v{currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
@@ -45,7 +55,7 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
You're on the latest version (v{state.currentVersion}).
|
||||
You're on the latest version.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
@@ -82,6 +83,7 @@ export function WorkspaceRouteLayout() {
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<WorkspacePresencePrefetch />
|
||||
<Outlet />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
|
||||
18
apps/desktop/src/renderer/src/pages/agent-detail-page.tsx
Normal file
18
apps/desktop/src/renderer/src/pages/agent-detail-page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AgentDetailPage as SharedAgentDetailPage } from "@multica/views/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function AgentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const agent = agents.find((a) => a.id === id) ?? null;
|
||||
|
||||
useDocumentTitle(agent?.name ?? "Agent");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedAgentDetailPage agentId={id} />;
|
||||
}
|
||||
18
apps/desktop/src/renderer/src/pages/runtime-detail-page.tsx
Normal file
18
apps/desktop/src/renderer/src/pages/runtime-detail-page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RuntimeDetailPage as SharedRuntimeDetailPage } from "@multica/views/runtimes";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function RuntimeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
|
||||
const runtime = runtimes?.find((r) => r.id === id);
|
||||
|
||||
useDocumentTitle(runtime?.name ?? "Runtime");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedRuntimeDetailPage runtimeId={id} />;
|
||||
}
|
||||
17
apps/desktop/src/renderer/src/pages/skill-detail-page.tsx
Normal file
17
apps/desktop/src/renderer/src/pages/skill-detail-page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SkillDetailPage as SharedSkillDetailPage } from "@multica/views/skills";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { skillDetailOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function SkillDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: skill } = useQuery(skillDetailOptions(wsId, id ?? ""));
|
||||
|
||||
useDocumentTitle(skill?.name ?? "Skill");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedSkillDetailPage skillId={id} />;
|
||||
}
|
||||
76
apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts
Normal file
76
apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { runtimeKeys } from "@multica/core/runtimes";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
|
||||
/**
|
||||
* DesktopAPI exposes a richer DaemonStatus shape than the public AgentRuntime
|
||||
* type — we redeclare the fields we consume here to avoid coupling the bridge
|
||||
* to the desktop preload typings (which live in apps/desktop/src/preload).
|
||||
*/
|
||||
interface DaemonStatusLike {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
daemonId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a local DaemonStatus into an AgentRuntime row. Only the `status`
|
||||
* field is overridden; other fields (name, provider, last_seen_at, etc)
|
||||
* remain server-authoritative. We deliberately ignore intermediate states
|
||||
* (starting / stopping / installing_cli / cli_not_found) so the cache
|
||||
* doesn't flap during boot — if the daemon is in such a state, the runtime
|
||||
* is effectively offline anyway, and the server-side sweeper will mark it
|
||||
* within 75s.
|
||||
*/
|
||||
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
|
||||
if (status.state === "stopped" || status.state === "stopping") {
|
||||
return { ...rt, status: "offline" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return {
|
||||
...rt,
|
||||
status: "online",
|
||||
last_seen_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to local daemon status changes via Electron IPC and writes them
|
||||
* into the runtimes Query cache for the active workspace.
|
||||
*
|
||||
* Why: the server-side runtime sweeper takes up to 75s to flip a runtime to
|
||||
* offline (heartbeat timeout 45s + sweep interval 30s). On the desktop app
|
||||
* we know about local daemon state instantly via IPC, so we use it to
|
||||
* pre-populate the cache and give users a sub-second feedback loop. Web and
|
||||
* "looking at someone else's daemon" still go through the server path.
|
||||
*
|
||||
* Same-daemon-multiple-runtimes: a single daemon can back several runtimes
|
||||
* in the same workspace (one per provider). We map across all matches so
|
||||
* every related runtime row sees the same status flip.
|
||||
*/
|
||||
export function useDaemonIPCBridge(wsId: string | undefined): void {
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsId) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const daemonAPI = (window as unknown as { daemonAPI?: { onStatusChange?: (cb: (s: DaemonStatusLike) => void) => () => void } }).daemonAPI;
|
||||
if (!daemonAPI?.onStatusChange) return;
|
||||
|
||||
const unsubscribe = daemonAPI.onStatusChange((status) => {
|
||||
if (!status.daemonId) return;
|
||||
qc.setQueryData<AgentRuntime[]>(runtimeKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((rt) =>
|
||||
rt.daemon_id === status.daemonId ? mergeDaemonStatus(rt, status) : rt,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [wsId, qc]);
|
||||
}
|
||||
@@ -115,10 +115,10 @@ export function DesktopNavigationProvider({
|
||||
const { tabId: activeTabId } = useActiveTabIdentity();
|
||||
const router = useActiveTabRouter();
|
||||
// Mirror the active tab router's full location (pathname + search) so
|
||||
// shell-level consumers of useNavigation() can read URL search params.
|
||||
// Must stay in sync with TabNavigationProvider below; a partial shape
|
||||
// here (just pathname) silently broke focus-mode anchor resolution on
|
||||
// `/inbox?issue=…`.
|
||||
// shell-level consumers of useNavigation() — ChatWindow in particular —
|
||||
// can read URL search params. Must stay in sync with TabNavigationProvider
|
||||
// below; a partial shape here (just pathname) silently broke focus-mode
|
||||
// anchor resolution on `/inbox?issue=…`.
|
||||
const [location, setLocation] = useState<{ pathname: string; search: string }>(
|
||||
() => ({
|
||||
pathname: router?.state.location.pathname ?? "/",
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { RouteObject } from "react-router-dom";
|
||||
import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
@@ -17,7 +20,6 @@ import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { ChatPage } from "@multica/views/chat";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
@@ -117,10 +119,24 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <DesktopRuntimesPage />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{
|
||||
path: "runtimes/:id",
|
||||
element: <RuntimeDetailPage />,
|
||||
handle: { title: "Runtime" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{
|
||||
path: "skills/:id",
|
||||
element: <SkillDetailPage />,
|
||||
handle: { title: "Skill" },
|
||||
},
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{
|
||||
path: "agents/:id",
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{ path: "chat", element: <ChatPage />, handle: { title: "Chat" } },
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
|
||||
@@ -101,7 +101,6 @@ interface TabStore {
|
||||
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
inbox: "Inbox",
|
||||
chat: "MessageSquare",
|
||||
"my-issues": "CircleUser",
|
||||
issues: "ListTodo",
|
||||
projects: "FolderKanban",
|
||||
|
||||
@@ -51,3 +51,35 @@ export function formatUptime(uptime?: string): string {
|
||||
const m = match[2] ? `${match[2]}m` : "";
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing description for the local daemon's current state. Replaces the
|
||||
* raw state label ("Running" / "Stopped") with a sentence that answers
|
||||
* "what does this mean for me?" — i.e. whether tasks can run on this device.
|
||||
*
|
||||
* `runtimeCount` is the number of runtimes the local daemon has registered
|
||||
* (claude / codex / gemini / ... — one per detected CLI). It's only consulted
|
||||
* when state === "running".
|
||||
*/
|
||||
export function daemonStateDescription(state: DaemonState, runtimeCount: number): string {
|
||||
switch (state) {
|
||||
case "running":
|
||||
if (runtimeCount === 0) {
|
||||
return "Running, but no runtimes have registered yet.";
|
||||
}
|
||||
if (runtimeCount === 1) {
|
||||
return "Running here · 1 runtime available for tasks.";
|
||||
}
|
||||
return `Running here · ${runtimeCount} runtimes available for tasks.`;
|
||||
case "stopped":
|
||||
return "Not running · this device can't take new tasks.";
|
||||
case "starting":
|
||||
return "Starting up the local daemon…";
|
||||
case "stopping":
|
||||
return "Shutting down the local daemon…";
|
||||
case "installing_cli":
|
||||
return "Setting up the runtime for the first time. Only happens once.";
|
||||
case "cli_not_found":
|
||||
return "Setup failed · couldn't download the runtime. Check your network.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ The form has only two required fields: **name** (unique within the workspace) an
|
||||
|
||||
## Pick an AI coding tool
|
||||
|
||||
Each runtime is backed by a specific AI coding tool. Multica supports 10 of them. The most common choices:
|
||||
Each runtime is backed by a specific AI coding tool. Multica supports 11 of them. The most common choices:
|
||||
|
||||
| Tool | Good for |
|
||||
|---|---|
|
||||
@@ -31,7 +31,7 @@ Each runtime is backed by a specific AI coding tool. Multica supports 10 of them
|
||||
| **Copilot** | Teams leveraging their GitHub account entitlements |
|
||||
| **Gemini** | Users in the Google ecosystem |
|
||||
|
||||
The other five (Hermes, Kimi, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
|
||||
The other six (Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
|
||||
|
||||
## Writing system instructions
|
||||
|
||||
@@ -123,5 +123,5 @@ Archived agents can't be assigned new tasks.
|
||||
## Next steps
|
||||
|
||||
- [Skills](/skills) — attach knowledge packs to an agent
|
||||
- [AI coding tools comparison](/providers) — full capability matrix across all 10 tools
|
||||
- [AI coding tools comparison](/providers) — full capability matrix across all 11 tools
|
||||
- [Assigning issues to agents](/assigning-issues) — put your new agent to work
|
||||
|
||||
@@ -21,7 +21,7 @@ multica agent create
|
||||
|
||||
## 选一款 AI 编程工具
|
||||
|
||||
运行时背后是一款具体的 AI 编程工具。Multica 支持 10 款,最常用的几款:
|
||||
运行时背后是一款具体的 AI 编程工具。Multica 支持 11 款,最常用的几款:
|
||||
|
||||
| 工具 | 适合 |
|
||||
|---|---|
|
||||
@@ -31,7 +31,7 @@ multica agent create
|
||||
| **Copilot** | 用 GitHub 账号权益的团队 |
|
||||
| **Gemini** | Google 生态用户 |
|
||||
|
||||
另外 5 款(Hermes、Kimi、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
|
||||
另外 6 款(Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
|
||||
|
||||
## 写系统指令
|
||||
|
||||
@@ -123,5 +123,5 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
|
||||
## 下一步
|
||||
|
||||
- [Skills](/skills) —— 给智能体挂专业知识包
|
||||
- [AI 编程工具对照](/providers) —— 10 款工具的完整能力差别
|
||||
- [AI 编程工具对照](/providers) —— 11 款工具的完整能力差别
|
||||
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来
|
||||
|
||||
@@ -12,7 +12,7 @@ Assign an [issue](/issues) to an [agent](/agents) and it works as the **official
|
||||
| **Assign** | Hand an agent ownership | Changes assignee | Issue + all comments | Inherits from issue | ✓ |
|
||||
| [**@-mention**](/mentioning-agents) | Pull it in to take a look | No changes | Issue + trigger comment | Inherits from issue | ✓ |
|
||||
| [**Chat**](/chat) | One-to-one conversation outside any issue | No issue involved | Current conversation history | Fixed medium | ✓ |
|
||||
| [**Routines**](/routines) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by routine | ✗ |
|
||||
| [**Autopilots**](/autopilots) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by autopilot | ✗ |
|
||||
|
||||
"Auto retry" refers to retries after infrastructure failures (runtime offline, timeout). Business errors on the agent side (for example, the model reporting an error) are not retried. See [**Tasks**](/tasks) for details.
|
||||
|
||||
@@ -78,4 +78,4 @@ But **different agents can work on the same issue in parallel** — for example,
|
||||
|
||||
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Routines**](/routines) — let agents start work automatically on a schedule
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
| **分配** | 让智能体正式负责 | 改 assignee | issue + 全部 comments | 继承 issue | ✓ |
|
||||
| [**@ 提及**](/mentioning-agents) | 评论里让它看一眼 | 都不改 | issue + 触发评论 | 继承 issue | ✓ |
|
||||
| [**对话**](/chat) | 独立于 issue 的一对一聊天 | 不涉及 issue | 当前对话历史 | 固定中 | ✓ |
|
||||
| [**Routines**](/routines) | 定时 / 手动自动化 | 视模式 | 视模式 | routine 自定 | ✗ |
|
||||
| [**Autopilots**](/autopilots) | 定时 / 手动自动化 | 视模式 | 视模式 | autopilot 自定 | ✗ |
|
||||
|
||||
"自动重试"指基础设施故障(运行时离线、超时)导致的重试;智能体侧业务错误(比如模型自己报错)不会自动重试。详见 [**执行任务**](/tasks)。
|
||||
|
||||
@@ -78,4 +78,4 @@ multica issue assign MUL-42 --unassign
|
||||
|
||||
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Routines**](/routines) —— 让智能体定时自动开工
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Sign-in and signup configuration
|
||||
description: Configure email + verification code sign-in, Google OAuth, and signup allowlists. Avoid the 888888 trap.
|
||||
description: Configure email + verification code sign-in, Google OAuth, signup allowlists, and local test codes.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -27,17 +27,24 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
|
||||
|
||||
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
|
||||
## The 888888 trap
|
||||
## Fixed local testing codes
|
||||
|
||||
<Callout type="warning">
|
||||
**If `APP_ENV` is not set to `production`, anyone can sign in to any account with the code `888888`.**
|
||||
**Do not enable a fixed verification code on a publicly reachable instance.**
|
||||
|
||||
Multica has a development-only master code, `888888` — a backdoor so local development doesn't depend on Resend. The rule is straightforward: when `APP_ENV != "production"`, **any email** plus `888888` passes verification.
|
||||
The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
|
||||
|
||||
**Production deployments must set `APP_ENV=production`**. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, this value is already set to `production` by default; but if you deploy from source yourself, write your own Docker config, or redefine environment variables in Kubernetes — you must add `APP_ENV=production` yourself.
|
||||
Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
MULTICA_DEV_VERIFICATION_CODE=888888
|
||||
```
|
||||
|
||||
This shortcut is ignored when `APP_ENV=production`.
|
||||
</Callout>
|
||||
|
||||
To check whether your deployment has this trap: open the sign-in page, enter **any email** to request a code, then enter `888888`. If you get in, your `APP_ENV` is not set to `production`, and **the entire instance is wide open**.
|
||||
Production deployments should leave `MULTICA_DEV_VERIFICATION_CODE` empty and set `APP_ENV=production`. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, `APP_ENV` defaults to `production`.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: 登录与注册配置
|
||||
description: 配 Email 验证码登录 + Google OAuth + 注册白名单。避开最坑的 888888 陷阱。
|
||||
description: 配 Email 验证码登录、Google OAuth、注册白名单和本地测试验证码。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及自部署最容易踩的一个陷阱。
|
||||
Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及本地测试验证码怎么安全使用。
|
||||
|
||||
上面用到的环境变量的清单见 [环境变量](/environment-variables);token 怎么用、生命周期细节见 [认证与令牌](/auth-tokens)。
|
||||
|
||||
@@ -27,17 +27,24 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 888888 陷阱
|
||||
## 固定本地测试验证码
|
||||
|
||||
<Callout type="warning">
|
||||
**`APP_ENV` 不设为 `production`,任何人都能用验证码 `888888` 登录任何账号。**
|
||||
**不要在公网可访问实例上启用固定验证码。**
|
||||
|
||||
Multica 有一个开发用的主验证码(master code)`888888`——为了本地开发不依赖 Resend 而设的后门。判定逻辑很简单:`APP_ENV != "production"` 时,**任何邮箱**输 `888888` 都能通过。
|
||||
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
|
||||
|
||||
**生产部署必须设 `APP_ENV=production`**。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,这个值已经默认设为 `production`;但如果你自己从源码部署、自己写 Docker 配置、或者在 Kubernetes 里重新定义环境变量——一定要自己把 `APP_ENV=production` 加上。
|
||||
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
MULTICA_DEV_VERIFICATION_CODE=888888
|
||||
```
|
||||
|
||||
`APP_ENV=production` 时这个快捷码会被忽略。
|
||||
</Callout>
|
||||
|
||||
检查你的部署是否有这个陷阱:打开登录页,输入**任意邮箱**请求验证码,再在验证码栏输 `888888`。如果能登进去 = 你的 `APP_ENV` 没设成 `production`,**整个实例处于完全开放状态**。
|
||||
生产部署应保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,并设置 `APP_ENV=production`。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,`APP_ENV` 默认就是 `production`。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ In day-to-day use you'll only touch the first two directly. The **[daemon](/daem
|
||||
2. Enter the code; the server issues a JWT cookie (browser) or exchanges it for a PAT (CLI).
|
||||
|
||||
<Callout type="warning">
|
||||
**Self-hosting operators, take note**: if `APP_ENV` is not set to `production`, the verification code is always `888888` — anyone can sign in as anyone. See [Self-host auth configuration](/auth-setup).
|
||||
**Self-hosting operators, take note**: keep `MULTICA_DEV_VERIFICATION_CODE` empty on public deployments. If you enable a fixed local test code, anyone who can request a code can sign in with that value while `APP_ENV` is non-production. See [Self-host auth configuration](/auth-setup).
|
||||
</Callout>
|
||||
|
||||
### Google OAuth
|
||||
|
||||
@@ -38,7 +38,7 @@ Multica 有三种令牌,对应三种使用场景:浏览器 Web UI、命令
|
||||
2. 输入验证码,server 签发 JWT cookie(浏览器)或交换出 PAT(CLI)
|
||||
|
||||
<Callout type="warning">
|
||||
**自部署运维注意**:如果环境变量 `APP_ENV` 不是 `production`,验证码恒为 `888888`——任何人能登录任何账号。详见 [自部署的认证配置](/auth-setup)。
|
||||
**自部署运维注意**:公网部署时保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。如果启用固定本地测试验证码,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用该固定值登录。详见 [自部署的认证配置](/auth-setup)。
|
||||
</Callout>
|
||||
|
||||
### Google OAuth
|
||||
|
||||
85
apps/docs/content/docs/autopilots.mdx
Normal file
85
apps/docs/content/docs/autopilots.mdx
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Autopilots let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths ([assigning](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/chat), where you are the one kicking things off), the core difference with Autopilots is that they are **time-driven**.
|
||||
|
||||
## Configure an autopilot
|
||||
|
||||
Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
|
||||
- **Name** — display name
|
||||
- **Agent** — who the run is dispatched to
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
|
||||
A few examples:
|
||||
|
||||
- `0 9 * * 1-5`, `Asia/Shanghai` — 9 AM Beijing time on weekdays
|
||||
- `*/30 * * * *`, `UTC` — every 30 minutes
|
||||
- `0 3 * * *`, `UTC` — every day at 3 AM UTC
|
||||
|
||||
The Multica server scans for due triggers every **30 seconds** — **the actual fire time can lag by up to 30 seconds**, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
|
||||
|
||||
## Trigger once manually
|
||||
|
||||
To avoid waiting for cron while debugging an autopilot, trigger it manually:
|
||||
|
||||
- UI: click "Run now" on the autopilot detail page
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <autopilot-id>
|
||||
```
|
||||
|
||||
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed)
|
||||
|
||||
## What happens when an autopilot fails
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilot failures are not auto-retried and do not send inbox notifications.** A failure leaves a `failed` entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the autopilot is periodic, **the next cron fire will trigger a new run**, but the failed work is not automatically re-run.
|
||||
|
||||
If an autopilot is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
|
||||
</Callout>
|
||||
|
||||
Why no auto-retry: autopilots are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
|
||||
|
||||
## What's not yet available
|
||||
|
||||
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
|
||||
|
||||
## Next
|
||||
|
||||
- [**Assign issues to agents**](/assigning-issues) — a one-shot hand-off of an issue to an agent
|
||||
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
title: Routines
|
||||
title: Autopilots
|
||||
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Routines 让 [智能体](/agents) **按调度自动开工**——配好 cron 和时区,到点 Multica 自己派发 [`task`](/tasks),不需要你每次触发。适合定期巡检、周期性报告、凌晨跑的清理任务这类"standing order"场景。和前三种触发方式([分配](/assigning-issues) / [@ 提及](/mentioning-agents) / [对话](/chat) 都是你主动喊一声)相比,Routines 的核心差别是**时间驱动**。
|
||||
Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron 和时区,到点 Multica 自己派发 [`task`](/tasks),不需要你每次触发。适合定期巡检、周期性报告、凌晨跑的清理任务这类"standing order"场景。和前三种触发方式([分配](/assigning-issues) / [@ 提及](/mentioning-agents) / [对话](/chat) 都是你主动喊一声)相比,Autopilots 的核心差别是**时间驱动**。
|
||||
|
||||
## 配置一个 Routine
|
||||
## 配置一个 Autopilot
|
||||
|
||||
在工作区的 **Routines** 页新建一条 routine,要定下:
|
||||
在工作区的 **Autopilot** 页新建一条 autopilot,要定下:
|
||||
|
||||
- **名字** — 显示名
|
||||
- **执行智能体** — 到点派给谁
|
||||
@@ -20,10 +20,10 @@ Routines 让 [智能体](/agents) **按调度自动开工**——配好 cron 和
|
||||
|
||||
## 选择执行模式
|
||||
|
||||
Routine 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Routine 的运行历史里看到。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
<Callout type="warning">
|
||||
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
|
||||
@@ -31,7 +31,7 @@ Routine 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
每个 Routine 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
|
||||
几个例子:
|
||||
|
||||
@@ -43,20 +43,20 @@ Multica 服务器每 **30 秒**扫一次到期的触发器——**触发时刻
|
||||
|
||||
## 手动触发一次
|
||||
|
||||
调试 Routine 时不想等 cron,可以手动触发一次:
|
||||
调试 Autopilot 时不想等 cron,可以手动触发一次:
|
||||
|
||||
- UI:在 Routine 详情页点"手动运行"
|
||||
- UI:在 Autopilot 详情页点"手动运行"
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <routine-id>
|
||||
multica autopilot trigger <autopilot-id>
|
||||
```
|
||||
|
||||
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
|
||||
|
||||
## 看运行历史
|
||||
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Routine 详情页的"历史"tab 看到:
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Autopilot 详情页的"历史"tab 看到:
|
||||
|
||||
- 触发源(`schedule` / `manual`)
|
||||
- 开始时间、完成时间
|
||||
@@ -64,29 +64,19 @@ multica autopilot trigger <routine-id>
|
||||
- 关联的 issue(先建 issue 模式)或 `task`(直跑模式)
|
||||
- 失败原因(如果失败)
|
||||
|
||||
## Routine 失败会怎样
|
||||
## Autopilot 失败会怎样
|
||||
|
||||
<Callout type="warning">
|
||||
**Routine 失败不自动重试,也不发 inbox 通知。** 失败后只在运行历史里留一条 `failed` 记录——不会像分配 / @ 提及那样由系统重新排队,也不会给任何人发通知。如果这条 Routine 是周期任务,**下一次 cron 到点会重新触发一次**(新的 run),但这一次失败的工作不会被自动补跑。
|
||||
**Autopilot 失败不自动重试,也不发 inbox 通知。** 失败后只在运行历史里留一条 `failed` 记录——不会像分配 / @ 提及那样由系统重新排队,也不会给任何人发通知。如果这条 Autopilot 是周期任务,**下一次 cron 到点会重新触发一次**(新的 run),但这一次失败的工作不会被自动补跑。
|
||||
|
||||
如果 Routine 很重要,要自己设计监控——例如让智能体在成功时给自己发个评论,通过缺失评论来发现失败。
|
||||
如果 Autopilot 很重要,要自己设计监控——例如让智能体在成功时给自己发个评论,通过缺失评论来发现失败。
|
||||
</Callout>
|
||||
|
||||
不自动重试的理由:Routine 本身是周期性的,系统层再加自动重试容易和下一次调度叠加,产生重复执行。调度权完全交给 cron 最干净。
|
||||
不自动重试的理由:Autopilot 本身是周期性的,系统层再加自动重试容易和下一次调度叠加,产生重复执行。调度权完全交给 cron 最干净。
|
||||
|
||||
## 两个遗留的命名 / 能力点
|
||||
## 暂不可用的能力
|
||||
|
||||
**CLI 里它叫 `autopilot`**。当前 CLI 子命令是 `multica autopilot` 而不是 `multica routine`:
|
||||
|
||||
```bash
|
||||
multica autopilot list
|
||||
multica autopilot create
|
||||
multica autopilot trigger <id>
|
||||
```
|
||||
|
||||
文档里一律用 Routines,后续版本 CLI 会统一。现在遇到 `autopilot` 字样把它当 Routines 看就行。
|
||||
|
||||
**Webhook 和 API 触发暂不可用**。Routine 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
|
||||
## 下一步
|
||||
|
||||
@@ -59,5 +59,5 @@ Conversations you no longer want to see can be archived — right-click in the c
|
||||
|
||||
## Next
|
||||
|
||||
- [**Routines**](/routines) — let agents start work automatically on a schedule
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
- [**Assign issues to agents**](/assigning-issues) — bring the topic back onto the issue board
|
||||
|
||||
@@ -59,5 +59,5 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**Routines**](/routines) —— 让智能体定时自动开工
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
- [**分配 issue 给智能体**](/assigning-issues) —— 把话题放回 issue 看板上
|
||||
|
||||
@@ -74,15 +74,13 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
## Routines (CLI command name: `autopilot`)
|
||||
|
||||
In the docs this feature is called **Routines**, but the CLI subcommand name is still `autopilot` — a future release will unify the two. If you're searching for "routines" and can't find it, try `multica autopilot --help`.
|
||||
## Autopilots
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica autopilot list` | List every routine in the workspace |
|
||||
| `multica autopilot get <id>` | Show a single routine |
|
||||
| `multica autopilot create ...` | Create a routine |
|
||||
| `multica autopilot list` | List every autopilot in the workspace |
|
||||
| `multica autopilot get <id>` | Show a single autopilot |
|
||||
| `multica autopilot create ...` | Create an autopilot |
|
||||
| `multica autopilot update <id> ...` | Update |
|
||||
| `multica autopilot delete <id>` | Delete |
|
||||
| `multica autopilot runs <id>` | Show run history |
|
||||
|
||||
@@ -74,15 +74,13 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
## Routines(CLI 命令名:`autopilot`)
|
||||
|
||||
文档里叫 **Routines**,但 CLI 子命令名保留为 `autopilot`——后续版本会统一。如果你在搜索 "routines" 相关命令但找不到,用 `multica autopilot --help`。
|
||||
## Autopilots
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica autopilot list` | 列出工作区所有 routine |
|
||||
| `multica autopilot get <id>` | 查看单个 routine |
|
||||
| `multica autopilot create ...` | 创建 routine |
|
||||
| `multica autopilot list` | 列出工作区所有 autopilot |
|
||||
| `multica autopilot get <id>` | 查看单个 autopilot |
|
||||
| `multica autopilot create ...` | 创建 autopilot |
|
||||
| `multica autopilot update <id> ...` | 修改 |
|
||||
| `multica autopilot delete <id>` | 删除 |
|
||||
| `multica autopilot runs <id>` | 查看运行历史 |
|
||||
|
||||
@@ -78,7 +78,7 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, `kiro`, or `pi`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, install at least one supported AI agent CLI:
|
||||
@@ -88,6 +88,8 @@ If the agents list is empty, install at least one supported AI agent CLI:
|
||||
- OpenCode (`opencode`)
|
||||
- OpenClaw (`openclaw`)
|
||||
- Hermes (`hermes`)
|
||||
- Kimi (`kimi`)
|
||||
- Kiro CLI (`kiro-cli`)
|
||||
|
||||
Then restart the daemon:
|
||||
|
||||
|
||||
@@ -92,6 +92,10 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
| Kimi | `kimi` | Moonshot coding agent |
|
||||
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
|
||||
| Pi | `pi` | Inflection coding agent |
|
||||
| Cursor Agent | `cursor-agent` | Cursor coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -134,6 +138,14 @@ Agent-specific overrides:
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor model used |
|
||||
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
|
||||
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
|
||||
@@ -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 ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [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 ([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.
|
||||
|
||||
## 1. Create an account
|
||||
|
||||
@@ -114,6 +114,6 @@ The web UI updates in **real time** (via WebSocket) — no refresh needed.
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean
|
||||
- [Tasks](/tasks) — task lifecycle and retry rules
|
||||
- [AI coding tools compared](/providers) — capability differences across the 10 tools
|
||||
- [AI coding tools compared](/providers) — capability differences across the 11 tools
|
||||
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
|
||||
- [Self-host quickstart](/self-host-quickstart) — run your own backend
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
|
||||
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([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. 注册账号
|
||||
|
||||
@@ -114,6 +114,6 @@ Web 界面会**实时**(通过 WebSocket)显示进度——不需要刷新
|
||||
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
|
||||
- [执行任务](/tasks) —— 任务生命周期、重试规则
|
||||
- [AI 编程工具对照](/providers) —— 10 款工具的能力差异
|
||||
- [AI 编程工具对照](/providers) —— 11 款工具的能力差异
|
||||
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
|
||||
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套
|
||||
|
||||
@@ -34,7 +34,7 @@ Mentioning the same person multiple times in one comment still produces **only o
|
||||
`@all` is a special target: it pushes a notification to every member of the workspace. Both people and agents can use `@all` — which means an agent reporting progress could also `@all`, so remind agents in their instructions to use it sparingly.
|
||||
|
||||
<Callout type="warning">
|
||||
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not routine updates.
|
||||
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not day-to-day updates.
|
||||
</Callout>
|
||||
|
||||
## Editing and deleting a comment
|
||||
|
||||
@@ -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` (10 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
2. Detects AI coding tools installed on your `PATH` (11 built-in: [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))
|
||||
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**
|
||||
|
||||
@@ -75,7 +75,7 @@ Multica uses heartbeats to decide whether a runtime is online. Three key numbers
|
||||
Missing is not permanent — as soon as the daemon sends another heartbeat it returns to online, and the runtime record is preserved. Restarting the daemon does not lose runtimes.
|
||||
|
||||
<Callout type="warning">
|
||||
**Tasks running on a missing runtime are marked as failed** (failure reason `runtime_offline`). For retryable sources (issues, chat), Multica automatically requeues them; Routines-triggered tasks are not retried automatically. See [Tasks → Which failures retry automatically](/tasks#which-failures-retry-automatically-which-dont).
|
||||
**Tasks running on a missing runtime are marked as failed** (failure reason `runtime_offline`). For retryable sources (issues, chat), Multica automatically requeues them; Autopilot-triggered tasks are not retried automatically. See [Tasks → Which failures retry automatically](/tasks#which-failures-retry-automatically-which-dont).
|
||||
</Callout>
|
||||
|
||||
## How many tasks can run in parallel
|
||||
@@ -108,4 +108,4 @@ More scenarios in [Troubleshooting](/troubleshooting).
|
||||
## Next
|
||||
|
||||
- [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up
|
||||
- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools
|
||||
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
启动后它会做四件事:
|
||||
|
||||
1. 读取你登录时保存的凭证
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 10 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 11 款:[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))
|
||||
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
|
||||
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
|
||||
|
||||
@@ -75,7 +75,7 @@ Multica 用心跳判断运行时是否在线。三个关键数字:
|
||||
失联不是永久的——守护进程只要再次发出心跳就立刻回到在线,运行时记录也会保留。重启守护进程不会丢运行时。
|
||||
|
||||
<Callout type="warning">
|
||||
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`)。对可重试的来源(issue、chat),Multica 会自动重新排队;Routines 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
|
||||
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`)。对可重试的来源(issue、chat),Multica 会自动重新排队;Autopilots 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
|
||||
</Callout>
|
||||
|
||||
## 一次能并发跑多少任务
|
||||
@@ -108,4 +108,4 @@ Multica 对并发有两层限额:
|
||||
## 下一步
|
||||
|
||||
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
|
||||
- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照
|
||||
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照
|
||||
|
||||
@@ -66,12 +66,25 @@ Grab the installer for your platform from the [Multica downloads page](https://m
|
||||
|
||||
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
|
||||
|
||||
<Callout type="info">
|
||||
**Which backend Desktop connects to** is determined by the address you select at sign-in. It defaults to Multica Cloud; if you're running self-hosted, click "Connect to a self-hosted instance" on the first login screen and fill in your server address.
|
||||
<Callout type="warning">
|
||||
**Released Desktop builds are pinned to Multica Cloud.** The backend, websocket, and web URLs are baked in at build time (`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`) — there is no in-app option to point Desktop at a self-hosted instance. To use Desktop against a self-hosted backend you need to build it yourself:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# Edit apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
```
|
||||
|
||||
If you'd rather not build from source, the supported self-hosted path is **web frontend + CLI** — see [Self-host quickstart](/self-host-quickstart). Runtime backend configuration in Desktop is tracked in [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — connecting Desktop to a self-hosted backend
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend (Desktop against self-host requires a custom build, see the callout above)
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)
|
||||
|
||||
@@ -66,12 +66,25 @@ macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的
|
||||
|
||||
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
|
||||
|
||||
<Callout type="info">
|
||||
**桌面版连哪个后端** 由登录时选的地址决定。默认连 Multica Cloud;如果你用自部署版本,在首次登录页点"连接到自部署实例"填你的 server 地址即可。
|
||||
<Callout type="warning">
|
||||
**发布版的 Desktop 是锁死连 Multica Cloud 的**。后端 / WebSocket / Web 前端 URL(`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`)在构建时就写死了,应用内**没有切换后端的入口**。要让 Desktop 连自部署后端,需要你自己从源码 build:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# 编辑 apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
```
|
||||
|
||||
不想自己 build 的话,自部署的官方路径是 **Web 前端 + CLI**——见 [自部署快速上手](/self-host-quickstart)。Desktop 运行时切换后端的能力跟踪在 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— Desktop 连自部署后端
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端(Desktop 连自部署需要自行构建,见上方提示)
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样)
|
||||
|
||||
@@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A self-hosted Multica [server](/self-host-quickstart) reads its configuration from environment variables at startup — database, sign-in, email, storage, signup allowlists all live here. This page groups every variable by purpose: each section spells out **what happens if you leave it unset** and **which ones you must set in production**. For how to actually configure the auth-related ones, see [Sign-in and signup configuration](/auth-setup).
|
||||
|
||||
## The five required at startup
|
||||
## Core server variables
|
||||
|
||||
These are the five you must think about before deploying — some have defaults that let the server start, but in production you should set all of them explicitly.
|
||||
These are the core variables you must think about before deploying — some have defaults that let the server start, but in production you should set the required ones explicitly.
|
||||
|
||||
| Variable | Default | Required in production? |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **Yes** |
|
||||
| `PORT` | `8080` | No (unless you change the port) |
|
||||
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **Yes** (the default is unsafe) |
|
||||
| `APP_ENV` | empty | **Yes** (must be `production` — see the next section for the trap) |
|
||||
| `APP_ENV` | empty | **Yes** (must be `production`) |
|
||||
| `FRONTEND_ORIGIN` | empty | **Yes** (self-host must set its own domain) |
|
||||
| `MULTICA_DEV_VERIFICATION_CODE` | empty | No (must stay empty in production) |
|
||||
|
||||
<Callout type="warning">
|
||||
**If `APP_ENV` is not set to `production`, anyone can sign in to any account using the code `888888`.** Multica has a development-only master code, `888888` — when `APP_ENV != "production"`, **any email** plus `888888` passes verification. The behavior is intentional for local development (no Resend dependency); **in production, failing to set `production` is equivalent to disabling auth entirely**. See [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap).
|
||||
**Keep `MULTICA_DEV_VERIFICATION_CODE` empty in production.** A fixed local test code is disabled by default, but if you opt in with `MULTICA_DEV_VERIFICATION_CODE=888888`, anyone who can request a code can sign in with that fixed value while `APP_ENV` is non-production. The shortcut is ignored when `APP_ENV=production`.
|
||||
</Callout>
|
||||
|
||||
### Database connection pool
|
||||
|
||||
@@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量读取配置——数据库、登录、邮件、存储、注册白名单都在这里配。这一页按用途分组给完整清单:每组说清楚**不设会怎样**、**生产必须设哪几个**。Auth 相关那几个怎么真正配见 [登录与注册配置](/auth-setup)。
|
||||
|
||||
## 启动必填的五个
|
||||
## 核心 server 环境变量
|
||||
|
||||
这五个是你部署前必须考虑的——有些有默认值能让 server 启动,但生产环境里你应该全部显式配。
|
||||
这些是你部署前必须考虑的核心变量——有些有默认值能让 server 启动,但生产环境里你应该显式配置必填项。
|
||||
|
||||
| 环境变量 | 默认值 | 生产必须设? |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **是** |
|
||||
| `PORT` | `8080` | 否(除非换端口)|
|
||||
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **是**(默认值不安全)|
|
||||
| `APP_ENV` | 空 | **是**(必须 `production`——见下一节陷阱)|
|
||||
| `APP_ENV` | 空 | **是**(必须 `production`)|
|
||||
| `FRONTEND_ORIGIN` | 空 | **是**(self-host 要填你自己的域名)|
|
||||
| `MULTICA_DEV_VERIFICATION_CODE` | 空 | 否(生产必须保持为空)|
|
||||
|
||||
<Callout type="warning">
|
||||
**`APP_ENV` 不设为 `production`,任何人都能用 `888888` 登录任何账号。** Multica 有一个开发用的主验证码(master code)`888888`——`APP_ENV != "production"` 时**任何邮箱**输 `888888` 都能通过。本地开发时故意留空方便调试;**生产环境一旦不设 `production`,等于 auth 完全失效**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。
|
||||
**生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。** 固定本地测试验证码默认关闭;如果你设置 `MULTICA_DEV_VERIFICATION_CODE=888888`,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用这个固定值登录。`APP_ENV=production` 时该快捷码会被忽略。
|
||||
</Callout>
|
||||
|
||||
### 数据库连接池
|
||||
|
||||
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
@@ -68,16 +68,16 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
|
||||
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
<Callout>
|
||||
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
**Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
</Callout>
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
@@ -408,14 +408,23 @@ NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
The backend exposes public health endpoints:
|
||||
|
||||
```
|
||||
```text
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
|
||||
GET /readyz
|
||||
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
|
||||
GET /healthz
|
||||
→ same response as /readyz
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
|
||||
dependency-aware readiness probes and external monitoring that should fail when
|
||||
the database is unavailable or migrations are not fully applied. `/healthz` is
|
||||
kept as an alias for operator familiarity.
|
||||
|
||||
## Upgrading
|
||||
|
||||
|
||||
@@ -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 ten (or several in parallel): [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [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 eleven (or several in parallel): [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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -39,7 +39,7 @@ It's not only "assign an issue" — Multica has 4 triggers, one per collaboratio
|
||||
| **Assign an issue** | The most common. Assign an issue to an agent and it starts on its own | [Assigning issues](/assigning-issues) |
|
||||
| **@mention an agent in a comment** | "Take a look at this one for me" — don't change the assignee or status, just fire off a comment | [Mentioning agents](/mentioning-agents) |
|
||||
| **Direct chat** | Standalone conversation, not tied to an issue — ask questions, have it draft an issue | [Chat](/chat) |
|
||||
| **Routines (scheduled)** | Standing instructions — "do a standup summary every Monday morning" and the like | [Routines](/routines) |
|
||||
| **Autopilots (scheduled)** | Standing instructions — "do a standup summary every Monday morning" and the like | [Autopilots](/autopilots) |
|
||||
|
||||
## Runtimes: where it runs, and how many tools
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
|
||||
|
||||
- **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub,把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。
|
||||
- **守护进程**(daemon)——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server,开始每 3 秒领一次任务、每 15 秒发一次心跳。
|
||||
- **AI 编程工具**——[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 十款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
- **AI 编程工具**——[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) 11 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
|
||||
工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用;Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。
|
||||
|
||||
@@ -39,7 +39,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
|
||||
| **分配 issue** | 最常见。把一条 issue 指派给智能体,它自动开工 | [分配 issue](/assigning-issues) |
|
||||
| **在评论里 @智能体** | "这条你帮我看一下"——不改 assignee、不改状态,用一条评论触发 | [在评论里 @智能体](/mentioning-agents) |
|
||||
| **直接聊天** | 独立对话,不绑 issue——问问题、让它帮起草任务 | [聊天](/chat) |
|
||||
| **Routines(定时)** | 长期指令——每周一早上做 standup 总结之类 | [Routines](/routines) |
|
||||
| **Autopilots(定时)** | 长期指令——每周一早上做 standup 总结之类 | [Autopilots](/autopilots) |
|
||||
|
||||
## 运行时:在哪里跑,跑几家工具
|
||||
|
||||
|
||||
@@ -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. Ten are built in today: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [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. Eleven are built in today: [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.
|
||||
|
||||
<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.
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在
|
||||
|
||||
智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式:
|
||||
|
||||
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置十种:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
|
||||
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 11 款:[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 密钥、工具链、代码目录都保留在本地。
|
||||
|
||||
<Callout type="info">
|
||||
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
|
||||
|
||||
@@ -54,5 +54,5 @@ This guard **only blocks direct self-references.** Agent A @-mentioning agent B
|
||||
## Next
|
||||
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Routines**](/routines) — let agents start work automatically on a schedule
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
- [**Comments**](/comments) — `@mention` syntax, the picker, and `@all` semantics
|
||||
|
||||
@@ -54,5 +54,5 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
## 下一步
|
||||
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Routines**](/routines) —— 让智能体定时自动开工
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
- [**评论**](/comments) —— `@mention` 的语法、picker、`@all` 的语义
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"assigning-issues",
|
||||
"mentioning-agents",
|
||||
"chat",
|
||||
"routines",
|
||||
"autopilots",
|
||||
"---Inbox---",
|
||||
"inbox",
|
||||
"---Self-hosting & ops---",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"assigning-issues",
|
||||
"mentioning-agents",
|
||||
"chat",
|
||||
"routines",
|
||||
"autopilots",
|
||||
"---收件箱---",
|
||||
"inbox",
|
||||
"---自部署运维---",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: AI coding tools matrix
|
||||
description: Multica supports 10 AI coding tools; they implement the same interface, but the capability details diverge significantly.
|
||||
description: Multica supports 11 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 **10 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 **11 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).
|
||||
|
||||
@@ -20,15 +20,16 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
| **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 | ✅ | ❌ | `.config/opencode/skills/` | Dynamic discovery |
|
||||
| **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/agent/skills/` | Dynamic discovery |
|
||||
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
|
||||
|
||||
## What each tool is for
|
||||
|
||||
### Claude Code
|
||||
|
||||
From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 10 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
|
||||
From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 11 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
|
||||
|
||||
### Codex
|
||||
|
||||
@@ -54,6 +55,10 @@ From Nous Research. Uses the ACP protocol (shares a transport with Kimi). Sessio
|
||||
|
||||
From Moonshot, aimed at the Chinese market. Shares the ACP protocol with Hermes, but the skill path `.kimi/skills/` is Kimi CLI's native discovery mechanism — different from Hermes's fallback.
|
||||
|
||||
### Kiro CLI
|
||||
|
||||
From Amazon. Uses ACP over stdio via `kiro-cli acp`. Session resumption works through ACP `session/load`, model selection works through `session/set_model`, and skills are copied into `.kiro/skills/` for native project-level discovery.
|
||||
|
||||
### OpenCode
|
||||
|
||||
From SST, open source. Dynamically discovers available models (scans the CLI's configuration file). Session resumption works. **Suitable for tinkerers who want to customize their model catalog.**
|
||||
@@ -72,7 +77,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
|
||||
|
||||
| Status | Tools | Meaning |
|
||||
|---|---|---|
|
||||
| ✅ Really works | Claude Code, Copilot, Hermes, Kimi, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ✅ Really works | Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** |
|
||||
| ❌ None | Gemini | The CLI has no resume mechanism |
|
||||
|
||||
@@ -80,7 +85,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
|
||||
|
||||
## MCP configuration: only Claude Code actually reads it
|
||||
|
||||
**Of the 10 tools, only Claude Code actually consumes `mcp_config`**. The other 9 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
|
||||
**Of the 11 tools, only Claude Code actually consumes `mcp_config`**. The other 10 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
|
||||
|
||||
<Callout type="warning">
|
||||
If you set `mcp_config` in an agent configuration but pick a tool other than Claude Code, your MCP servers have **no effect** on that agent. MCP integration currently covers Claude Code only.
|
||||
@@ -97,8 +102,9 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| Copilot | `.github/skills/` | ✅ Native |
|
||||
| Cursor | `.cursor/skills/` | ✅ Native |
|
||||
| Kimi | `.kimi/skills/` | ✅ Native |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ Native |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ Native |
|
||||
| Pi | `.pi/agent/skills/` | ✅ Native |
|
||||
| Pi | `.pi/skills/` | ✅ Native |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: AI 编程工具对照
|
||||
description: Multica 支持 10 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
|
||||
description: Multica 支持 11 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
|
||||
Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
|
||||
|
||||
创建智能体时挑选工具的指引见 [创建和配置智能体](/agents-create)。
|
||||
|
||||
@@ -20,15 +20,16 @@ Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静态 |
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback)| 动态发现 |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | 动态发现 |
|
||||
| **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 |
|
||||
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/agent/skills/` | 动态发现 |
|
||||
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
|
||||
|
||||
## 每款工具的定位
|
||||
|
||||
### Claude Code
|
||||
|
||||
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **10 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
|
||||
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **11 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
|
||||
|
||||
### Codex
|
||||
|
||||
@@ -54,6 +55,10 @@ Nous Research 出品。使用 ACP 协议(和 Kimi 共享传输层)。会话
|
||||
|
||||
Moonshot 出品,中国市场向。和 Hermes 共享 ACP 协议,但 skill 路径 `.kimi/skills/` 是 Kimi CLI 的原生发现机制——和 Hermes 的 fallback 不一样。
|
||||
|
||||
### Kiro CLI
|
||||
|
||||
Amazon 出品。通过 `kiro-cli acp` 使用 ACP stdio 协议。会话恢复走 ACP `session/load`,模型选择走 `session/set_model`,skill 会复制到 `.kiro/skills/` 让 Kiro 做项目级原生发现。
|
||||
|
||||
### OpenCode
|
||||
|
||||
SST 出品,开源。动态发现可用模型(扫 CLI 的配置文件)。会话恢复真用。**适合爱折腾、想自定义模型目录**的开发者。
|
||||
@@ -72,7 +77,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
|
||||
| 状态 | 工具 | 含义 |
|
||||
|---|---|---|
|
||||
| ✅ 真用 | Claude Code、Copilot、Hermes、Kimi、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ✅ 真用 | Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到(Codex 静默回落、Cursor session id 不回传)—— **当作不支持** |
|
||||
| ❌ 无 | Gemini | CLI 无 resume 机制 |
|
||||
|
||||
@@ -80,7 +85,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
|
||||
## MCP 配置:只有 Claude Code 真的读
|
||||
|
||||
**10 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 9 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
|
||||
**11 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 10 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
|
||||
|
||||
<Callout type="warning">
|
||||
如果你在智能体配置里设置了 `mcp_config`,但选了 Claude Code 之外的工具,你的 MCP server 对这个智能体**没有效果**。目前的 MCP 集成只覆盖 Claude Code。
|
||||
@@ -97,8 +102,9 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
| Copilot | `.github/skills/` | ✅ 原生 |
|
||||
| Cursor | `.cursor/skills/` | ✅ 原生 |
|
||||
| Kimi | `.kimi/skills/` | ✅ 原生 |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/agent/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/skills/` | ✅ 原生 |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
title: Routines
|
||||
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Routines let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths ([assigning](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/chat), where you are the one kicking things off), the core difference with Routines is that they are **time-driven**.
|
||||
|
||||
## Configure a routine
|
||||
|
||||
Create a new routine on the workspace's **Routines** page. You set:
|
||||
|
||||
- **Name** — display name
|
||||
- **Agent** — who the run is dispatched to
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
A routine has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the routine's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every routine needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
|
||||
A few examples:
|
||||
|
||||
- `0 9 * * 1-5`, `Asia/Shanghai` — 9 AM Beijing time on weekdays
|
||||
- `*/30 * * * *`, `UTC` — every 30 minutes
|
||||
- `0 3 * * *`, `UTC` — every day at 3 AM UTC
|
||||
|
||||
The Multica server scans for due triggers every **30 seconds** — **the actual fire time can lag by up to 30 seconds**, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
|
||||
|
||||
## Trigger once manually
|
||||
|
||||
To avoid waiting for cron while debugging a routine, trigger it manually:
|
||||
|
||||
- UI: click "Run now" on the routine detail page
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <routine-id>
|
||||
```
|
||||
|
||||
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the routine detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed)
|
||||
|
||||
## What happens when a routine fails
|
||||
|
||||
<Callout type="warning">
|
||||
**Routine failures are not auto-retried and do not send inbox notifications.** A failure leaves a `failed` entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the routine is periodic, **the next cron fire will trigger a new run**, but the failed work is not automatically re-run.
|
||||
|
||||
If a routine is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
|
||||
</Callout>
|
||||
|
||||
Why no auto-retry: routines are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
|
||||
|
||||
## Two naming / capability carryovers
|
||||
|
||||
**In the CLI it is called `autopilot`.** The current CLI subcommand is `multica autopilot` rather than `multica routine`:
|
||||
|
||||
```bash
|
||||
multica autopilot list
|
||||
multica autopilot create
|
||||
multica autopilot trigger <id>
|
||||
```
|
||||
|
||||
The docs use Routines throughout; a future CLI release will unify the naming. For now, treat any `autopilot` wording as Routines.
|
||||
|
||||
**Webhook and API triggers are not available yet.** The routine trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
|
||||
|
||||
## Next
|
||||
|
||||
- [**Assign issues to agents**](/assigning-issues) — a one-shot hand-off of an issue to an agent
|
||||
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
@@ -31,6 +31,9 @@ make selfhost
|
||||
3. Bring up every service using `docker-compose.selfhost.yml`
|
||||
4. Wait until the backend's `/health` endpoint is ready
|
||||
|
||||
For ongoing production probes after startup, use `/readyz` when you want the
|
||||
check to fail on database or migration problems.
|
||||
|
||||
The backend container **runs database migrations automatically** on startup (`docker/entrypoint.sh` runs `./migrate up` before the server starts) — you'll see the migration output in the backend logs. Version upgrades are handled the same way.
|
||||
|
||||
<Callout type="info">
|
||||
@@ -42,19 +45,19 @@ Once it's up:
|
||||
- **Frontend**: [http://localhost:3000](http://localhost:3000)
|
||||
- **Backend**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
## 2. Important: set `APP_ENV` to `production`
|
||||
## 2. Important: keep production safety on
|
||||
|
||||
<Callout type="warning">
|
||||
**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** — this prevents the development "master code `888888`" from being enabled on an instance you've exposed to the public internet.
|
||||
**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** and leaves `MULTICA_DEV_VERIFICATION_CODE` empty, so there is no fixed code on public instances.
|
||||
|
||||
**But if your `.env` leaves `APP_ENV` empty or sets it to another value**, `888888` is enabled — **anyone can log in as any email by typing `888888` as the verification code**. See [Auth setup → The 888888 trap](/auth-setup#the-888888-trap).
|
||||
Only set `MULTICA_DEV_VERIFICATION_CODE` for local or private test automation. If a fixed code is enabled while `APP_ENV` is non-production, anyone who can request a code can sign in with that fixed value. See [Auth setup → Fixed local testing codes](/auth-setup#fixed-local-testing-codes).
|
||||
|
||||
Before any public deployment, make sure `.env` has `APP_ENV=production`.
|
||||
Before any public deployment, make sure `.env` has `APP_ENV=production` and `MULTICA_DEV_VERIFICATION_CODE` is empty.
|
||||
</Callout>
|
||||
|
||||
## 3. Configure the email service (optional but recommended)
|
||||
|
||||
Without email configured, your users can't receive verification codes — **unless `APP_ENV != production`, in which case `888888` works** (see the warning above).
|
||||
Without email configured, your users can't receive verification codes by email; the server prints generated codes to stdout instead.
|
||||
|
||||
To actually send verification emails:
|
||||
|
||||
@@ -77,6 +80,7 @@ Open [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- Enter your email
|
||||
- Grab the verification code from the Resend email (or, if you haven't configured Resend, from the server container stdout — look for the `[DEV] Verification code` line)
|
||||
- Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance
|
||||
- Log in and create your first workspace
|
||||
|
||||
## 5. Point the CLI at your own server
|
||||
@@ -112,4 +116,4 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
|
||||
- [Environment variables](/environment-variables) — full env reference
|
||||
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
|
||||
- [Troubleshooting](/troubleshooting) — start here when things go wrong
|
||||
- [Desktop app](/desktop-app) — the desktop app can also connect to your self-hosted backend
|
||||
- [Desktop app](/desktop-app) — released Desktop builds connect to Multica Cloud only; using Desktop with self-host requires a custom build (see the callout in the desktop-app page)
|
||||
|
||||
@@ -31,6 +31,8 @@ make selfhost
|
||||
3. 用 `docker-compose.selfhost.yml` 启动全部服务
|
||||
4. 等后端 `/health` 端点准备就绪
|
||||
|
||||
如果是启动完成后的生产探针,想让数据库或 migration 异常也体现为失败,请改用 `/readyz`。
|
||||
|
||||
后端容器启动时会**自动跑数据库 migration**(`docker/entrypoint.sh` 在启动 server 前执行 `./migrate up`)——你会在 backend 日志里看到 migration 输出。升级版本时同样自动处理。
|
||||
|
||||
<Callout type="info">
|
||||
@@ -42,19 +44,19 @@ make selfhost
|
||||
- **前端**:[http://localhost:3000](http://localhost:3000)
|
||||
- **后端**:[http://localhost:8080](http://localhost:8080)
|
||||
|
||||
## 2. 重要:改 `APP_ENV` 成 `production`
|
||||
## 2. 重要:保持生产安全配置
|
||||
|
||||
<Callout type="warning">
|
||||
**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**——这防止开发用的"万能验证码 `888888`"在你公网暴露的实例上启用。
|
||||
**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**,并让 `MULTICA_DEV_VERIFICATION_CODE` 为空,所以公网实例默认没有固定验证码。
|
||||
|
||||
**但如果你的 `.env` 里把 `APP_ENV` 留空或改成其他值**,`888888` 会被启用——**任何人输入任何邮箱 + `888888` 都能登录**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。
|
||||
只在本地或私有测试自动化里设置 `MULTICA_DEV_VERIFICATION_CODE`。如果在 `APP_ENV` 非 production 时启用了固定验证码,任何能请求验证码的人都能用这个固定值登录。详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码)。
|
||||
|
||||
公网部署前一定检查 `.env` 里 `APP_ENV=production`。
|
||||
公网部署前一定检查 `.env` 里 `APP_ENV=production`,且 `MULTICA_DEV_VERIFICATION_CODE` 为空。
|
||||
</Callout>
|
||||
|
||||
## 3. 配置邮件服务(可选但推荐)
|
||||
|
||||
如果不配邮件,你的用户无法收到验证码——**但如果 `APP_ENV != production` 你可以用 `888888` 登录**(见上方警告)。
|
||||
如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。
|
||||
|
||||
要真的发验证码邮件:
|
||||
|
||||
@@ -77,6 +79,7 @@ make selfhost
|
||||
|
||||
- 输入你的邮箱
|
||||
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行)
|
||||
- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
|
||||
- 登录后创建第一个工作区
|
||||
|
||||
## 5. 连接命令行工具到你自己的 server
|
||||
@@ -112,4 +115,4 @@ multica setup self-host
|
||||
- [环境变量](/environment-variables) —— 完整 env 清单
|
||||
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
|
||||
- [故障排查](/troubleshooting) —— 遇到问题先来这里
|
||||
- [桌面应用](/desktop-app) —— 桌面应用也能连你的自部署后端
|
||||
- [桌面应用](/desktop-app) —— 发布版 Desktop 只连 Multica Cloud;要让 Desktop 连自部署后端需要自行构建(详见 desktop-app 页的提示)
|
||||
|
||||
@@ -64,4 +64,4 @@ By now you know what an agent is, how to create one, and how to attach skills. T
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — where agents actually run, and how to tell online from offline
|
||||
- [Executing tasks](/tasks) — the full lifecycle of one "agent work session"
|
||||
- [AI coding tools comparison](/providers) — full comparison of all 10 tools (including each one's skill injection path)
|
||||
- [AI coding tools comparison](/providers) — full comparison of all 11 tools (including each one's skill injection path)
|
||||
|
||||
@@ -64,4 +64,4 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
|
||||
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 智能体到底跑在哪、怎么判断在线 / 离线
|
||||
- [执行任务](/tasks) —— 一次"智能体工作"的完整生命周期
|
||||
- [AI 编程工具对照](/providers) —— 10 款工具的完整对比(含每款的 Skill 注入路径)
|
||||
- [AI 编程工具对照](/providers) —— 11 款工具的完整对比(含每款的 Skill 注入路径)
|
||||
|
||||
@@ -6,7 +6,7 @@ description: The unit of work for every agent run, with a clear state machine, t
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
A **task** is the unit of every [agent](/agents) run — [assigning an issue to an agent](/assigning-issues), [@-mentioning an agent in a comment](/mentioning-agents), sending a message in [chat](/chat), or a [Routine](/routines) firing on schedule all produce a task. Multica puts it in a queue; a [daemon](/daemon-runtimes) picks it up and hands it off to the corresponding [AI coding tool](/providers), then writes the result back to the server when it finishes.
|
||||
A **task** is the unit of every [agent](/agents) run — [assigning an issue to an agent](/assigning-issues), [@-mentioning an agent in a comment](/mentioning-agents), sending a message in [chat](/chat), or an [Autopilot](/autopilots) firing on schedule all produce a task. Multica puts it in a queue; a [daemon](/daemon-runtimes) picks it up and hands it off to the corresponding [AI coding tool](/providers), then writes the result back to the server when it finishes.
|
||||
|
||||
Tasks and [issues](/issues) are two different objects. A single issue can be assigned, @-mentioned, and manually rerun many times — each produces a **new** task.
|
||||
|
||||
@@ -59,10 +59,10 @@ Failures fall into two categories: **retryable** and **non-retryable**.
|
||||
Automatic retry also has two extra conditions:
|
||||
|
||||
1. **At most 2 attempts** — 1 original + 1 retry. If the retry also fails, no further retries, even if the reason is retryable.
|
||||
2. **Only for issue- and chat-triggered tasks** — Routine-triggered tasks do **not** retry automatically.
|
||||
2. **Only for issue- and chat-triggered tasks** — Autopilot-triggered tasks do **not** retry automatically.
|
||||
|
||||
<Callout type="warning">
|
||||
**Routine tasks don't retry automatically** by design. A Routine has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
|
||||
**Autopilot tasks don't retry automatically** by design. An Autopilot has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
|
||||
</Callout>
|
||||
|
||||
## Manual rerun vs. automatic retry
|
||||
@@ -100,7 +100,7 @@ Multica pins the session ID **twice** during a task: once at the start (when the
|
||||
|
||||
But **which AI coding tools actually support this** varies a lot:
|
||||
|
||||
- ✅ **Real support** — Claude Code, Copilot, Hermes, Kimi, OpenCode, OpenClaw, Pi
|
||||
- ✅ **Real support** — Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **Code exists but unusable** — Codex, Cursor
|
||||
- ❌ **No support** — Gemini
|
||||
|
||||
@@ -108,5 +108,5 @@ See [Providers Matrix → Session resumption](/providers#session-resumption-who-
|
||||
|
||||
## Next
|
||||
|
||||
- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools (including the exact session-resumption status)
|
||||
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Routines](/routines) — the four ways to trigger a task
|
||||
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools (including the exact session-resumption status)
|
||||
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task
|
||||
|
||||
@@ -6,7 +6,7 @@ description: 智能体每一次工作的单位,有明确的状态机、超时
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**执行任务**(task)是 [智能体](/agents) 每一次工作的单位——把一个 [issue 分给智能体](/assigning-issues)、[在评论里 @提及智能体](/mentioning-agents)、在 [聊天](/chat) 里发一条消息、或者 [Routine](/routines) 到点触发,都会产生一个执行任务。Multica 把它放进队列,由 [守护进程](/daemon-runtimes) 领走后交给对应的 [AI 编程工具](/providers) 执行,结束时把结果写回服务器。
|
||||
**执行任务**(task)是 [智能体](/agents) 每一次工作的单位——把一个 [issue 分给智能体](/assigning-issues)、[在评论里 @提及智能体](/mentioning-agents)、在 [聊天](/chat) 里发一条消息、或者 [Autopilot](/autopilots) 到点触发,都会产生一个执行任务。Multica 把它放进队列,由 [守护进程](/daemon-runtimes) 领走后交给对应的 [AI 编程工具](/providers) 执行,结束时把结果写回服务器。
|
||||
|
||||
执行任务和 [issue](/issues) 是两层不同对象:一个 issue 可以反复分配、反复 @提及、手动重跑——每次都产生一个**新的**执行任务。
|
||||
|
||||
@@ -59,10 +59,10 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
|
||||
自动重试有两个额外条件:
|
||||
|
||||
1. **最多 2 次**——1 次原任务 + 1 次重试。重试也失败就不再重试,即使原因可重试。
|
||||
2. **只对 issue 和聊天触发的任务生效**——Routines 触发的任务**不自动重试**。
|
||||
2. **只对 issue 和聊天触发的任务生效**——Autopilots 触发的任务**不自动重试**。
|
||||
|
||||
<Callout type="warning">
|
||||
**Routines 任务不自动重试**是刻意设计。Routine 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
|
||||
**Autopilots 任务不自动重试**是刻意设计。Autopilot 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
|
||||
</Callout>
|
||||
|
||||
## 手动重跑和自动重试的区别
|
||||
@@ -100,7 +100,7 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
|
||||
|
||||
但**哪些 AI 编程工具真的支持**差别很大:
|
||||
|
||||
- ✅ **真支持**——Claude Code、Copilot、Hermes、Kimi、OpenCode、OpenClaw、Pi
|
||||
- ✅ **真支持**——Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
|
||||
- ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor
|
||||
- ❌ **不支持**——Gemini
|
||||
|
||||
@@ -108,5 +108,5 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
|
||||
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Routines](/routines) —— 触发执行任务的四种方式
|
||||
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
|
||||
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Autopilots](/autopilots) —— 触发执行任务的四种方式
|
||||
|
||||
@@ -25,6 +25,7 @@ Look up issues by symptom. Each entry gives you **symptom / likely causes / how
|
||||
multica daemon logs --lines 100 # look for daemon-side errors
|
||||
echo $MULTICA_SERVER_URL # confirm the address is set
|
||||
curl -i http://<server-host>:8080/health # hit the server directly
|
||||
curl -i http://<server-host>:8080/readyz # include DB + migration readiness
|
||||
cat ~/.multica/config.json # verify api_token exists
|
||||
multica workspace list # confirm you're a member of the target workspace
|
||||
```
|
||||
@@ -107,28 +108,29 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
|
||||
- Domain not verified → run the DNS verification flow in the Resend console (add SPF / DKIM records)
|
||||
- In an emergency (internal testing) → copy the code printed under `[DEV]` from the server logs
|
||||
|
||||
## Verification code `888888` doesn't work
|
||||
## Fixed local test code doesn't work
|
||||
|
||||
**Symptom**: on a self-hosted instance, you try to sign in with the development-only master code `888888` and it's rejected with `invalid or expired code`.
|
||||
**Symptom**: on a self-hosted instance, you try to sign in with a fixed local test code such as `888888` and it's rejected with `invalid or expired code`.
|
||||
|
||||
**Likely causes** (mutually exclusive):
|
||||
|
||||
1. **`APP_ENV=production`** — this is the **correct** production configuration; `888888` is **disabled** when `APP_ENV=production`. Intentional design, not a bug
|
||||
2. **You received a real code via Resend** — if Resend is configured, the server sent an actual email; `888888` is only a dev fallback
|
||||
1. **`MULTICA_DEV_VERIFICATION_CODE` is empty** — fixed codes are disabled by default
|
||||
2. **`APP_ENV=production`** — this is the **correct** production configuration; fixed local test codes are ignored in production
|
||||
3. **The configured code is not 6 digits** — the shortcut only accepts a 6-digit value
|
||||
|
||||
**How to diagnose**:
|
||||
|
||||
```bash
|
||||
cat .env | grep APP_ENV # inspect current config
|
||||
docker exec <container> env | grep APP_ENV # docker deployment
|
||||
cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
|
||||
docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
|
||||
```
|
||||
|
||||
Check your inbox (including spam) for the real verification code.
|
||||
|
||||
**How to fix**:
|
||||
|
||||
- In production, you shouldn't be using `888888` at all — configure Resend and use real codes
|
||||
- **For local development or internal testing**, if you need `888888`, ensure `APP_ENV` is unset or not `production` — but **never** run a public instance this way (see [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap))
|
||||
- In production, leave `MULTICA_DEV_VERIFICATION_CODE` empty — configure Resend and use real codes
|
||||
- For local development or internal testing, either copy the generated code from server logs or set `APP_ENV=development` plus `MULTICA_DEV_VERIFICATION_CODE=888888` — never enable a fixed code on a public instance (see [Sign-in and signup configuration → Fixed local testing codes](/auth-setup#fixed-local-testing-codes))
|
||||
|
||||
## Port conflicts
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
multica daemon logs --lines 100 # 看 daemon 侧错误
|
||||
echo $MULTICA_SERVER_URL # 确认地址配对
|
||||
curl -i http://<server-host>:8080/health # 直接戳 server
|
||||
curl -i http://<server-host>:8080/readyz # 连同 DB + migration readiness 一起检查
|
||||
cat ~/.multica/config.json # 看 api_token 是否存在
|
||||
multica workspace list # 确认你是目标工作区成员
|
||||
```
|
||||
@@ -107,28 +108,29 @@ multica issue show <issue-id> # 看 task 历史
|
||||
- 域名没验证 → Resend console 里走 DNS 验证流程(加 SPF / DKIM 记录)
|
||||
- 紧急情况下(如内部测试)→ 从 server 日志里抄 `[DEV]` 打印出的验证码
|
||||
|
||||
## 验证码是 `888888` 但登不进去
|
||||
## 固定本地测试验证码登不进去
|
||||
|
||||
**症状**:自部署实例,想用开发用的主验证码 `888888` 登录,但被拒 `invalid or expired code`。
|
||||
**症状**:自部署实例,想用 `888888` 这类固定本地测试验证码登录,但被拒 `invalid or expired code`。
|
||||
|
||||
**可能原因**(这俩互斥):
|
||||
**可能原因**(互斥):
|
||||
|
||||
1. **`APP_ENV=production`** —— 这正是你**应该**的生产配置;`888888` 在 `APP_ENV=production` 时**被禁用**。这是刻意设计,不是 bug
|
||||
2. **你在 Resend 收到了真实验证码** —— 如果 Resend 已配,server 实际发了真邮件,`888888` 只作为 dev fallback
|
||||
1. **`MULTICA_DEV_VERIFICATION_CODE` 为空** —— 固定验证码默认关闭
|
||||
2. **`APP_ENV=production`** —— 这是正确的生产配置;固定本地测试验证码在 production 中会被忽略
|
||||
3. **配置的验证码不是 6 位数字** —— 这个快捷码只接受 6 位数字
|
||||
|
||||
**怎么查**:
|
||||
|
||||
```bash
|
||||
cat .env | grep APP_ENV # 看当前配置
|
||||
docker exec <container> env | grep APP_ENV # docker 部署
|
||||
cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
|
||||
docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
|
||||
```
|
||||
|
||||
检查邮箱(含 spam)看有没有收到真实验证码。
|
||||
|
||||
**怎么修**:
|
||||
|
||||
- 生产环境你本来就不该用 `888888`—— 配好 Resend 用真实验证码
|
||||
- **本地开发或内网测试**若需要 `888888`,确保 `APP_ENV` 未设或不是 `production`——但**绝对不要**这样跑公网实例(详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱))
|
||||
- 生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,配好 Resend 后使用真实验证码
|
||||
- 本地开发或内网测试可以从 server 日志抄生成的验证码;如果需要 `888888`,设置 `APP_ENV=development` 和 `MULTICA_DEV_VERIFICATION_CODE=888888`。不要在公网实例启用固定验证码(详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码))
|
||||
|
||||
## 端口冲突
|
||||
|
||||
|
||||
1
apps/web/.gitignore
vendored
Normal file
1
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.vercel
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { AgentDetailPage } from "@multica/views/agents";
|
||||
|
||||
export default function AgentDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <AgentDetailPage agentId={id} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ChatPage as default } from "@multica/views/chat";
|
||||
@@ -3,6 +3,7 @@
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
@@ -13,6 +14,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
extra={
|
||||
<>
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { RuntimeDetailPage } from "@multica/views/runtimes";
|
||||
|
||||
export default function RuntimeDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <RuntimeDetailPage runtimeId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { SkillDetailPage } from "@multica/views/skills";
|
||||
|
||||
export default function SkillDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <SkillDetailPage skillId={id} />;
|
||||
}
|
||||
61
apps/web/app/not-found.tsx
Normal file
61
apps/web/app/not-found.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Instrument_Serif } from "next/font/google";
|
||||
|
||||
// Editorial-style 404. Cream + ink + terracotta palette is intentionally
|
||||
// inline — these brand experiments have not been promoted to design tokens.
|
||||
// The route lives outside the (landing) group's font scope, so we attach
|
||||
// Instrument Serif locally to match the editorial direction.
|
||||
const CREAM = "#faf9f6";
|
||||
const INK = "#1b1812";
|
||||
const TERRACOTTA = "#a64a2c";
|
||||
|
||||
const editorialSerif = Instrument_Serif({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-serif",
|
||||
});
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section
|
||||
className={`${editorialSerif.variable} relative flex min-h-screen flex-col items-center justify-center px-6 py-16`}
|
||||
style={{ backgroundColor: CREAM, color: INK }}
|
||||
>
|
||||
{/* tracking is wider than Tailwind's tracking-widest (0.1em) — editorial eyebrow detail, deliberate. */}
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs uppercase tracking-[0.25em]"
|
||||
style={{ color: TERRACOTTA }}
|
||||
>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
<span>error · not found</span>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
</div>
|
||||
|
||||
{/* Fluid hero size + ultra-tight leading; outside the Tailwind type scale by design. */}
|
||||
<h1 className="mt-12 font-serif text-[clamp(7rem,16vw,15rem)] leading-[0.85] tracking-tight">
|
||||
404
|
||||
</h1>
|
||||
|
||||
<p className="mt-10 max-w-xl text-center font-serif text-3xl leading-tight">
|
||||
This page{" "}
|
||||
<em className="not-italic" style={{ color: TERRACOTTA }}>
|
||||
doesn’t exist
|
||||
</em>
|
||||
.
|
||||
</p>
|
||||
<p
|
||||
className="mt-5 max-w-md text-center text-sm leading-relaxed"
|
||||
style={{ color: INK, opacity: 0.6 }}
|
||||
>
|
||||
The URL may have changed, the resource may be deleted, or you arrived from a stale link.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="mt-12 inline-flex h-10 items-center rounded-full px-6 text-sm font-medium transition hover:opacity-90"
|
||||
style={{ background: INK, color: CREAM }}
|
||||
>
|
||||
Back to Multica
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, heroButtonClassName } from "./shared";
|
||||
|
||||
export function HowItWorksSection() {
|
||||
const { t } = useLocale();
|
||||
const { t, locale } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
@@ -44,6 +44,12 @@ export function HowItWorksSection() {
|
||||
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.howItWorks.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href={locale === "zh" ? "/docs/zh" : "/docs"}
|
||||
className={heroButtonClassName("ghost")}
|
||||
>
|
||||
{t.howItWorks.ctaDocs}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -144,6 +144,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
],
|
||||
cta: "Get started",
|
||||
ctaGithub: "View on GitHub",
|
||||
ctaDocs: "Read the docs",
|
||||
},
|
||||
|
||||
openSource: {
|
||||
@@ -232,7 +233,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
resources: {
|
||||
label: "Resources",
|
||||
links: [
|
||||
{ label: "Documentation", href: githubUrl },
|
||||
{ label: "Documentation", href: "/docs" },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
],
|
||||
@@ -282,6 +283,127 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.20",
|
||||
date: "2026-04-29",
|
||||
title: "Create Issue by Agent, Agent Presence v3 & Daemon WebSocket Heartbeat",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create Issue by Agent — press `c`, write one line, pick an agent; issue creation runs async and the result lands in your inbox",
|
||||
"Agent Presence v3 — availability and last-task split into clearer signals, with an execution log on the issue panel showing active and recent runs",
|
||||
"Daemon ↔ server heartbeat now flows over WebSocket with HTTP fallback, cutting task wakeup latency",
|
||||
"Mention picker ranks suggestions by your local recency",
|
||||
],
|
||||
improvements: [
|
||||
"Server caches PAT / daemon token lookups in Redis, so large fleets stop hammering the database on every request",
|
||||
"Backend default agent CLI args via `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` env vars",
|
||||
"Manual and agent create-issue flows share one dialog shell, and picker agents become the default assignee",
|
||||
],
|
||||
fixes: [
|
||||
"Create-issue-by-agent no longer leaves tasks stuck queued, and no longer duplicates the issue when an attachment upload fails",
|
||||
"Agent comments respect newlines instead of rendering literal `\\n`, and multi-line replies keep their formatting",
|
||||
"Agent-authored root comments no longer inherit parent @mentions, breaking accidental agent loops",
|
||||
"Cursor agent on Windows preserves multi-line prompts",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.19",
|
||||
date: "2026-04-28",
|
||||
title: "Kiro CLI Runtime, Desktop Notifications & Issue Label Filter",
|
||||
changes: [],
|
||||
features: [
|
||||
"Kiro CLI added as a local agent runtime option",
|
||||
"macOS dock badge for unread issues, plus a native notification when the window is unfocused — click to jump straight to the issue",
|
||||
"Issue list now supports filtering by label, combinable with status / priority / assignee",
|
||||
"Daemon receives task wakeups over WebSocket — task startup latency drops noticeably",
|
||||
],
|
||||
improvements: [
|
||||
"List and board status group headers are simpler, with clearer color cues",
|
||||
"Author-written markdown links are preserved through linkify",
|
||||
"Label attach now applies optimistically, no server round-trip wait",
|
||||
"Mention picker's issue search refreshes as you type",
|
||||
],
|
||||
fixes: [
|
||||
"Deleting a comment now cancels any agent task it triggered — no more ghost runs",
|
||||
"Stalled Codex turns now time out instead of holding the slot",
|
||||
"Windows daemon no longer dies when the parent shell closes",
|
||||
"Agent-to-agent mention threads no longer cause feedback loops",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.18",
|
||||
date: "2026-04-27",
|
||||
title: "Issue Labels, Labs Tab & Sidebar Invite Dot",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue labels — color-code and filter issues across list, board and detail views",
|
||||
"Labs settings tab for experimental toggles",
|
||||
"Sidebar shows a dot when you have an unread workspace invite",
|
||||
],
|
||||
improvements: [
|
||||
"Project picker now shows the selected project's icon",
|
||||
"Sidebar parent items stay highlighted on detail pages",
|
||||
"Self-hosted deployments correctly honor signup gating env vars",
|
||||
],
|
||||
fixes: [
|
||||
"Agent comments preserve line breaks again",
|
||||
"Desktop RPM no longer conflicts with Slack / VS Code on Fedora",
|
||||
"Windows agents handle multi-line prompts correctly",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.17",
|
||||
date: "2026-04-26",
|
||||
title: "Custom Agent Env, Better Failure Messages & Reliability Fixes",
|
||||
changes: [],
|
||||
features: [
|
||||
"`multica agent create/update --custom-env KEY=VALUE` injects custom environment variables into agent runs",
|
||||
"Agent failure messages now include a tail of the runtime CLI's stderr — much easier to debug runtime errors",
|
||||
"CLI update download timeout is now configurable, so slow links no longer abort `multica update`",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon reports cancelled tasks as `cancelled` instead of `timeout`, and reconciles agent status when an issue's tasks are cancelled",
|
||||
"Server heartbeat split into probe/claim with slow-log + a model-list running-timeout, so a lost heartbeat no longer wedges the UI",
|
||||
],
|
||||
fixes: [
|
||||
"Server validates `assignee_id` on issue create/update so phantom IDs are rejected, and `DeleteIssue` uses the resolved issue ID",
|
||||
"Pi runtime now reads/writes `.pi/skills` instead of the old `.pi/agent/skills` path",
|
||||
"Windows daemon uses `CREATE_NEW_CONSOLE` so grandchild console popups no longer appear when launching agents",
|
||||
"Autopilot run-only context is now properly forwarded to the agent",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.16",
|
||||
date: "2026-04-24",
|
||||
title: "Chat V2, Issue Right-Click Menu & In-App Feedback",
|
||||
changes: [],
|
||||
features: [
|
||||
"Chat V2 — dedicated sidebar entry and full main-area page for AI conversations",
|
||||
"Right-click context menu on issues with a unified action set across list, board, and detail",
|
||||
"In-app feedback flow with a new Help launcher centralizing docs, support, and feedback",
|
||||
"Autopilot modal redesigned — simpler schema and consistent schedule UI across creation and edit",
|
||||
"Skills page redesigned — list + detail pages, scroll-fade card layout, shared PageHeader and mobile nav",
|
||||
"Bilingual flat-content rewrite of the docs site — English and Chinese sections share one tree",
|
||||
],
|
||||
improvements: [
|
||||
"Agent profile card appears on avatar hover for quick context",
|
||||
"Native right-click menu on desktop with clipboard actions (copy / paste / cut / select all)",
|
||||
"Daemon agent prompts hardened to break self-mention loops between agents",
|
||||
"Server readiness health endpoints for proper rollout / ingress probes",
|
||||
"Daemon GC defaults tightened and now accept flexible duration suffixes (e.g. `7d`, `12h`)",
|
||||
"Test Connection / runtime ping removed — runtime reachability is detected automatically",
|
||||
],
|
||||
fixes: [
|
||||
"Chat no longer flickers when a streamed response finalizes, and the input box no longer jumps when sending the first message",
|
||||
"Desktop reopens the last-used workspace on app start instead of falling back to the first one",
|
||||
"Editor preserves nested ordered lists through the readonly render path",
|
||||
"CLI `browser-login` now works from a machine that isn't running the server",
|
||||
"Daemon suppresses extra terminal windows when launching agents on Windows, and retries local-skill reports on transient server errors",
|
||||
"`/api/config` is publicly reachable again so unauthenticated clients can bootstrap",
|
||||
"Defense-in-depth owner check on workspace deletion, and `/health/realtime` metrics restricted to authorized callers (security)",
|
||||
"Hermes ACP runtime now receives the configured model; OpenClaw agent discovery timeout raised to 30s",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.15",
|
||||
date: "2026-04-22",
|
||||
|
||||
@@ -43,6 +43,7 @@ export type LandingDict = {
|
||||
steps: { title: string; description: string }[];
|
||||
cta: string;
|
||||
ctaGithub: string;
|
||||
ctaDocs: string;
|
||||
};
|
||||
openSource: {
|
||||
label: string;
|
||||
|
||||
@@ -144,6 +144,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
],
|
||||
cta: "\u5f00\u59cb\u4f7f\u7528",
|
||||
ctaGithub: "\u5728 GitHub \u4e0a\u67e5\u770b",
|
||||
ctaDocs: "\u9605\u8bfb\u6587\u6863",
|
||||
},
|
||||
|
||||
openSource: {
|
||||
@@ -232,7 +233,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
resources: {
|
||||
label: "\u8d44\u6e90",
|
||||
links: [
|
||||
{ label: "\u6587\u6863", href: githubUrl },
|
||||
{ label: "\u6587\u6863", href: "/docs/zh" },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
],
|
||||
@@ -282,6 +283,127 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.20",
|
||||
date: "2026-04-29",
|
||||
title: "Create Issue by Agent、Agent Presence v3 与 Daemon WebSocket 心跳",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create Issue by Agent —— 按 `c` 输入一句话并选 Agent,Issue 异步创建,结果回执送达 Inbox",
|
||||
"Agent Presence v3 —— 可用性与最近任务拆成两条更清晰的信号;Issue 详情右侧新增 Execution Log,可看到当前 active run 与历史 run",
|
||||
"Daemon ↔ Server 心跳改走 WebSocket,HTTP 自动 fallback,任务起跑延迟更低",
|
||||
"Mention 选择器按本机最近使用排序",
|
||||
],
|
||||
improvements: [
|
||||
"Server 用 Redis 缓存 PAT / Daemon Token 校验,大型团队不再让 DB 抗下每次请求",
|
||||
"后端支持通过 `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` 配置 Agent CLI 默认参数",
|
||||
"Manual 与 Agent 创建 Issue 共享同一个 Dialog 外壳,picker Agent 会被默认设为 assignee",
|
||||
],
|
||||
fixes: [
|
||||
"Create Issue by Agent 不再卡住 queued 任务,也不再因附件上传失败而重复创建 Issue",
|
||||
"Agent 评论保留换行,不再渲染成字面量 `\\n`,多行回复的格式也被完整保留",
|
||||
"Agent 自身发出的根评论不再继承父评论的 @mention,避免互相唤起的死循环",
|
||||
"Windows 下 Cursor Agent 启动时保留多行 prompt",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.19",
|
||||
date: "2026-04-28",
|
||||
title: "Kiro CLI Runtime、桌面通知红点与 Issue 标签过滤",
|
||||
changes: [],
|
||||
features: [
|
||||
"新增 Kiro CLI 作为本地 Agent runtime 选项",
|
||||
"macOS Dock 显示未读 Issue 红点;窗口失焦时弹出原生通知,点击直达对应 Issue",
|
||||
"Issue 列表新增 Label 过滤,可与状态、优先级、Assignee 等组合使用",
|
||||
"Daemon 通过 WebSocket 接收任务唤醒,任务起跑延迟显著降低",
|
||||
],
|
||||
improvements: [
|
||||
"List/Board 视图的状态分组 header 更简洁,颜色提示更清晰",
|
||||
"评论中作者手写的 Markdown 链接不再被自动 linkify 替换",
|
||||
"添加 Label 现在乐观更新,无需等待服务端往返",
|
||||
"Mention 输入时的 Issue 搜索结果会随着输入实时刷新",
|
||||
],
|
||||
fixes: [
|
||||
"Comment 被删除时会取消已触发的 Agent 任务,不再有幽灵 run",
|
||||
"Codex 卡住的对话回合会超时退出,避免占用配额",
|
||||
"Windows Daemon 不再随父 shell 关闭被一同杀掉",
|
||||
"Agent 之间的 mention 不再相互触发,避免死循环",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.18",
|
||||
date: "2026-04-27",
|
||||
title: "Issue 标签、Labs 设置页与邀请红点",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 标签——给 Issue 上色、分类,列表、看板和详情页都能用",
|
||||
"新增 Labs 设置页,集中放实验性开关",
|
||||
"有未读工作区邀请时,侧边栏会出现红点提示",
|
||||
],
|
||||
improvements: [
|
||||
"Project 选择器会显示当前所选 Project 的图标",
|
||||
"进入详情页时,侧边栏父级菜单保持高亮",
|
||||
"自托管部署正确读取注册放行相关的环境变量",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 评论的换行恢复正常显示",
|
||||
"桌面端 RPM 不再与 Slack / VS Code 在 Fedora 上冲突",
|
||||
"Windows 下 Agent 能正确处理多行 prompt",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.17",
|
||||
date: "2026-04-26",
|
||||
title: "Agent 自定义环境变量、更清晰的失败信息与一系列稳定性修复",
|
||||
changes: [],
|
||||
features: [
|
||||
"`multica agent create/update --custom-env KEY=VALUE` 支持为 Agent 注入自定义环境变量",
|
||||
"Agent 失败信息会带上 Runtime CLI 的 stderr 末尾片段,排查 Runtime 报错更直接",
|
||||
"CLI 更新下载超时支持配置,弱网下 `multica update` 不再被默认超时切断",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon 把取消的任务上报为 `cancelled` 而非 `timeout`,并在按 Issue 取消任务时同步对齐 Agent 状态",
|
||||
"Server 心跳拆成 probe/claim 两步,并补上慢日志和 model-list running-timeout,丢心跳不再卡住 UI",
|
||||
],
|
||||
fixes: [
|
||||
"Server 在 Issue 创建/更新时校验 `assignee_id` 真实存在;DeleteIssue 改用解析后的 Issue ID",
|
||||
"Pi Runtime 改为读写 `.pi/skills`,不再使用旧的 `.pi/agent/skills` 路径",
|
||||
"Windows 下 Daemon 启动 Agent 改用 `CREATE_NEW_CONSOLE`,孙子进程不再弹出额外终端窗口",
|
||||
"Autopilot 的 run-only 上下文正确传给被调起的 Agent",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.16",
|
||||
date: "2026-04-24",
|
||||
title: "Chat V2、Issue 右键菜单与应用内反馈",
|
||||
changes: [],
|
||||
features: [
|
||||
"Chat V2——侧边栏新增 Chat 入口,主区域提供完整的 AI 对话页面",
|
||||
"Issue 支持右键菜单,列表、看板和详情的操作入口统一收敛",
|
||||
"应用内反馈流程及全新的 Help 启动器,集中托管文档、支持和反馈入口",
|
||||
"Autopilot 弹窗重设计——更简的字段配置,创建与编辑共享一致的排期界面",
|
||||
"Skills 页面重设计——列表+详情、卡片化布局、滚动渐隐和共享 PageHeader / 移动端导航",
|
||||
"文档站重写为双语扁平内容树——中英文章节共用一棵目录",
|
||||
],
|
||||
improvements: [
|
||||
"悬停 Agent 头像即可弹出资料卡,快速了解上下文",
|
||||
"桌面应用新增原生右键菜单,支持复制 / 粘贴 / 剪切 / 全选等剪贴板操作",
|
||||
"Daemon 强化 Agent 提示,避免 Agent 之间形成自互 @ 的循环",
|
||||
"Server 新增就绪态健康检查端点,可对接灰度发布和 Ingress 探针",
|
||||
"Daemon GC 默认参数收紧,并支持灵活的时长后缀(如 `7d`、`12h`)",
|
||||
"移除 Runtime 的 Test Connection / Ping 功能,可达性改为自动检测",
|
||||
],
|
||||
fixes: [
|
||||
"Chat 流式回复结束时不再闪烁,发送第一条消息时输入框不再跳动",
|
||||
"桌面应用启动时正确恢复上次的工作区,而不是默认回到第一个",
|
||||
"编辑器只读渲染路径正确保留嵌套有序列表",
|
||||
"CLI `browser-login` 现在可以从未运行 Server 的机器上发起",
|
||||
"Windows 下 Daemon 启动 Agent 不再拉起额外终端窗口;本地 Skill 上报在服务端瞬时错误时会自动重试",
|
||||
"`/api/config` 重新对未登录客户端可达,方便初次 bootstrap",
|
||||
"DeleteWorkspace 增加防御性 owner 校验;`/health/realtime` 指标限定授权访问(安全)",
|
||||
"Hermes ACP Runtime 正确传递配置的模型;OpenClaw Agent 发现超时提高到 30s",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.15",
|
||||
date: "2026-04-22",
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -40,6 +40,7 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
|
||||
PORT: "8080"
|
||||
METRICS_ADDR: ${METRICS_ADDR:-}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
@@ -55,7 +56,11 @@ services:
|
||||
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
||||
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
|
||||
@@ -147,7 +147,7 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
|
||||
**关键约定**:
|
||||
|
||||
- **Callout**:`<Callout type="info|warning|tip">...</Callout>`。warning 用于陷阱(如 888888),info 用于补充说明,tip 用于最佳实践
|
||||
- **Callout**:`<Callout type="info|warning|tip">...</Callout>`。warning 用于陷阱(如固定测试验证码),info 用于补充说明,tip 用于最佳实践
|
||||
- **代码块**:shell 命令用 \`\`\`bash;配置用 \`\`\`yaml / \`\`\`env;JSON 用 \`\`\`json
|
||||
- **Cross-link**:用 markdown 链接 `[显示文字](/docs/page-slug)`,不要写成 "详见 Tasks 章节"
|
||||
- **表格**:有 3 行以上对照才用表格,不要 1-2 行也用
|
||||
@@ -723,11 +723,11 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
|
||||
> **合并说明**:原 7.3 Auth Setup + 7.10 Signup Controls 合并。
|
||||
|
||||
- **Source files**: `server/internal/handler/auth.go`(APP_ENV 判断 + checkSignupAllowed), `.env.example`(auth 相关注释)
|
||||
- **Source files**: `server/internal/handler/auth.go`(固定测试验证码 + checkSignupAllowed), `.env.example`(auth 相关注释)
|
||||
- **目标读者**: self-host 运维
|
||||
- **叙事位置**: self-host 的 auth 配置。
|
||||
- **写什么**(1500-2000 字):
|
||||
- **🚨 超醒目 warning block**:`APP_ENV=production` 必须设置,否则 verification code 恒为 `888888`(任何人登录任何账号)
|
||||
- **🚨 超醒目 warning block**:生产环境必须保持 `MULTICA_DEV_VERIFICATION_CODE` 为空;固定测试验证码只用于非 production 私有测试
|
||||
- Email + verification code 登录流程(依赖 Resend)
|
||||
- Google OAuth 配置步骤(创建 OAuth client → redirect URI → 填 env)
|
||||
- **Signup 白名单三层优先级决策树**:
|
||||
@@ -737,9 +737,9 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
- 典型场景:开放给公司域 / 限定几个邮箱 / 完全关闭 signup
|
||||
- 和邀请的关系(signup 关了也能通过邀请加人)
|
||||
- **不写**: JWT 实现、token 类型(§8.2 讲)
|
||||
- **写前要验证**: APP_ENV 判断条件;OAuth 流程最新;Signup 优先级
|
||||
- **写前要验证**: 固定测试验证码的 env 条件;OAuth 流程最新;Signup 优先级
|
||||
- **⚠️ 动笔前必读**:
|
||||
- ⚠️⚠️ **888888 陷阱必须最醒目**(红色 warning block),这是 self-host 最大坑
|
||||
- ⚠️⚠️ **固定测试验证码风险必须最醒目**(红色 warning block),这是 self-host 最大坑
|
||||
- OAuth 给外部步骤截图,别假设读者懂 GCP Console
|
||||
- 决策树建议用 Mermaid 图
|
||||
- **Owner**: –
|
||||
@@ -754,7 +754,7 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
- 任务一直 queued(runtime offline / max_concurrent 满 / agent 配错)
|
||||
- WebSocket 连不上(cookie / CORS / proxy)
|
||||
- Email 没收到(Resend 未配置 → 看 stderr)
|
||||
- 验证码收到是 888888 但不工作(APP_ENV 检查)
|
||||
- 固定测试验证码不工作(APP_ENV / MULTICA_DEV_VERIFICATION_CODE 检查)
|
||||
- Port 冲突
|
||||
- 日志位置:daemon / server / browser console
|
||||
- **不写**: 深度 bug report(去 GitHub issue)
|
||||
|
||||
@@ -85,7 +85,7 @@ Multica = **人 + AI agent 在同一个看板上协作的任务管理平台**。
|
||||
| 7 | **The Daemon** | 分布式执行的灵魂;poll + heartbeat + concurrent execution | 每 30s heartbeat;75s 无心跳 → 离线;启动时调 `recover-orphans` 回收孤儿任务;max_concurrent_tasks 有双层(daemon + agent) |
|
||||
| 8 | **Tasks** | 任务是什么;生命周期 queued→dispatched→running→completed/failed | **session_id mid-flight pinning**(agent 首条 system message 一到就持久化,不等完成);失败自动重试只对 issue-sourced 任务(max_attempts=3),chat 和 autopilot 不自动重试 |
|
||||
| 9 | **Triggers & Entry Points** ← **独立页** | 5 种让 task 产生的入口:Assignment / Comment @mention / Chat / Autopilot / Rerun;每种的行为对比 | 每种的 FK 字段不同(trigger_comment_id / chat_session_id / autopilot_run_id);**对比表**:哪种有 session resume / 自动重试 / priority 来源 / dedup |
|
||||
| 10 | **Skills** | 工作区 skill + 本地 skill;按 provider 的注入路径 | 8 种 provider 有不同 skill 根路径(Claude=`.claude/skills/`、Codex=`$CODEX_HOME/skills/`、Pi=`.pi/agent/skills/`、etc);skill 不参与执行,只参与上下文注入 |
|
||||
| 10 | **Skills** | 工作区 skill + 本地 skill;按 provider 的注入路径 | 8 种 provider 有不同 skill 根路径(Claude=`.claude/skills/`、Codex=`$CODEX_HOME/skills/`、Pi=`.pi/skills/`、etc);skill 不参与执行,只参与上下文注入 |
|
||||
| 11 | **MCP** | 独立协议;怎么给 agent 配 MCP server;和 skill 的区别 | **目前只 Claude Code 真用**——其他 provider 收到 McpConfig 但 CLI 没对应 flag;JSONB 明文存储,非 owner redact |
|
||||
| 12 | **Autopilots** | 让 agent 自动开工的调度器;两种执行模式;三种触发;并发策略 | **Webhook trigger 字段有但没接路由**——第一版不文档化;concurrency policy 只对 `run_only` 模式生效;`create_issue` 模式由 issue FSM 自然 gate |
|
||||
| 13 | **Chat** | 和 issue comment 的区别;session 复用 | **完全沙盒**——chat 里的 agent 不能发 comment 到 issue;session_id 用 COALESCE 持久化,agent crash 不会抹掉 |
|
||||
@@ -118,7 +118,7 @@ Multica = **人 + AI agent 在同一个看板上协作的任务管理平台**。
|
||||
| Overview | 决策树(哪种部署模式适合你) |
|
||||
| Docker Compose deployment | `make selfhost` vs `make selfhost-build` |
|
||||
| Environment variables reference | 完整 env 表 |
|
||||
| Authentication setup | **🚨 `APP_ENV != "production"` 会让 verification code 固定为 `888888`** —— 生产必须设置 `APP_ENV=production`;Google OAuth 配置;signup 白名单 |
|
||||
| Authentication setup | **🚨 固定测试验证码必须显式设置 `MULTICA_DEV_VERIFICATION_CODE`,生产保持为空**;Google OAuth 配置;signup 白名单 |
|
||||
| Storage | S3 / CloudFront / 本地磁盘 |
|
||||
| Email | Resend 配置;**没配会落到 stderr** |
|
||||
| Upgrading | 版本升级 + migration 策略 |
|
||||
@@ -145,7 +145,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A
|
||||
| 5 | Webhook autopilot trigger 字段建了但没接路由——第一版不文档化 | Autopilots |
|
||||
| 6 | custom_env merge 是覆盖而非合并——不能用 custom_env"取消设置"系统 env | Agents |
|
||||
| 7 | 旧 assignee 取消分配后不会被取消订阅 | Subscriptions |
|
||||
| 8 | `APP_ENV != "production"` 时 verification code 恒为 `888888` | Self-Hosting → Auth |
|
||||
| 8 | 固定本地测试验证码默认关闭;`MULTICA_DEV_VERIFICATION_CODE` 仅用于非 production 私有测试 | Self-Hosting → Auth |
|
||||
| 9 | Signup 白名单优先级:ALLOWED_EMAILS > ALLOWED_EMAIL_DOMAINS > ALLOW_SIGNUP | Self-Hosting → Auth |
|
||||
| 10 | One daemon ↔ many runtimes;one runtime ↔ ONE provider;同 daemon_id 重启复用旧 runtime 行 | Runtimes / Daemon |
|
||||
| 11 | Inbox 10 种类型,mention dedup 只在单 event 内生效 | Inbox |
|
||||
@@ -159,7 +159,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A
|
||||
|---|---|
|
||||
| Mermaid diagram | 架构图 / task 生命周期 / trigger 流向 / autopilot 调度链 |
|
||||
| Tabs | Cloud / Self-Host / Desktop 并列;CLI / UI 并列 |
|
||||
| Callouts(内置)| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的 888888** |
|
||||
| Callouts(内置)| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的固定测试验证码** |
|
||||
| Code Tabs | API 调用多语言(Shell / Node / Go) |
|
||||
| Video / GIF | "Create your first agent"、"Follow an agent working" |
|
||||
| DeploymentPicker(定制)| 交互式决策树:回答 3 个问题 → 推荐部署路径 |
|
||||
|
||||
@@ -82,7 +82,7 @@ Multica 做的事:
|
||||
|
||||
Multica **不自己训模型**,也不锁定某一家厂商。它是调度器,本地 daemon 会自动探测以下 CLI 工具并接入:
|
||||
|
||||
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent
|
||||
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent · Kimi · Kiro CLI
|
||||
|
||||
每个 agent 可以配置自己的模型、API Key、环境变量、MCP 服务器。
|
||||
|
||||
@@ -244,7 +244,7 @@ Project 相比 Issue 是更高层的组织单元。一个 issue 可以不属于
|
||||
#### 配置字段
|
||||
|
||||
- **基本信息**:名字、描述、头像(自动生成)
|
||||
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor 中的哪一个
|
||||
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor / Kimi / Kiro 中的哪一个
|
||||
- **Runtime**:绑定到哪个运行时(即在哪台机器上跑)
|
||||
- **Instructions 说明书**:agent 的系统提示词("你是一个资深工程师...")
|
||||
- **Custom Env**:要注入到 CLI 进程的环境变量(如 `ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL`、`CLAUDE_CODE_USE_BEDROCK`)
|
||||
@@ -291,7 +291,7 @@ Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent
|
||||
|
||||
`multica` CLI 在用户的机器上启动一个后台进程(macOS launchd / Linux systemd / Windows 服务风格),它:
|
||||
|
||||
1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`)
|
||||
1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`)
|
||||
2. 向 server **注册** 为一组 runtime(一个 CLI = 一个 runtime)
|
||||
3. 每 3 秒 **轮询** 一次 server,有任务就认领
|
||||
4. 每 15 秒 **心跳**(keepalive),报告自己还活着
|
||||
@@ -373,7 +373,7 @@ skill
|
||||
- Claude Code → `.claude/skills/{name}/SKILL.md`
|
||||
- Codex → `CODEX_HOME/skills/{name}/`
|
||||
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/agent/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/skills/{name}/SKILL.md`
|
||||
- Cursor → `.cursor/skills/{name}/SKILL.md`
|
||||
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
|
||||
- 其他 → `.agent_context/skills/{name}/SKILL.md`
|
||||
|
||||
4
packages/core/agents/constants.ts
Normal file
4
packages/core/agents/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// User-facing limits enforced symmetrically on the front-end (UI counter +
|
||||
// disabled save) and the back-end (handler validation + DB CHECK constraint).
|
||||
// Kept in core so both apps and the test suite read from one source.
|
||||
export const AGENT_DESCRIPTION_MAX_LENGTH = 255;
|
||||
418
packages/core/agents/derive-presence.test.ts
Normal file
418
packages/core/agents/derive-presence.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, AgentRuntime, AgentTask } from "../types";
|
||||
import {
|
||||
buildPresenceMap,
|
||||
deriveAgentAvailability,
|
||||
deriveAgentPresenceDetail,
|
||||
deriveWorkload,
|
||||
deriveWorkloadDetail,
|
||||
} from "./derive-presence";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: "rt-1",
|
||||
name: "Test Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 6,
|
||||
model: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
return {
|
||||
id: "rt-1",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: "daemon-1",
|
||||
name: "Test Runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Anchor for all wall-clock comparisons in the suite. Pairs with the
|
||||
// runtime fixture's last_seen_at (10s before NOW) so an "online" runtime
|
||||
// looks fresh by default.
|
||||
const NOW = new Date("2026-04-27T12:00:00Z").getTime();
|
||||
|
||||
function makeTask(overrides: Partial<AgentTask> = {}): AgentTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "rt-1",
|
||||
issue_id: "",
|
||||
status: "queued",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-04-27T11:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveAgentAvailability", () => {
|
||||
// Reachability dimension only — runtime + clock decide it; tasks are
|
||||
// irrelevant to this axis.
|
||||
|
||||
it("returns online when runtime is fresh-online", () => {
|
||||
expect(deriveAgentAvailability(makeRuntime(), NOW)).toBe("online");
|
||||
});
|
||||
|
||||
it("returns unstable when runtime just dropped (< 5 min)", () => {
|
||||
expect(
|
||||
deriveAgentAvailability(
|
||||
makeRuntime({ status: "offline", last_seen_at: "2026-04-27T11:59:30Z" }),
|
||||
NOW,
|
||||
),
|
||||
).toBe("unstable");
|
||||
});
|
||||
|
||||
it("returns offline when runtime has been gone > 5 min", () => {
|
||||
expect(
|
||||
deriveAgentAvailability(
|
||||
makeRuntime({ status: "offline", last_seen_at: "2026-04-27T11:50:00Z" }),
|
||||
NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("collapses about_to_gc into offline (it's a runtime-card concern, not the dot)", () => {
|
||||
expect(
|
||||
deriveAgentAvailability(
|
||||
// 6.5 days ago — past the 6-day about_to_gc threshold.
|
||||
makeRuntime({ status: "offline", last_seen_at: "2026-04-21T00:00:00Z" }),
|
||||
NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("returns offline when the runtime is null (deleted / never registered)", () => {
|
||||
expect(deriveAgentAvailability(null, NOW)).toBe("offline");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkload", () => {
|
||||
// Atomic 3-way classifier — used by both Agent (per-agent task counts)
|
||||
// and Runtime (per-runtime aggregated counts). Pure functional mapping
|
||||
// from a count pair to a workload label.
|
||||
|
||||
it("returns working when runningCount > 0", () => {
|
||||
expect(deriveWorkload({ runningCount: 1, queuedCount: 0 })).toBe("working");
|
||||
expect(deriveWorkload({ runningCount: 3, queuedCount: 5 })).toBe("working");
|
||||
});
|
||||
|
||||
it("returns queued when nothing running but queuedCount > 0", () => {
|
||||
expect(deriveWorkload({ runningCount: 0, queuedCount: 1 })).toBe("queued");
|
||||
expect(deriveWorkload({ runningCount: 0, queuedCount: 5 })).toBe("queued");
|
||||
});
|
||||
|
||||
it("returns idle when both counts are zero", () => {
|
||||
expect(deriveWorkload({ runningCount: 0, queuedCount: 0 })).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkloadDetail", () => {
|
||||
// Aggregates a task list into running/queued counts before classifying.
|
||||
// Terminal statuses (completed / failed / cancelled) are silently
|
||||
// ignored — workload is "what's on the plate right now", not history.
|
||||
|
||||
it("returns idle when no tasks at all", () => {
|
||||
const r = deriveWorkloadDetail([]);
|
||||
expect(r.workload).toBe("idle");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns working when at least one task is running", () => {
|
||||
const r = deriveWorkloadDetail([makeTask({ status: "running" })]);
|
||||
expect(r.workload).toBe("working");
|
||||
expect(r.runningCount).toBe(1);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns queued when only queued / dispatched tasks exist (no running)", () => {
|
||||
// The "stuck on offline runtime" scenario in isolation: runningCount=0,
|
||||
// queuedCount>0 surfaces as `queued` so the UI can honestly say
|
||||
// "Queued · N" instead of misleading "Running 0/3 +Nq".
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ status: "queued" }),
|
||||
makeTask({ id: "t2", status: "dispatched" }),
|
||||
]);
|
||||
expect(r.workload).toBe("queued");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns working when running coexists with queued (overflow)", () => {
|
||||
// Capacity-saturated agent: still running, but with a queue building.
|
||||
// The chip says "Working" with the queue expressed as a `+Nq` badge.
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ id: "t1", status: "running" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
makeTask({ id: "t3", status: "queued" }),
|
||||
]);
|
||||
expect(r.workload).toBe("working");
|
||||
expect(r.runningCount).toBe(1);
|
||||
expect(r.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores terminal statuses entirely (no historical state in workload)", () => {
|
||||
// Failed / completed / cancelled tasks contribute no count and don't
|
||||
// change the verdict — Recent Work + Inbox handle history, not workload.
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({
|
||||
id: "t-failed",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "t-completed",
|
||||
status: "completed",
|
||||
completed_at: "2026-04-27T11:00:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "t-cancelled",
|
||||
status: "cancelled",
|
||||
completed_at: "2026-04-27T10:30:00Z",
|
||||
}),
|
||||
]);
|
||||
expect(r.workload).toBe("idle");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("classifies running over queued when both present, regardless of order", () => {
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ id: "t1", status: "queued" }),
|
||||
makeTask({ id: "t2", status: "running" }),
|
||||
]);
|
||||
expect(r.workload).toBe("working");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAgentPresenceDetail", () => {
|
||||
// Composition: the two dimensions are derived independently and the
|
||||
// detail object exposes both. No cross-axis override — workload never
|
||||
// colours the dot, availability never overrides workload.
|
||||
|
||||
it("composes online + working for the common busy case", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [
|
||||
makeTask({ status: "running" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("online");
|
||||
expect(detail.workload).toBe("working");
|
||||
expect(detail.runningCount).toBe(1);
|
||||
expect(detail.queuedCount).toBe(1);
|
||||
expect(detail.capacity).toBe(6);
|
||||
});
|
||||
|
||||
it("composes offline + queued — the canonical 'stuck' case (was previously misleading 'running 0/N')", () => {
|
||||
// The motivation for the redesign: runtime offline + queued tasks
|
||||
// used to surface as `running` with `0/3 +2q` counts (literally false).
|
||||
// Workload now returns `queued` honestly, paired with offline
|
||||
// availability — UI reads "Offline · Queued · 2".
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:50:00Z",
|
||||
}),
|
||||
tasks: [
|
||||
makeTask({ status: "queued" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("queued");
|
||||
expect(detail.runningCount).toBe(0);
|
||||
expect(detail.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("composes unstable + working — runtime hiccup with tasks still in flight", () => {
|
||||
// Recently-lost runtime, but a task is still recorded as running.
|
||||
// Both signals surface independently — amber dot AND working chip —
|
||||
// so the user sees "connection wobbling" alongside "agent is busy".
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:59:00Z",
|
||||
}),
|
||||
tasks: [makeTask({ status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("unstable");
|
||||
expect(detail.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("composes offline + idle for an unreachable agent with no tasks pending", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:50:00Z",
|
||||
}),
|
||||
tasks: [],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("idle");
|
||||
});
|
||||
|
||||
it("handles a missing runtime by reporting offline + the task-driven workload", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: null,
|
||||
tasks: [makeTask({ status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("returns idle workload when only terminal tasks are present (history doesn't bleed in)", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [
|
||||
makeTask({
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("online");
|
||||
expect(detail.workload).toBe("idle");
|
||||
});
|
||||
|
||||
it("mirrors agent.max_concurrent_tasks into capacity", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent({ max_concurrent_tasks: 3 }),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.capacity).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPresenceMap", () => {
|
||||
it("returns one entry per agent, sourcing tasks by agent_id from a flat list", () => {
|
||||
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
|
||||
const agentB = makeAgent({ id: "b", runtime_id: "rt-1" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [agentA, agentB],
|
||||
runtimes: [makeRuntime()],
|
||||
snapshot: [
|
||||
makeTask({ id: "t1", agent_id: "a", status: "running" }),
|
||||
makeTask({ id: "t2", agent_id: "b", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
const a = map.get("a");
|
||||
const b = map.get("b");
|
||||
expect(a?.availability).toBe("online");
|
||||
expect(a?.workload).toBe("working");
|
||||
expect(b?.availability).toBe("online");
|
||||
expect(b?.workload).toBe("queued");
|
||||
});
|
||||
|
||||
it("returns offline availability for agents whose runtime_id has no matching runtime", () => {
|
||||
const orphan = makeAgent({ id: "orphan", runtime_id: "missing" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [orphan],
|
||||
runtimes: [],
|
||||
snapshot: [makeTask({ agent_id: "orphan", status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
const o = map.get("orphan");
|
||||
expect(o?.availability).toBe("offline");
|
||||
// Workload still resolves independently — running task counts.
|
||||
expect(o?.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("threads the same `now` so every agent on a shared runtime gets the same availability", () => {
|
||||
// Multi-agent scenario: one local daemon backs N agents, daemon dies.
|
||||
// All dependent agents should report unstable together — the shared
|
||||
// `now` parameter is what guarantees consistent bucket boundaries.
|
||||
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
|
||||
const agentB = makeAgent({ id: "b", runtime_id: "rt-1" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [agentA, agentB],
|
||||
runtimes: [
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:59:00Z",
|
||||
}),
|
||||
],
|
||||
snapshot: [
|
||||
makeTask({ id: "t1", agent_id: "a", status: "queued" }),
|
||||
makeTask({ id: "t2", agent_id: "b", status: "running" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(map.get("a")?.availability).toBe("unstable");
|
||||
expect(map.get("b")?.availability).toBe("unstable");
|
||||
// Workload remains independent: a is queued (waiting), b is working.
|
||||
expect(map.get("a")?.workload).toBe("queued");
|
||||
expect(map.get("b")?.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("ignores terminal tasks in the snapshot when building per-agent workload", () => {
|
||||
// Snapshot intentionally still includes each agent's most recent
|
||||
// terminal task (back-end SQL didn't change); the front-end now
|
||||
// filters them out at the workload-derivation step.
|
||||
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [agentA],
|
||||
runtimes: [makeRuntime()],
|
||||
snapshot: [
|
||||
makeTask({
|
||||
id: "t-terminal",
|
||||
agent_id: "a",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(map.get("a")?.workload).toBe("idle");
|
||||
});
|
||||
});
|
||||
134
packages/core/agents/derive-presence.ts
Normal file
134
packages/core/agents/derive-presence.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Pure derivation of an agent's user-facing presence from raw server data.
|
||||
// The back-end stores facts (which tasks exist, their statuses, the runtime
|
||||
// last_seen_at); the front-end translates them into two orthogonal
|
||||
// dimensions:
|
||||
//
|
||||
// 1. AgentAvailability — derived from runtime reachability only.
|
||||
// 2. Workload — derived from the task counts only.
|
||||
//
|
||||
// They are computed independently and assembled into AgentPresenceDetail.
|
||||
// Workload is strictly "what's on the plate right now" — no historical
|
||||
// terminal state. Past failures / completions live on the detail page
|
||||
// (Recent Work, failure_reason) and Inbox.
|
||||
|
||||
import { deriveRuntimeHealth } from "../runtimes/derive-health";
|
||||
import type { Agent, AgentRuntime, AgentTask } from "../types";
|
||||
import type {
|
||||
AgentAvailability,
|
||||
AgentPresenceDetail,
|
||||
Workload,
|
||||
} from "./types";
|
||||
|
||||
// AgentAvailability mirrors RuntimeHealth's reachability buckets but folds
|
||||
// `about_to_gc` into `offline` — both mean "long unreachable" from the
|
||||
// user's standpoint; the GC-warning copy belongs to the runtime card, not
|
||||
// the agent dot.
|
||||
export function deriveAgentAvailability(
|
||||
runtime: AgentRuntime | null,
|
||||
now: number,
|
||||
): AgentAvailability {
|
||||
if (!runtime) return "offline";
|
||||
const health = deriveRuntimeHealth(runtime, now);
|
||||
if (health === "online") return "online";
|
||||
if (health === "recently_lost") return "unstable";
|
||||
return "offline"; // offline | about_to_gc collapse here
|
||||
}
|
||||
|
||||
// Atomic workload derivation: pure 3-way classification of running/queued
|
||||
// counts. Exported so Runtime-level views (which already aggregate counts
|
||||
// per-runtime in their own indices) can plug into the same vocabulary
|
||||
// without re-deriving from raw task arrays.
|
||||
export function deriveWorkload(counts: {
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
}): Workload {
|
||||
if (counts.runningCount > 0) return "working";
|
||||
if (counts.queuedCount > 0) return "queued";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
interface WorkloadDetail {
|
||||
workload: Workload;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
}
|
||||
|
||||
// Aggregates a task list into running/queued counts, then classifies via
|
||||
// deriveWorkload. Caller pre-filters to the relevant scope (per-agent or
|
||||
// per-runtime) — we don't filter again here.
|
||||
export function deriveWorkloadDetail(tasks: readonly AgentTask[]): WorkloadDetail {
|
||||
let runningCount = 0;
|
||||
let queuedCount = 0;
|
||||
for (const t of tasks) {
|
||||
if (t.status === "running") {
|
||||
runningCount += 1;
|
||||
} else if (t.status === "queued" || t.status === "dispatched") {
|
||||
queuedCount += 1;
|
||||
}
|
||||
// Terminal statuses (completed / failed / cancelled) intentionally
|
||||
// ignored — workload is "what's on the plate right now", not history.
|
||||
}
|
||||
return {
|
||||
workload: deriveWorkload({ runningCount, queuedCount }),
|
||||
runningCount,
|
||||
queuedCount,
|
||||
};
|
||||
}
|
||||
|
||||
interface DerivePresenceInput {
|
||||
agent: Agent;
|
||||
runtime: AgentRuntime | null;
|
||||
// Tasks for THIS agent only. Callers (buildPresenceMap, hooks) pre-filter
|
||||
// by agent_id — we don't re-check here.
|
||||
tasks: readonly AgentTask[];
|
||||
// Wall-clock millis used by deriveAgentAvailability to bucket runtime
|
||||
// health. Threading it as a parameter keeps the function pure.
|
||||
now: number;
|
||||
}
|
||||
|
||||
export function deriveAgentPresenceDetail(input: DerivePresenceInput): AgentPresenceDetail {
|
||||
const availability = deriveAgentAvailability(input.runtime, input.now);
|
||||
const detail = deriveWorkloadDetail(input.tasks);
|
||||
|
||||
return {
|
||||
availability,
|
||||
workload: detail.workload,
|
||||
runningCount: detail.runningCount,
|
||||
queuedCount: detail.queuedCount,
|
||||
capacity: input.agent.max_concurrent_tasks,
|
||||
};
|
||||
}
|
||||
|
||||
// Workspace-level batch builder. One pass over the workspace's agents
|
||||
// produces a Map<agentId, AgentPresenceDetail> that every list / card /
|
||||
// runtime sub-page can read without re-deriving.
|
||||
export function buildPresenceMap(args: {
|
||||
agents: readonly Agent[];
|
||||
runtimes: readonly AgentRuntime[];
|
||||
// The workspace agent task snapshot: every active task plus each agent's
|
||||
// most recent terminal task. Comes straight from getAgentTaskSnapshot()
|
||||
// — no pre-filtering needed. Terminal rows are silently ignored by
|
||||
// deriveWorkloadDetail (workload is current-state only).
|
||||
snapshot: readonly AgentTask[];
|
||||
now: number;
|
||||
}): Map<string, AgentPresenceDetail> {
|
||||
const out = new Map<string, AgentPresenceDetail>();
|
||||
const runtimesById = new Map<string, AgentRuntime>();
|
||||
for (const r of args.runtimes) runtimesById.set(r.id, r);
|
||||
|
||||
// Group tasks by agent_id once — O(N) — so per-agent derivation is O(1)
|
||||
// task scans rather than O(N×M).
|
||||
const tasksByAgent = new Map<string, AgentTask[]>();
|
||||
for (const t of args.snapshot) {
|
||||
const list = tasksByAgent.get(t.agent_id);
|
||||
if (list) list.push(t);
|
||||
else tasksByAgent.set(t.agent_id, [t]);
|
||||
}
|
||||
|
||||
for (const agent of args.agents) {
|
||||
const runtime = runtimesById.get(agent.runtime_id) ?? null;
|
||||
const tasks = tasksByAgent.get(agent.id) ?? [];
|
||||
out.set(agent.id, deriveAgentPresenceDetail({ agent, runtime, tasks, now: args.now }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user