Compare commits

..

87 Commits

Author SHA1 Message Date
Jiang Bohan
75c9a5c742 refactor(create-issue): unify agent/manual modes under one Dialog shell
Recasts Quick/Advanced as Agent/Manual and lets users flip between modes
in-place from a footer switch button instead of a separate Advanced
shortcut. The two old modal types now route through one CreateIssueDialog
shell that owns the single <Dialog> and <DialogContent> — only the inner
panel body swaps on mode change, so the Portal/Backdrop/Popup stay
mounted and the switch is instant (no close→open animation flash).

Mode preference is persisted globally in localStorage via a small
useCreateModeStore, so the `c` shortcut always opens whichever mode the
user last used (or switched to). Carry payload (description / agent /
prompt) hands off through the shell's local state plus the existing
issue-draft store, so nothing the user typed is lost across switches.

Also drops the Shift+C → manual branch — `c` is now mode-agnostic and
the in-modal switch covers the same intent without users having to
remember a second shortcut.

Visible labels: "Quick create" → "Create with agent",
"New issue" → "Create manually".
2026-04-29 14:54:25 +08:00
Bohan Jiang
a475c17283 fix(views): drop disableHoverCard from QuickCreate modal ActorAvatars (#1818)
The ActorAvatar prop was renamed in #1794 (split presence into
availability + last-task) — `disableHoverCard` is now `enableHoverCard`
with inverted semantics. The QuickCreate modal landed against the old
API and broke main's frontend typecheck. The two avatars in the modal
already want the default (no hover card), so just drop the prop
instead of opting in.
2026-04-29 14:16:34 +08:00
Bohan Jiang
e4103f6ad7 fix(execenv): strip [[skills.config]] from per-task codex config.toml (#1816)
Codex Desktop writes one [[skills.config]] entry per known skill into
~/.codex/config.toml. File-backed entries get path = "...", but
plugin-backed entries (e.g. name = "superpowers:brainstorming") only get
a name. Codex CLI 0.114's TOML deserializer treats path as required, so
it rejects the plugin entries with "missing field path" and fails
thread/start.

The daemon copies ~/.codex/config.toml verbatim into each task's
isolated codex-home, which propagated those broken entries into the
per-task config and blocked every Codex agent run for affected users.

Strip the whole [[skills.config]] array on copy. Multica writes the
agent's currently assigned skills directly to codex-home/skills/ and
Codex auto-discovers them from there, so the user-level skill registry
is redundant for a per-task run.

Closes #1753
2026-04-29 14:06:29 +08:00
Bohan Jiang
2d9c153695 feat: quick-create issue (async agent + inbox completion) (#1786)
* feat(server): add quick-create issue async task path

Adds POST /api/issues/quick-create which validates the picked agent's
reachability up front (not archived, has runtime, runtime online) then
queues an issue-less agent task whose context JSONB carries the user's
natural-language prompt + requester + workspace. Daemon claim resolves
the workspace from the context, and the prompt builder switches to a
quick-create template instructing the agent to translate the prompt
into a single multica issue create call.

Task completion writes a success inbox item to the requester pointing at
the newly-created issue (located by querying the agent's most recent
issue in the workspace since task start, so we don't depend on agent
stdout shape). Failures write an action_required inbox item carrying the
original prompt + agent id so the frontend can offer "Edit as advanced
form" without losing input.

* feat(views): quick-create issue modal + inbox failure CTA

Adds a streamlined create-issue UI bound to the c shortcut: pick an
agent, type one line, submit. The modal closes immediately and the
agent translates the prompt into a multica issue create call in the
background. Shift+c keeps the legacy advanced form for users who want
every field. The "Advanced" button inside the new modal seeds the
shared issue-draft store with the prompt + picked agent so switching
mid-flow doesn't lose input.

Last-used agent persists per (user, workspace) via a workspace-aware
zustand store so frequent users skip the picker on every open.

Inbox renders quick_create_done items with a status pin to the new
issue and quick_create_failed items with an "Edit as advanced form"
CTA that re-seeds the legacy modal with the original prompt.

ApiError now carries the parsed JSON body so the modal can branch on
the structured agent_unavailable code without parsing the error
message.

* fix(quick-create): execenv injection, claim race, private-agent permission

Addresses GPT-Boy review on #1786:

1. execenv was rendering the assignment-task issue_context.md / runtime
   workflow even for quick-create, telling the agent to call
   `multica issue get/status/comment add` against an empty IssueID.
   Adds QuickCreatePrompt to TaskContextForEnv, plus a quick-create
   branch in renderIssueContext + the runtime_config workflow that
   instructs the agent to run a single `multica issue create` and
   exit, with explicit "do NOT call issue get/status/comment add"
   guards.

2. ClaimAgentTask serialized only on issue_id / chat_session_id, so
   concurrent quick-creates on the same agent (both NULL on those
   columns) ran in parallel — making the success-inbox lookup race
   over "most recent issue by this agent". Adds a third OR clause
   that treats "all four FKs NULL" as a serialization key for the
   same agent, so quick-create tasks on a given agent run one at a
   time.

3. QuickCreateIssue handler bypassed the private-agent ownership rule
   that validateAssigneePair enforces elsewhere — a user could POST a
   private agent_id they didn't own and trigger it. Now routes the
   picked agent through validateAssigneePair before the runtime
   liveness check.

4. Clarifies the quick-create-store namespacing comment to match the
   actual workspace-aware StateStorage convention used by the other
   issue stores (per-user is browser-profile-local).

* fix(quick-create): branch Output section + deterministic origin lookup

Addresses GPT-Boy's second-pass review on #1786:

1. The runtime_config.go Output section forced "Final results MUST be
   delivered via multica issue comment add" for every non-autopilot
   task — quick-create still got this conflicting instruction even
   though there's no issue to comment on. Switched the Output block
   to a three-way switch so quick-create gets a tailored "stdout is
   captured automatically; do NOT call comment add" branch matching
   the autopilot variant.

2. Completion lookup was "most recent issue created by this agent
   since task.started_at", which races against concurrent issue
   creates by the same agent (assignment task running alongside
   quick-create when max_concurrent_tasks > 1). Replaced with a
   deterministic origin link:

   - Migration 060 extends issue.origin_type CHECK to allow
     'quick_create'.
   - Daemon sets MULTICA_QUICK_CREATE_TASK_ID env var when running a
     quick-create task.
   - multica issue create CLI reads the env var and stamps the new
     issue with origin_type=quick_create + origin_id=<task_id>.
   - Server CreateIssue handler accepts (origin_type, origin_id)
     from trusted callers (only "quick_create" is allowed; the pair
     is rejected unless both fields are provided together).
   - notifyQuickCreateCompleted now calls GetIssueByOrigin keyed on
     (workspace_id, "quick_create", task.ID) — no more time-window
     racing against parallel agent activity.

The old GetRecentIssueByCreatorSince query is removed.
2026-04-29 14:05:26 +08:00
carmake
805071b5b1 fix(agent/cursor): route Windows launcher through PowerShell -File to preserve multi-line prompts (#1709)
On Windows the official cursor-agent installer ships cursor-agent.cmd whose
body is `powershell ... -File cursor-agent.ps1 %*`. CreateProcess for a .cmd
file goes through cmd.exe, and `%*` in a batch file is expanded by
re-tokenising the original command line, which mangles arguments containing
newlines or other whitespace - most notably a long, multi-line `-p <prompt>`.
The agent then only sees a truncated prompt and fails with "Workspace Trust
Required" or exits 1 immediately.

When LookPath resolves cursor-agent to a .cmd/.bat launcher and a sibling
cursor-agent.ps1 exists, invoke PowerShell directly with `-File <ps1>` so
Go's os/exec passes each argv as a discrete token. This is exactly what the
.cmd does internally; we just skip the cmd.exe re-tokenisation step.
PowerShell host resolution prefers pwsh.exe (PS 7) on PATH, then
powershell.exe on PATH, and finally falls back to
%SystemRoot%\System32\WindowsPowerShell\v1.0.

Platform-specific code is split via build tags
(cursor_invocation_windows.go / cursor_invocation_other.go) so non-Windows
builds carry no Windows-only dependencies. The lookup is exposed as a
package variable to make the Windows path fully unit-testable without
spawning real PowerShell. Five unit tests cover: passthrough on non-launcher
targets, successful rewrite with a multi-line prompt, .exe direct launch
(skip), missing .ps1 (skip), and missing PowerShell host (skip).

The change leaves macOS / Linux behaviour entirely untouched and stays on
the official cursor-agent launch chain - no node.exe direct invocation, no
prompt mutation, no extra flags.

Closes #1297

Made-with: Cursor
2026-04-29 14:00:15 +08:00
Naiyuan Qing
f0c845b777 fix: popover click bubble + resilient presence loading (#1798)
* fix(popover): stop click bubble + resilient presence loading

Two related bugs surfacing on production after #1794:

* Click-through: clicking a Detail link inside an agent hover card, or
  a kebab item in agents/runtimes list rows, also fired the parent row
  link's onClick. Base UI portals popovers in the DOM but React's
  synthetic events still bubble through the React tree, so the
  ancestor <a> wrapping the trigger still received the click. Fix at
  the primitive level (HoverCardContent + DropdownMenuContent) so
  every existing and future popover gets it for free — stopPropagation
  on the popup's onClick, then forward consumer-supplied handlers.

* Presence loading forever: useAgentPresenceDetail returned "loading"
  whenever any of its three queries had data === undefined. With prod
  backend missing the new agent-task-snapshot endpoint (404), or with
  an issue assignee referencing an archived agent (not in ListAgents),
  the UI spun forever. Now: query errors degrade to empty arrays, and
  a missing agent yields a synthesised offline+idle detail. The dot
  still renders gray, hover card still shows "Agent unavailable" —
  but no infinite skeleton.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(inbox): enable hover card on notification actor avatar

Originally excluded from the hover-card opt-in pass, but inbox
notifications are exactly the kind of "who sent me this?" surface
where seeing the actor profile on dwell is useful. Click-through to
the wrong target is no longer a concern — the popover stop-bubble
fix in this branch handles it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(autopilot): show agent presence dot on autopilots list rows

Autopilot detail / picker / dialog already render the dot — the list
was the lone holdout. With the autopilot-agent dependency this strong
("autopilot is dead if its agent is offline"), an at-a-glance dot is
the most useful signal in the row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:54:18 +08:00
devv-eve
9587a577e2 fix: guide codex multiline comments (#1795)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-28 19:33:45 +08:00
Naiyuan Qing
21e3cfaa01 Agent runtime status redesign: split presence into availability + last-task (#1794)
* feat(agent-status): add workspace live-tasks endpoint and TaskFailureReason type

Lays the API + type contract for the front-end agent presence cache:

- New `GET /api/active-tasks` returns active (queued/dispatched/running)
  tasks plus failed tasks within the last 2 minutes for the current
  workspace. The 2-minute window powers a UI-side auto-clearing "Failed"
  agent state without back-end pollers.
- `agent_task_queue` has no workspace_id column, so the query JOINs agent;
  `SELECT atq.*` keeps `failure_reason` (migration 055) on the wire.
- Adds `TaskFailureReason` to `AgentTask` so the UI can map the 5 backend
  classifiers (agent_error / timeout / runtime_offline / runtime_recovery
  / manual) to copy without parsing free-text errors.
- New `api.getActiveTasksForWorkspace()` client method; workspace is
  resolved server-side from the X-Workspace-Slug header (no path param,
  matching /api/agents and /api/runtimes conventions).

Includes the joint engineering plan and designer brief that scope the
broader Agent / Runtime status redesign — Phase 0 is this contract plus
the front-end derivation layer landing in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent-status): derive presence/health states with WS sync and desktop IPC bridge

Adds the front-end derivation layer that turns raw server data into the
user-facing 5-state agent / 4-state runtime enums. UI files are
deliberately untouched in this commit — derivation lives behind hooks
(useAgentPresence, useRuntimeHealth) that any component can call with
zero additional network traffic.

Architecture:
- Derivation is pure functions in packages/core/{agents,runtimes}; the
  back-end stays free of UI translation. Agents algorithm: runtime
  offline > recent failed (2-min window) > running > queued > available.
  Runtimes algorithm: status + last_seen_at -> online / recently_lost /
  offline / about_to_gc.
- A single workspace-wide active-tasks query backs all per-agent
  presence reads, eliminating N+1 across hover cards, list rows, and
  pickers. 30-second tick re-renders the hooks so the failed window
  expires even when no underlying data changes.
- WS task lifecycle events (dispatch / completed / failed / cancelled)
  invalidate active-tasks via the prefix dispatcher. completed/failed
  were removed from specificEvents so they go through both the prefix
  invalidate and the existing chat ws.on() handlers. Reconnect refetch
  picks up active-tasks too.
- Desktop bridges window.daemonAPI.onStatusChange directly into the
  runtimes cache via setQueryData, giving the local daemon sub-second
  feedback (vs. 75s server sweep). Bridge is wsId-bound so workspace
  switches automatically rebind the subscription; daemon_id matching
  covers the same-daemon-multiple-providers case.

24 derivation unit tests cover all branches plus null/empty/boundary
inputs (FAILED_WINDOW_MS edges, null last_seen_at, missing
completed_at). Full core suite: 112 tests passing. Typecheck green
across all 8 workspace packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent-status): redesign agent runtime status as two orthogonal dimensions

Splits the conflated 5-state agent presence into two independent axes:

- AgentAvailability (3-state): online / unstable / offline — drives the
  dot indicator everywhere a dot appears. Pure runtime reachability;
  never sticky-red because of a past task outcome.

- LastTaskState (5-state): running / completed / failed / cancelled /
  idle — surfaced as text + icon on focused surfaces (hover card,
  agent detail page, agents list, runtime detail). Never colours the dot.

Major changes:

* Domain layer: AgentPresence union → AgentAvailability + LastTaskState.
  derive-presence split into deriveAgentAvailability + deriveLastTaskState
  + deriveAgentPresenceDetail orchestrator. Tests reorganised into three
  groups (availability invariants, last-task invariants, composition).

* Visual config: presenceConfig (5 entries) → availabilityConfig (3) +
  taskStateConfig (5). availabilityOrder + lastTaskOrder for filter chips.

* Workspace-level presence prefetch: new useWorkspacePresencePrefetch
  hook + WorkspacePresencePrefetch mount component, wired into
  DashboardLayout (web) and WorkspaceRouteLayout (desktop). Hover cards
  render synchronously with no skeleton flash on first hover.

* ActorAvatar hover: flipped default — disableHoverCard removed,
  enableHoverCard added (default false). Opt-in at ~14 decision-moment
  surfaces; pickers / decoration sub-chips stay plain. Status dot
  decoupled (showStatusDot prop) so picker rows can show presence
  without nesting popovers.

* Hover cards: AgentProfileCard simplified — availability dot only,
  Detail link top-right (logs live on the detail page). New
  MemberProfileCard mirrors the structure: name + role + email +
  top-2 owned agents (sorted by 30d run count) with click-through to
  agent detail.

* Agents list: split Status into two columns — availability (3-color
  dot + label) and Last run (task icon + label, optional running
  counts). Two independent filter chip groups (Status + Last run);
  combination acts as intersection ("online + failed" finds broken-
  but-alive agents).

* Other UI surfaces (issue list/board/detail, comments, autopilots,
  projects, runtimes, mention autocomplete, subscribers picker)
  updated to the new dot semantics; status dot now strictly 3-color.

Server changes accompany the client redesign — workspace-wide
agent-task-snapshot endpoint, runtime usage queries, etc. — to feed
the derive layer with the data it needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent-detail): drop last-task chip from detail header + inspector

The Recent work section on the agent detail page already shows the same
data (with task titles, timestamps, error context) — surfacing
"Completed" / "Failed" / etc. up in the header was redundant chrome.
Detail surfaces now show only the 3-state availability dot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tables): handle narrow viewports across agents / skills / runtimes

Three table layouts were squeezing content into adjacent cells at
intermediate widths. Each fix is small and targeted:

* runtime-list: the Runtime cell's base name had `shrink-0`, so it
  refused to truncate when its grid column was narrowed under width
  pressure — the name visually overflowed into the Health column
  ("ClaudeOnline" etc). Removed shrink-0, added truncate. The Health
  column was also a fixed 9.5rem reservation for the worst-case
  "Recently lost · 2m 14s ago" copy; switched to minmax(0,1fr) so it
  competes fairly with Runtime.

* skills-page: had a single grid template with no responsive
  breakpoints — all 6 columns were rendered at any width and got
  visually jammed below md. Added a <md template that drops Source +
  Updated; the row markup hides those cells via `hidden md:block` /
  `md:contents`.

* agent-list-item: the new Last run column was reserved at minmax(8rem,
  max-content); on narrow md viewports the 8rem floor pushed the row
  past available width. Changed to minmax(0,max-content) so the cell
  shrinks under pressure (its content already truncates).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent-card): hover-only Detail + add Runtime row + breathing room

Three small polish tweaks to the agent hover card:

- Detail link gets `mr-1` + fades in only on card hover (group-hover).
  It was visually flush against the popover edge and competing for
  attention; now it stays out of the way during a quick glance and
  surfaces only when the user is dwelling on the card.

- Runtime row is back, in the meta block (cloud/local icon + runtime
  name). The earlier removal was over-aggressive — knowing where an
  agent runs is part of "who is this agent". The wifi badge stays
  dropped because the availability dot in the header already conveys
  reachability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(runtime): wifi-style health icon (4-state) for runtime list + agent card

Replaces the 6px coloured dot with a wifi-shape icon that carries both
state (Wifi vs WifiOff) and severity (success/warning/muted/destructive).

Mapping:
- online        → Wifi (success)
- recently_lost → WifiHigh (warning) — transient hiccup, fewer bars
- offline       → WifiOff (muted)    — long unreachable
- about_to_gc   → WifiOff (destructive) — sweeper coming soon

Used in two places:

- Runtime list: replaces HealthDot in the dedicated leading-icon column.
  Bumped the column from 0.5rem (dot-sized) to 0.875rem (icon-sized).

- Agent profile card RuntimeRow: derives runtime health from runtime +
  clock (matching the 4-state semantics) and renders HealthIcon next
  to the runtime name. Cloud runtimes always read as online. The
  duplicate signal with the header availability dot is intentional —
  it confirms WHICH runtime is the one currently in the dot's state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:21:13 +08:00
Naiyuan Qing
01855f6b09 revert(chat): Chat V2 — restore right-bottom floating drawer (#1580) (#1792)
* Revert "fix(chat): prevent UI flicker when streaming response finalizes (#1583)"

This reverts commit 71cc646951.

* Revert "fix(chat): prevent chatbox jump when sending first message (#1582)"

This reverts commit bb767e0ea6.

* Revert "feat(chat): Chat V2 — sidebar entry + main-area page (#1580)"

This reverts commit 35aca57939.
2026-04-28 18:31:33 +08:00
LinYushen
03f3180b8f fix(agent): ignore Kiro session/load history replay (#1789)
Ignore Kiro ACP session/load history replay before the active prompt starts; keep task messages, usage, and tool state scoped to the current Kiro turn. Verified with go test ./pkg/agent -run TestKiro, go test ./pkg/agent, and git diff --check origin/main...HEAD.
2026-04-28 17:50:13 +08:00
Bohan Jiang
6f9e82cecc docs(changelog): publish v0.2.19 release notes (#1791)
* docs(changelog): publish v0.2.19 release notes

Today's release covers 23 commits since v0.2.18. Headline items are the
macOS dock unread badge with focus-gated inbox notifications, the daemon
WebSocket task wakeup path that drops task startup latency, and a
client-side label filter on the issue list. Improvements / fixes round
out comment linkify, optimistic label attach, agent-to-agent mention
loop prevention, Codex turn timeouts, Windows daemon survivability, and
the comment-delete task cancellation.

The Kiro CLI runtime addition is intentionally omitted pending a
chat-mode regression flagged before release.

* docs(changelog): include Kiro CLI runtime, drop assignee-default line

Per release sign-off: Kiro CLI ACP runtime ships in v0.2.19 once the
chat-mode regression is fixed, so it goes back into the headline. The
"create-issue remembers last assignee" line is dropped from features
to keep the list to four spotlight items.
2026-04-28 17:46:28 +08:00
Bohan Jiang
bbe73ade8b feat(desktop): dock unread badge + focus-gated inbox notifications (#1445)
* feat(desktop): dock unread badge + focus-gated inbox notifications

Wire two OS-level integrations for inbox activity. Both degrade cleanly on
web and unsupported platforms.

- Unread badge on the macOS dock / Linux Unity launcher. Derived from the
  same inbox list the UI renders, deduplicated per issue, capped as "99+"
  on macOS via `app.dock.setBadge` (setBadgeCount truncates at 99). New
  `useInboxUnreadCount` hook (core/inbox) + `useDesktopUnreadBadge`
  (views/platform) keep renderer and main in sync via a `badge:set` IPC.
- Native OS notification on `inbox:new`, fired from the renderer only when
  `document.hasFocus()` is false — in-focus feedback is the existing inbox
  sidebar's unread styling, so we don't fight macOS's deliberate foreground
  suppression. Clicking the banner focuses the main window and navigates
  to `/inbox?issue=<key>` via the shared `multica:navigate` bus.

Refactors `inbox-page.tsx` to read the unread count through the new hook
(was a per-render inline filter).

* fix(desktop): pin notification routing to source workspace + mark read on URL select

Two bugs GPT-Boy caught on PR #1445:

1. A notification from workspace A used `getCurrentSlug()` at click time,
   so if the user switched to workspace B before clicking the banner (macOS
   Notification Center persists banners), routing landed on `/B/inbox?issue=<A key>`
   and 404'd. Fix: round-trip the emit-time `slug` through the IPC payload
   and use it in the click handler.
2. Notification-click navigation set the URL param but never fired the
   mark-read mutation (only InboxPage's click-handler did). The row stayed
   unread and the dock badge didn't decrement. Fix: move the mark-read
   logic from handleSelect into a useEffect keyed on the selected item —
   it now covers both click-to-select and URL-param-select.

IPC payload gains `slug` and `itemId`; preload types + main handler + the
desktop bridge are updated to match.
2026-04-28 17:33:48 +08:00
devv-eve
1845eaf42c fix: update kiro runtime icon (#1787)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-28 17:21:30 +08:00
LinYushen
c366cf2ba1 feat(agent): add Kiro CLI ACP runtime (#1780)
* feat(agent): add kiro cli acp runtime

* fix(agent): align kiro acp prompt and notifications

* chore(agent): clarify kiro acp args compatibility
2026-04-28 17:03:46 +08:00
LinYushen
fae108ebdc fix: refresh mention issue search results 2026-04-28 16:54:41 +08:00
Bohan Jiang
0236e409e4 feat(issues): client-side label filter on the issues list (#1782)
Adds a Label submenu to the workspace issues filter dropdown, backed by
labelFilters in the shared issue view store. The filter is OR'd within
itself (issue matches if it carries any of the selected labels) and
AND'd with the existing status / priority / assignee / creator /
project dimensions, mirroring the multi-select semantics already in
place. Each label row renders via LabelChip for color parity with the
sidebar picker, and each row's count comes from the same
useIssueCounts pass that drives the other filter chips.

Filtering stays client-side, consistent with all other filters today.
The pagination caveat is a known limitation we'll revisit if real
workspaces start hitting it; this PR intentionally does not change the
fetch path.
2026-04-28 16:47:33 +08:00
Bohan Jiang
2f793fb6fe docs(desktop-app): correct self-host callout to reflect build-time URLs (#1777)
Released Desktop builds bake VITE_API_URL/VITE_WS_URL/VITE_APP_URL
at build time and ship pointing at Multica Cloud — there is no
in-app 'Connect to a self-hosted instance' button. Reported in
multica-ai/multica#1768.

- Replace the misleading callout in desktop-app.mdx (and zh) with
  the actual self-host path: build from source with custom env, or
  use web + CLI. Link to #1371 for the runtime-config feature.
- Soften the corresponding 'Next steps' link in self-host-quickstart
  (and zh) so it no longer implies one-click Desktop self-host.
2026-04-28 16:46:03 +08:00
Naiyuan Qing
b2fb39ed21 refactor(issues): flatten status group headers in list/board (#1783)
Drop the filled status chip (bg-warning/text-white etc.) from the
list/board column headers — StatusIcon already carries the semantic
color, so the chip duplicated it on the text background, and the
bg-muted variants were nearly invisible against the muted/40 row
background. Wrap the shared icon + label + count in a new StatusHeading
component used by both list-view and board-column.

Remove the now-unused badgeBg/badgeText fields from STATUS_CONFIG.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:45:26 +08:00
Bohan Jiang
abd69890a8 Revert "feat(issues): server-side filters incl. label, fixing pagination drop…" (#1779)
This reverts commit 246fcd4ce4.
2026-04-28 16:29:42 +08:00
Bohan Jiang
246fcd4ce4 feat(issues): server-side filters incl. label, fixing pagination drops (#1776)
* feat(issues): server-side label + filter querying for issue list

Extends GET /api/issues with label_ids, priorities, creator_ids,
project_ids, include_no_assignee, and include_no_project params, and
moves the existing single-value filters onto array-form. Each filter
becomes part of the SQL WHERE clause so paginated buckets reflect the
user's selection — fixes the bug where client-side filtering hid
matches sitting past the first page (#1491).

CLI gains a repeatable --label flag; legacy --priority/--assignee/
--project keep working via the single-value compatibility paths.

* feat(issues): drive workspace + my-issues filters from the server

issueListOptions and myIssueListOptions now key the React Query cache
on a normalized filter object, so each filter combination has its own
cache entry and a filter change re-fetches with the wire-shape filter
applied server-side. Drops the client-side filterIssues step on the
issues page, my-issues page, and project detail — that step silently
hid matches that lived past the first paginated page (#1491).

Adds a Label submenu to the workspace issues filter dropdown, plus
labelFilters in the view store. Mutations and ws-updaters fan their
optimistic patches across every filter-keyed list cache via
qc.setQueriesData on issueKeys.listPrefix(wsId), and the editor's
mention-suggestion reads from any matching list cache for instant
first paint regardless of which filter is active.

* fix(issues): route Members/Agents scope through server-side filter

The Members/Agents scope tabs on the workspace issues page were still
narrowing client-side via `assignee_type === 'member'`. That hits the
exact pagination-blind bug this PR is meant to fix: if the first 50
issues per status don't include the right assignee type, the tab
shows "No issues" while later pages have matches.

Adds an `assignee_types text[]` filter to ListIssues / ListOpenIssues /
CountIssues, threads it through the API client, normalizer and view
filter, and maps the scope tab to it. Each scope now keys its own
list cache and refetches with the correct first page.

Also disables the My Issues "My Agents" query when the user owns no
agents — `assignee_ids: []` was getting dropped by both the API client
and the query-key normalizer, so the request went out unfiltered and
surfaced unrelated issues under "My Agents".
2026-04-28 16:13:56 +08:00
devv-eve
9db91e89f5 feat: add daemon websocket task wakeups (#1772)
* feat: add daemon websocket task wakeups

* feat: fan out daemon wakeups across nodes

* fix: dedupe daemon wakeup loopback events

* fix: lengthen daemon polling fallback interval

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 16:07:24 +08:00
Bohan Jiang
541aaa974d fix(server): clarify silent-exit prompt and pin handoff contract (#1775)
Follow-ups to #1765 review nits:

- Tighten the per-turn prompt and AGENTS.md workflow instructions so
  that "exit with no output" only applies when the trigger is from
  another agent AND no actual work was produced this turn. If the
  agent did real work, the standard "post results as a comment" rule
  still applies — a result reply is not a noise comment.

- Add TestAgentExplicitMentionStillTriggers as a positive control
  documenting the boundary the structural fix preserves: suppressing
  implicit parent-mention inheritance for agent authors does NOT
  block deliberate handoffs. An agent that explicitly @mentions
  another agent in its own content still enqueues a task for the
  mentioned agent and does not self-trigger.
2026-04-28 15:21:39 +08:00
Bright Zheng
81231e06f8 fix(server): prevent agent-to-agent mention inheritance loops (BRI-34) (#1765)
When an agent replied in a thread whose root mentioned another agent,
the reply inherited the parent mention and re-triggered the other agent.
This caused 'No reply needed' ping-pong loops between co-assigned agents.

Structural fix:
- In enqueueMentionedAgentTasks, suppress parent-mention inheritance
  when authorType == 'agent'. Explicit @mentions in the agent's own
  comment still work for deliberate handoffs.

Defense-in-depth (prompt):
- Strengthen per-turn prompt and AGENTS.md workflow instructions to
  explicitly forbid posting 'No reply needed' noise comments.

Regression test:
- TestAgentReplyDoesNotInheritParentMentions covers both the fix
  (agent reply does not re-trigger) and the positive control
  (member reply still inherits mentions).

Also updates TestBuildPromptCommentTriggeredByAgent to match the
new prompt wording.
2026-04-28 15:14:14 +08:00
devv-eve
6ef711cd35 fix: gate dev verification code behind explicit env (#1773)
* fix: gate dev verification code behind explicit env

* docs: fold dev verification code into env table

* docs: clarify fixed verification code opt-in

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 15:14:07 +08:00
Bohan Jiang
b8f661e006 feat(create-issue): default assignee to last-selected value (#1774)
The create-issue modal now remembers the assignee picked at submit
time and prefills the picker with that value when the modal next
opens. Implemented by tracking lastAssigneeType/Id alongside the
draft and seeding clearDraft's reset with those values.
2026-04-28 15:11:10 +08:00
Bohan Jiang
f628e48775 refactor(server): error-returning ParseUUID to prevent silent data loss
* refactor(server): make ParseUUID error-returning to prevent silent data loss (MUL-1410)

util.ParseUUID previously swallowed errors and returned a zero pgtype.UUID
on invalid input. When this zero UUID reached a write query (DELETE/UPDATE),
the SQL matched zero rows and the handler returned 2xx success — producing
silent data corruption. #1661 (DeleteIssue with identifier-style ID) was the
visible symptom; PR #1680 patched that one site, this commit closes the
class of bug.

Changes:

- util.ParseUUID now returns (pgtype.UUID, error). Add util.MustParseUUID
  for trusted round-trips that should panic on invalid input.
- handler/handler.go: parseUUID wrapper now calls MustParseUUID — any
  unguarded user-input string reaching it surfaces as a recovered panic
  (chi middleware.Recoverer → 500) instead of silently corrupting data.
  Add parseUUIDOrBadRequest(w, s, fieldName) for handler entry points.
- Convert every Queries.Delete*/Update* call site reachable from raw user
  input (autopilot, comment, project, skill, skill_file, label, pin,
  attachment, feedback, issue assignee, daemon runtime, workspace) to
  validate UUIDs explicitly with parseUUIDOrBadRequest, returning 400 on
  invalid input. Where a resolved entity.ID is already in scope, write
  queries now use it directly instead of re-parsing the URL string.
- Update getWorkspaceMember + loadIssueForUser to handle invalid UUIDs
  gracefully (404/400 instead of panic).
- Update util/middleware/cmd-level callers (subscriber_listeners,
  notification_listeners, activity_listeners, scope_authorizer,
  middleware/workspace) to use the error-returning API.
- Add server/internal/util/pgx_test.go covering valid/invalid input and
  the MustParseUUID panic contract.
- Add TestDeleteIssueByIdentifier + TestDeleteIssueRejectsInvalidUUID
  regression tests in handler_test.go (the original #1661 bug + the
  invalid-input case).
- Document the handler UUID parsing convention in CLAUDE.md so the rule
  is enforceable in future PR review.

* fix(server): address GPT-Boy review of #1748

P1 fixes from PR #1748 review:

1. Migrate remaining request-boundary UUIDs to parseUUIDOrBadRequest so
   malformed input returns 400 instead of panic/500. Was missing on:
   - issue.go: workspace_id in CreateIssue/ChildIssueProgress/ListIssues/
     SearchIssues/BatchUpdateIssues/BatchDeleteIssues; project_id /
     parent_issue_id / lead_id / assignee_id / assignee_ids / creator_id
     filters; batch issue_ids and assignee/parent/project fields in
     BatchUpdateIssues (skip on bad input via util.ParseUUID, matching
     the existing per-row continue semantics).
   - project.go: project id + workspace_id in GetProject/UpdateProject/
     DeleteProject; lead_id in CreateProject/UpdateProject;
     workspace_id in ListProjects + SearchProjects.
   - handler.go: resolveActor now uses util.ParseUUID for X-Agent-ID /
     X-Task-ID headers; invalid UUID falls back to "member" (matches
     pre-existing semantics) instead of panicking.
   - issue.go: validateAssigneePair returns 400 on invalid workspace_id
     instead of panicking.

2. Fix issue:deleted WS event payloads to emit uuidToString(issue.ID)
   instead of the raw URL string. After an identifier-path delete
   ("MUL-7"), the previous payload would have leaked the identifier to
   subscribers, leaving stale entries in frontend caches that key by
   UUID. Updated DeleteIssue (issue.go:1341) and BatchDeleteIssues
   (issue.go:1641). The slog "issue deleted" log line also now records
   the resolved UUID so logs match the WS payload.

3. Extend TestDeleteIssueByIdentifier to subscribe to the bus and
   assert issue:deleted.payload.issue_id is the resolved UUID, not
   the identifier.

* fix(server): validate remaining reviewed UUID inputs

* fix(server): validate remaining handler UUID inputs

* fix(server): finish request boundary UUID audit

* fix(server): validate remaining request body UUIDs

* fix(server): validate runtime path UUIDs

* fix(server): validate remaining audit UUID inputs

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 14:50:28 +08:00
devv-eve
f864a07bd5 feat: add server Prometheus metrics endpoint
Add Prometheus metrics endpoint with local-bind listener support and baseline metrics collectors.
2026-04-28 14:29:01 +08:00
devv-eve
c381d59c7a fix: preserve authored markdown links during linkify (#1761)
Co-authored-by: Eve <eve@multica.ai>
2026-04-28 08:57:15 +08:00
Bohan Jiang
1292ecf71b fix(labels): apply label attach optimistically (#1746)
* fix(labels): apply attach optimistically so chips render before round-trip

Attach went through onSuccess only, so users waited for the server
before seeing the new chip — out of step with detach (already optimistic)
and with status/assignee/priority via useUpdateIssue. Mirror the detach
pattern: snapshot the byIssue cache, look up the full label from the
workspace list cache, patch byIssue + the issue list/detail caches via
onIssueLabelsChanged in onMutate, and roll back on error. onSuccess and
onSettled keep the existing reconcile behavior.

* fix(labels): only patch attach when prev label set is known

GPT-Boy's review caught a corruption case: when byIssue cache was
unpopulated (user clicked before issueLabelsOptions resolved), the
optimistic patch fell back to an empty prev.labels, then mirrored
[label] into issue list/detail via onIssueLabelsChanged — wiping any
denormalized labels already on the issue. Worse, onError only restored
byIssue when ctx.prev existed, so the wipe persisted on failure.

Match useDetachLabel's invariant: skip the optimistic patch unless prev
is in cache. The chip will wait for the round-trip in the rare race
window, but caches stay consistent and rollback always works.
2026-04-27 18:24:40 +08:00
Bohan Jiang
b77acdf642 fix(comments): cancel triggered tasks when comment is deleted (#1747)
When a user deletes a comment that triggered an agent task, the agent
would still run with the now-deleted content baked into its prompt
(fetched at task claim time) — manifesting as "the agent still sees the
deleted comment". The FK ON DELETE SET NULL only nullified
trigger_comment_id; the queued task itself was never cancelled.

DeleteComment now cancels any queued/dispatched/running task whose
trigger is the deleted comment, before the comment row is removed.
2026-04-27 18:24:07 +08:00
dyjxg4xygary
6bd5bbad9c fix: timeout stalled Codex turns (#1730)
* fix: timeout stalled codex turns

* fix: count codex progress events as activity
2026-04-27 18:23:31 +08:00
songlei
4c81fbed2b fix(daemon/windows): break out of parent shell Job Object so daemon survives
Approved and merged via Multica after CI passed.
2026-04-27 17:47:30 +08:00
Alex Fishlock
d63e7c1c45 ci(release): skip homebrew-tap publish on forks (#1687)
The release job uses GoReleaser to bump the formula in
multica-ai/homebrew-tap. Forks don't have HOMEBREW_TAP_GITHUB_TOKEN
and should not publish to that tap, so the job currently fails on
every fork tag push (401 Bad credentials against the upstream tap).
This makes the workflow red on downstream forks even though the
actual artifact pipeline (verify → docker-backend-build →
docker-backend-merge) succeeds and produces a usable image.

Gate the release job on `github.repository_owner == 'multica-ai'`.
Upstream behaviour unchanged. Forks now see a clean green run for
docker artifacts only.
2026-04-27 17:47:11 +08:00
Bohan Jiang
dabebe0c12 docs(changelog): publish v0.2.18 release notes (#1745)
* docs(changelog): publish v0.2.18 release notes

Today's release covers 13 PRs since v0.2.17. Spotlight is the full Issue
Labels feature (backend + CLI + Web UI), plus the Labs settings tab,
sidebar invitation indicator, and the sharded Redis realtime relay.
Improvements and fixes round out comment rendering, project-icon usage
across the app, self-host env-var pass-through, and several
Windows-specific agent issues.

* docs(changelog): simplify v0.2.18 entries

Trim each line to a short, user-facing sentence; drop implementation
detail (sharded relay, build-id symlinks, --description-stdin, etc.) per
review feedback that the previous draft was too detailed.
2026-04-27 17:34:07 +08:00
Bohan Jiang
d14265de2a fix(comments): preserve newlines from agent CLI writes (#1744)
* fix(comments): preserve newlines from agent CLI writes

Agents (e.g. Codex) routinely emit `multica issue comment add --content
"para1\n\npara2"` because Python/JSON-style string literals are their
default. Bash does not expand `\n` inside double quotes, so the literal
4-char sequence flowed through the CLI into the database and rendered
as text in the issue panel — comments came out as one wall of prose.

Three coordinated fixes so the platform behavior no longer depends on
whether a given model has strong bash-quoting intuition:

- CLI: decode `\n / \r / \t / \\` in `--content` and `--description` for
  `issue create / update / comment add` (callers needing a literal
  backslash still have `--content-stdin`).
- Agent prompt: rewrite the comment-add example in the injected runtime
  config to require `--content-stdin` + HEREDOC for any multi-line body,
  and call out the same rule for `--description`. The previous wording
  flagged stdin only for "backticks, quotes", which models read as
  irrelevant to plain paragraphs.
- Renderer: add `remark-breaks` to the shared Markdown plugin chain so a
  bare `\n` becomes a visible line break instead of a CommonMark soft
  break — protects against models that emit single newlines for
  formatting.

Tests: pin the new CLI helper, and pin the runtime-config guidance so
the multi-line wording cannot decay back into a footnote.

* fix(comments): address review feedback on newline-rendering PR

- Cover the issue panel: ReadonlyContent (used by every comment card and
  the issue description) has its own react-markdown wiring; add
  remark-breaks there too so the renderer fix actually applies to the
  surface the bug was reported on, not just the chat panel. Pinned by
  ReadonlyContent line-break tests.
- Make the prompt's `--description` guidance executable: add
  `--description-stdin` to `issue create` / `issue update`, refactor
  comment-add to share a single `resolveTextFlag` helper, and have the
  injected runtime config name the real flag instead of an imaginary
  "stdin / a tempfile" path. Pinned by the runtime-config guidance test.
- Document the unescape contract on each affected flag's help text and
  pin the precise boundary in tests: `\n / \r / \t / \\` are decoded;
  `\d / \w / \s / \u / \0` and other unrecognised escapes pass through
  verbatim, so regex literals and Windows paths survive intact unless
  they embed a literal `\n` / `\r` / `\t`. Callers that need the literal
  sequence have `--content-stdin` / `--description-stdin` as the escape
  hatch.
2026-04-27 17:17:34 +08:00
Bohan Jiang
bf6509be96 fix(issues): show labels in my-issues view + place chips after title (#1743)
- my-issues page lost labels because myIssuesViewStore cherry-picked
  name/storage/partialize from viewStorePersistOptions and dropped the
  cardProperties-aware merge. Persisted snapshots predating the labels
  toggle had cardProperties.labels = undefined, falsy-shorting the chip
  render. Extracted mergeViewStatePersisted as a generic and wired it
  into both stores.
- list-row chips now render right after the title (with a small left
  margin for breathing room) instead of in the right-aligned cluster.
2026-04-27 16:50:13 +08:00
Bohan Jiang
6620997503 feat(issues): render labels on list/board with bulk server-side fetch (#1741)
* feat(issues): render labels on list/board with bulk server-side fetch

ListIssues / ListOpenIssues / GetIssue now bulk-fetch labels per response
via a new ListLabelsForIssues query so the client gets labels in a single
round-trip instead of N requests per visible issue. List-row and board-card
read issue.labels directly; an issue_labels:changed WS handler patches the
list and detail caches in place so chips stay live across tabs, and
attach/detach mutations mirror their result into the same caches for
immediate same-tab feedback.

Adds a "Labels" toggle to the card properties dropdown (defaults on).

* fix(issues): preserve cached labels and refresh on label edit/delete

Three fixes from gpt-boy's review of #1741:

1. IssueResponse.Labels was a non-omitempty slice, so paths that didn't
   load labels (UpdateIssue, batch updates, the issue:updated WS broadcast)
   serialized labels:null. onIssueUpdated then merged that null into the
   list/detail caches, wiping chips on every other tab whenever any non-
   label field changed. Switched to *[]LabelResponse + omitempty: nil =
   field absent (client merge keeps existing labels); non-nil (incl. empty
   slice) = authoritative.

2. issue.labels is a denormalized snapshot, but useUpdateLabel /
   useDeleteLabel and the WS label:* prefix only touched labelKeys, leaving
   stale chips in list/board after rename/recolor/delete. Mutations now
   also invalidate issueKeys.all(wsId), and the realtime refreshMap maps
   the label prefix to both labels and issues invalidation for cross-tab.

3. Persisted cardProperties from before this branch lacks the new `labels`
   key. Render fell back to `?? true` but the dropdown switch read it raw
   and showed unchecked. Added a custom Zustand merge that deep-merges
   cardProperties so newly added toggles inherit defaults for existing
   users; dropped the `?? true` fallbacks now that the store guarantees
   the key.
2026-04-27 16:33:34 +08:00
Naiyuan Qing
e268ee3e71 refactor(views): centralize project icon rendering and fix nav active state (#1738)
Extract <ProjectIcon> with sm/md/lg sizes and a single 📁 fallback,
replacing 9 inline render sites that had drifted into 6 different
sizes and a mixed FolderKanban/emoji fallback.

Two visible fixes fall out of the centralization:
- ProjectPicker trigger now shows the selected project's icon (most
  visibly in the issue detail right Properties panel, where it had
  always been a generic FolderKanban).
- Sidebar parent nav (Projects, Issues, Settings, ...) now stays
  highlighted on child detail routes via a small isNavActive helper.
  Pinned items keep strict equality.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:42:56 +08:00
Ayman Alkurdi
e9d04ecfc1 feat(labels): ship issue labels (closes #1191) (#1233)
* feat(labels): add issue label CRUD + attach/detach handlers (#1191)

The issue_label and issue_to_label tables were scaffolded in 001_init.up.sql
but never wired to any code path. This commit ships the backend for #1191:

- Migration 048: adds created_at/updated_at timestamps + workspace-scoped
  case-insensitive unique index on label names
- sqlc queries for label CRUD + issue<->label attach/detach + batch list
  (ListLabelsByIssueIDs for board/list views)
- HTTP handlers: /api/labels CRUD, /api/issues/{id}/labels attach/detach
- Protocol events: label:{created,updated,deleted} + issue_labels:changed
- Handler tests covering CRUD, duplicate-name conflict, invalid-color,
  attach/detach idempotency, and cross-workspace isolation

* feat(cli): add label and issue label subcommands (#1191)

- multica label {list,get,create,update,delete}
- multica issue label {list,add,remove}

Both follow existing CLI conventions (JSON/table output, flag shapes)
and exercise the /api/labels endpoints shipped in the previous commit.

* feat(web): add labels UI — picker with inline create + management dialog (#1191)

Exposes the backend label feature to users via the existing issue-detail
sidebar.

- `@multica/core/types/label` — Label, CreateLabelRequest, UpdateLabelRequest,
  plus response envelopes
- `@multica/core/api/client` — 8 methods for label CRUD and issue↔label
  attach/detach
- `@multica/core/labels` — labelKeys, queryOptions, and mutation hooks with
  optimistic updates (matches the project/ module layout)
- WS event type literals extended for label:{created,updated,deleted} and
  issue_labels:changed

- `views/labels/label-chip.tsx` — colored pill; uses relative luminance
  (ITU-R BT.601) to pick #111827 or #f9fafb text so chips stay readable on
  both pastel and saturated backgrounds
- `views/issues/components/pickers/label-picker.tsx`
  - Multi-select combobox in the issue sidebar
  - When 0 labels: "Add label" trigger
  - When 1+ labels: the chips themselves are the trigger; × on each chip
    detaches without opening the picker
  - Inline create: typing a new name + Enter creates with a hash-derived
    color and attaches in one motion (matches Linear/GitHub)
  - "Manage labels…" footer opens a dialog containing the full workspace
    panel — users never leave the issue context to rename/recolor/delete
- `views/issues/components/labels-panel.tsx` — workspace labels manager.
  Single-row create form (color swatch + name + Add button). Each label
  row supports inline rename + recolor + delete (with confirm dialog).
  Color input uses the browser's native picker for full-gamut access —
  no preset palette clutter.

- `PropRow label="Labels"` added to the issue-detail sidebar below Project

Labels are issue metadata everyone uses — not admin configuration.
Putting them in Settings next to destructive workspace actions misframed
them; adding a top-level nav entry or a sibling tab to the Issues page
added surface area that wasn't earning its keep for a feature users
touch occasionally. Keeping management in a dialog launched from the
picker itself keeps users in their issue context and matches how GitHub
handles label editing from the label selector.
2026-04-27 14:23:42 +08:00
Bohan Jiang
2e7da8c63f fix(desktop): disable RPM build-id symlinks to avoid Slack conflict (#1734)
Electron apps share an identical upstream Electron binary, so its GNU
build-id is the same across every Electron RPM (Slack, VS Code, Discord,
etc.). The default fpm/rpm behavior owns /usr/lib/.build-id/<hash>
symlinks, which collide between packages and make `dnf install` fail
when any other Electron app is already installed.

Pass `_build_id_links none` to rpmbuild via fpm so the multica-desktop
RPM no longer claims those paths.

Fixes multica-ai/multica#1723.
2026-04-27 14:11:16 +08:00
Jiayuan Zhang
04882c2201 feat(labs): Add labs settings tab (#1732) 2026-04-27 13:46:25 +08:00
devv-eve
ba2f19d631 fix: refresh agent status from active tasks (#1733)
Co-authored-by: Eve <eve@multica.ai>
2026-04-27 13:34:24 +08:00
devv-eve
7f6776b12f fix: harden Windows CLI architecture detection
* fix: harden windows cli architecture detection

* fix: avoid duplicate windows architecture signals

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-27 13:01:53 +08:00
Truffle
8b340fcf21 fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts (#1718)
* fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts

The npm-generated `opencode.cmd` shim forwards argv via Windows batch `%*`,
which silently truncates positional arguments at the first newline. The
daemon spawns OpenCode with a multi-line prompt (system prompt + user
message), so on Windows the agent only ever sees the first line and
responds generically as if it never received the user's message
(reported in #1717 with native-binary repro confirming the same prompt
arrives intact when cmd.exe is skipped).

When `runtime.GOOS == "windows"` and `exec.LookPath` returns a `.cmd`
shim, walk to the native binary that npm bundles next to the shim:

  <prefix>\opencode.cmd
  <prefix>\node_modules\opencode-ai\node_modules\opencode-windows-x64\bin\opencode.exe

If the native binary is missing (unusual install layout), keep the
original shim path so PATH lookup still wins. The resolver is a pure
function with an injectable `statFn`, so layout assertions are testable
on Linux:

- shim resolves to the bundled native binary
- missing native returns "" (caller keeps original path)
- non-cmd paths (Linux/Mac binary, opencode.exe direct, empty) skip resolution
- uppercase `.CMD` is accepted (PATHEXT entries can be either case)

Closes the user-facing failure mode without restructuring exec resolution
across the rest of the agent backends — the other shim-aware fixes can
follow the same shape if/when they land in similar repros.

* fix(agent/opencode): cover x64-baseline and arm64 npm package variants

`npm install -g opencode-ai` ships three Windows platform packages
(opencode-windows-x64, opencode-windows-x64-baseline for older CPUs
without AVX2, opencode-windows-arm64 for Surface / Copilot+ PC) and
installs whichever matches the host. The previous resolver only knew
about opencode-windows-x64, so baseline-x64 and arm64 hosts would fall
back to the .cmd shim and hit the multi-line prompt truncation again.

Iterate the three package candidates in GOARCH-preferred order. ARM64
hosts try arm64 first; everything else tries x64, then baseline, then
arm64 as a last resort. Cost is one extra statFn call per miss when
the GOARCH-preferred package isn't installed.

Surfaced by review on #1718.

* test(agent): add Windows counterpart to writeTestExecutable

writeTestExecutable in exec_fixture_unix_test.go is referenced by
claude_test.go / codex_test.go / kimi_test.go, but the //go:build unix
constraint meant `go test ./pkg/agent` failed to build on Windows.

ETXTBSY is a Linux/Unix fork-exec race; Windows doesn't have that
pathology, so a plain os.WriteFile is sufficient.

Lifted from #1719 (Codex) with attribution. Surfaced by review on #1718.
2026-04-27 12:16:56 +08:00
supercon99
1f770813dd fix(selfhost): pass ALLOW_SIGNUP / ALLOWED_EMAILS / ALLOWED_EMAIL_DOMAINS to backend (#1726)
docker-compose.selfhost.yml documents these as load-bearing in .env.example
but the backend service never received them, so allowlist / signup-gating
configs were silently ignored on self-hosted deployments. Wires the three
vars through with defaults matching .env.example.
2026-04-27 12:16:15 +08:00
Muhammadrizo
29122cc18b feat(sidebar): add dot to show the user about new invintation (#1711) 2026-04-27 11:41:03 +08:00
LinYushen
18524d80d0 Implement sharded Redis realtime relay (#1702)
* Implement sharded Redis realtime relay

* Isolate dual relay read pools

* Surface mirrored relay publish divergence
2026-04-26 12:03:06 +08:00
LinYushen
141c294cdb P0: isolate Redis relay pools (#1701)
* Isolate Redis relay pools

* Fix Redis relay shutdown order
2026-04-26 11:26:13 +08:00
Black
04f813a70f fix PR 1573 follow-up colors (#1699) 2026-04-26 11:14:40 +08:00
Bohan Jiang
c7a2d53f76 docs(changelog): publish v0.2.17 release notes (#1700)
* docs(changelog): publish v0.2.17 release notes

Covers commits between v0.2.16 (2026-04-24) and the v0.2.17 cut
(2026-04-26): --custom-env flag for agents, agent CLI stderr tail in
failure messages, configurable update download timeout, plus reliability
fixes around daemon cancellation, server heartbeat, Codex execenv, Pi
skills path, Windows console, CJK markdown URLs, attachment downloads
and autopilot run-only context.

Both en.ts and zh.ts updated.

* docs(changelog): trim small/internal items from v0.2.17 entry

Drops items that read as internal polish or were too narrow to belong in
release notes:
- Skills landing intro polish
- Codex execenv plugin-cache cleanup
- CLI exact-name/ShortID assignee resolution
- Settings invite role label rendering
- Skills SKILL.md fast-path
- CJK markdown URL-boundary fix
- Relative attachment download URLs

Keeps the user-facing wins: --custom-env, stderr-tail in failure
messages, configurable update timeout, cancelled-task classification,
heartbeat probe/claim split, plus the higher-impact fixes.
2026-04-26 11:10:20 +08:00
Bohan Jiang
aca74293dd fix(agent/claude): surface stderr tail on writeClaudeInput failure + lock with e2e test (#1698)
#1674 wired claude's post-handshake error path through withAgentStderr but
left the writeClaudeInput failure branch returning a bare "broken pipe"
error. That branch fires precisely when claude crashes during startup —
exactly when the stderr tail is most useful for root-causing V8 aborts,
Bun panics, or missing native modules. cmd.Wait() before sampling Tail()
flushes os/exec's internal stderr copy goroutine, matching the
Wait→Tail synchronization contract spelled out in stderr_tail.go.

Adds TestClaudeExecuteSurfacesStderrWhenChildExitsEarly mirroring the
codex test: a fake claude binary drains stdin, writes a V8-abort line to
stderr, and exits 3. Locks in the contract that Result.Error carries the
stderr tail in the post-handshake failure path on the claude backend too.
2026-04-26 11:09:38 +08:00
Bohan Jiang
12e6ca9906 refactor(execenv): collapse codex plugin cache stale-link branches (#1697)
Merge the two symlink removal branches in exposeSharedCodexPluginCache —
they shared the same os.Remove + recreate path with only the error label
differing. The branch is now keyed off Lstat's ModeSymlink bit, with
Readlink reused only to fast-path an already-correct link. Behaviour is
unchanged; just less duplicated code.
2026-04-26 11:05:08 +08:00
jmoney8896
3c3e3bd330 fix(task): reconcile agent status when cancelling tasks by issue (#1587) (#1648)
CancelTasksForIssue silently dropped the list of affected tasks, so
whenever an issue transitioned to "cancelled" or "done" while a task was
still active (6 call sites in issue.go), the underlying agent was left
stuck at status="working" indefinitely and required a manual
`multica agent update <id> --status idle` to self-correct. This matches
the symptom reported in #1587: task rows move to "cancelled" via a
non-user-initiated path, agent status never reconciles.

Change CancelAgentTasksByIssue from :exec to :many (also tack on
completed_at = now() for consistency with CancelAgentTasksByIssueAndAgent),
then update CancelTasksForIssue to iterate the returned rows and call
ReconcileAgentStatus + broadcast task:cancelled per affected task —
mirroring the pattern already used by CancelTask and RerunIssue.

No test added; the change is small and mirrors well-covered paths.
Happy to add a mock-backed test in a follow-up if reviewers prefer.

Refs #1587
Refs #1149
2026-04-26 10:58:42 +08:00
Y. L.
25b393df17 fix(execenv): hydrate Codex skill sources (#1668)
Expose the shared Codex plugin cache inside each per-task CODEX_HOME before launch so plugin-provided skills are available on the first session.

Refresh agent-assigned workspace skills for both newly prepared and reused Codex environments, and cover plugin cache plus reuse behavior with focused execenv tests.
2026-04-26 10:57:51 +08:00
songlei
6f04a6d26b feat(agent): surface agent CLI stderr tail in failure messages (#1674)
Hoist the existing stderrTail ring-buffer (previously codex-only) into
a shared pkg/agent helper so every Backend that supervises a child CLI
can include the last ~2 KB of that CLI's stderr in Result.Error. Wire
the claude backend through the same path.

Motivation: claude on Windows occasionally exits with a non-zero status
after ~5–8 minutes of a single long-running tool_use, and right now the
daemon only reports "claude exited with error: exit status 3" /
"exit status 0x80000003" — useless for root-causing V8 aborts, Bun
panics, native-module OOMs, or any other CLI-side crash. With the tail
attached, the failure message carries the real signal (panic line, V8
assertion, stderr-printed HTTP error) all the way into the task row's
error field that users see in the API.

Renames withCodexStderr to withAgentStderr(msg, label, tail) so the
helper is self-documenting across providers.
2026-04-26 10:55:21 +08:00
Bohan Jiang
58547faf31 fix(server): validate assignee_id existence on issue create/update (#1694)
* fix(server): validate assignee_id existence on issue create/update

POST /api/issues and PUT /api/issues/:id silently accepted any
well-formed UUID as assignee_id (#1662). The new validateAssigneePair
helper consolidates the existing canAssignAgent check and adds:

- existence lookup against workspace members for assignee_type=member
- existence lookup against workspace agents for assignee_type=agent
- pair consistency: type and id must be both set or both null
- whitelist for assignee_type values (member|agent)

UpdateIssue and BatchUpdateIssues now run the same validator on the
post-merge assignee pair whenever the caller touches either field,
closing the parallel gap on the update path.

* fix(server): reject malformed assignee_id at handler entry

parseUUID silently returns an invalid pgtype.UUID for unparseable input
and validateAssigneePair treats (type unset + id invalid) as "no
assignee". Together they let `POST /api/issues` and `PUT /api/issues/:id`
silently drop a malformed assignee_id and return a successful response.

Reject the parse failure inline at every entry point — Create, Update,
and BatchUpdateIssues — so the validator never sees an unparseable id.
Adds two regression tests covering the create and update paths.
2026-04-26 10:35:47 +08:00
Magnus Handeland
9b55b2a9ce feat(cli): add --custom-env flag to agent create/update (#1518)
* feat(cli): add --custom-env to agent create/update

Adds a JSON-object flag on `multica agent create` and `multica agent
update` that writes the agent's `custom_env` map via the existing
handler API. Needed so runtime bearer tokens (e.g. SECOND_BRAIN_TOKEN)
can be provisioned from the CLI without falling back to curl or
admin-only UI access.

- `--custom-env '{"KEY":"value"}'` → sets the map.
- `--custom-env '{}'` or `--custom-env ''` → clears the map on update
  (server treats a non-nil empty map as "clear all entries").
- Omitted flag → no change.
- Help text flags the value as secret material and never logged.
- Table-driven tests cover the parser (valid, clear, invalid JSON,
  wrong shape) plus flag discoverability on both commands.

* feat(cli): add --custom-env-{stdin,file}; sanitize parse errors

Security review of the --custom-env flag (PR #1518) surfaced two issues:

1. Secrets on the command line leak via shell history and /proc/<pid>/cmdline
   regardless of CLI logging. Add --custom-env-stdin and --custom-env-file
   as mutually-exclusive alternatives, and update the --custom-env help
   text to warn about shell history / 'ps' exposure so the "never logged"
   claim is no longer misleading.

2. parseCustomEnv wrapped json.Unmarshal errors with %w; SyntaxError /
   UnmarshalTypeError can surface fragments of the (secret) input. Return
   a fixed, content-free message instead.

Refactor the body-assembly blocks in both agentCreateCmd and
agentUpdateCmd to go through a single resolveCustomEnv helper so the
three input channels behave identically. Tests cover every channel,
mutual exclusion, error sanitization, and help-text wording.

* fix(cli): require explicit '{}' to clear custom_env; sanitize --custom-args errors

Address PR #1518 review feedback from @Bohan-J:

1. parseCustomEnv now errors on empty/whitespace input. The clear signal
   is the explicit '{}' object only. The previous behavior silently wiped
   the secret map when an upstream pipe was empty (cat missing.json |
   ... --custom-env-stdin without set -o pipefail) or when --custom-env-file
   pointed at an empty file. resolveCustomEnv emits channel-specific error
   messages (e.g. "--custom-env-stdin: empty input; pass '{}' to clear").

2. Drop the '&& filePath != ""' guard so an explicit --custom-env-file ""
   surfaces an error instead of being silently ignored.

3. Rewrite TestAgentUpdateNoFieldsMentionsCustomEnv into
   TestAgentUpdateNoFieldsErrorMentionsAllCustomEnvFlags — the body now
   actually runs runAgentUpdate with no flags and asserts the resulting
   "no fields" error names all three --custom-env channels.

4. Extract parseCustomArgs helper. Replace the '%w'-wrapped json error
   with a content-free message, mirroring parseCustomEnv. Although
   custom_args is not a dedicated secret channel, callers regularly stuff
   sensitive values like "--api-key=..." into it, so json.Unmarshal must
   never echo input fragments. Adds TestParseCustomArgsErrorSanitization.

Also adds resolveCustomEnv subtests for stdin/file empty-input, empty
file contents, empty file path, and explicit '{}' positive cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Implementer (Multica Agent) <implementer@multica-agent.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:32:55 +08:00
Bohan Jiang
c7bac0aa6b docs(changelog): publish v0.2.16 release notes (#1695)
Covers everything between v0.2.15 (2026-04-22) and v0.2.16 (2026-04-24):
Chat V2, issue right-click context menu, in-app feedback + Help launcher,
Autopilot modal redesign, Skills page redesign, bilingual flat docs site
rewrite, plus the supporting agent / runtime / chat / desktop fixes.

Both en.ts and zh.ts updated.
2026-04-26 10:22:53 +08:00
Bohan Jiang
101601a4c3 fix(settings): render invite role label via roleConfig in members tab (#1693)
The invite-member role Select rendered the raw value ("member"/"admin")
in the trigger because Base UI's SelectValue defaults to the value, not
the item text. PR #1672 worked around it with `className="capitalize"`,
but this file already owns a roleConfig map with proper labels and the
codebase has an established render-prop pattern for SelectValue (see
trigger-config.tsx and runtime-local-skill-import-panel.tsx).

Use roleConfig[inviteRole].label inside SelectValue and reuse the same
labels for SelectItem children. Single source of truth for role display
names; future role additions or i18n won't depend on CSS capitalize.
2026-04-26 09:43:35 +08:00
Bohan Jiang
95912243bb test(daemon): cover cancelled classification in executeAndDrain (#1692)
Follow-up to #1686. Locks in two nits flagged during review:

1. agent.Result.Status doc comment now lists "cancelled" alongside the
   existing values, so the enum surface matches actual usage.
2. New TestExecuteAndDrain_ContextCancelled_ReportsCancelled exercises
   the path added in #1686: when the parent context is cancelled before
   the backend produces a Result, executeAndDrain must return
   Status="cancelled" (not "timeout"). A regression here would silently
   restore the misleading log line we just fixed.
2026-04-26 09:27:13 +08:00
Kagura
24e135541b fix(server): use resolved issue ID in DeleteIssue handler (#1680)
DeleteIssue passed the raw URL parameter through parseUUID(), which
returns a zero UUID for human-readable identifiers like "API-123".
This caused DELETE requests with identifier-style IDs to silently
succeed (204) without actually deleting the issue.

Use issue.ID from the already-resolved issue object instead, consistent
with BatchDeleteIssues and all other operations in the same handler.

Fixes #1661
2026-04-26 09:24:19 +08:00
Alex Fishlock
2df969cffc fix(daemon): report cancelled tasks as "cancelled", not "timeout" (#1686)
When the server cancels a task (e.g. assignee changes during execution,
explicit user cancel, or workspace_isolation check fail), the daemon's
cancellation poll fires runCancel() on the run context. The drainCtx
derived from runCtx then signals Done(), but executeAndDrain() was
returning Status: "timeout" regardless of *why* the context ended.

The "agent finished status=timeout" log line is then misleading — it
suggests an actual deadline timeout when really the task was cancelled
by upstream. We spent hours misdiagnosing a healthy handoff as a
broken timeout because of this.

Distinguish context.Canceled from context.DeadlineExceeded in
executeAndDrain, and add a "cancelled" case to runTask so the status
propagates through the existing log path.

No behaviour change for genuine timeouts; no behaviour change for
the cancelled-by-poll discard path in handleTask. Only the daemon
log line and TaskResult.Status get the more accurate label.
2026-04-26 09:23:32 +08:00
lmorgan-yozu
5eab1dbbe1 fix: handle relative attachment download URLs
Resolve server-relative attachment download URLs against the CLI server base URL while preserving signed absolute URL behavior.
2026-04-25 02:13:18 +08:00
Bohan Jiang
a89064d693 docs: clean up leftover .pi/agent/skills references (#1645)
PR #1632 updated the Pi project-level skill dir from
.pi/agent/skills/ to .pi/skills/, but missed two references:

- server/internal/daemon/execenv/runtime_config.go:20 — the comment
  block here lists project-level paths for every other provider, so
  using Pi's global path was inconsistent and misleading.
- docs/docs-rewrite-plan.md:88 — planning doc still listed the old
  path in the Skills row.

Follow-up to #1632.
2026-04-25 02:08:33 +08:00
etern
68a312c297 fix(runtimes): fix pi skills dir to: .pi/skills (#1632)
change .pi/agent/skills to .pi/skills

Pi loads skills from:

Global:
  ~/.pi/agent/skills/
  ~/.agents/skills/
Project:
  .pi/skills/
  .agents/skills/

- ref: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md#locations
2026-04-25 02:06:25 +08:00
Bohan Jiang
683ff132ca fix(server/heartbeat): probe/claim split + slow-log + model-list running timeout (#1644)
Mitigates #1637 and the related model-discovery failure in MUL-1397 by bounding the /api/daemon/heartbeat hot path with an ack-safe probe/claim split, adding structured slow-log attribution, and closing the ModelListStore running-state gap. See PR description for details.
2026-04-25 02:06:00 +08:00
Truffle
93fe324bb9 fix(skills): fast-path root-level SKILL.md with frontmatter guard (#1625)
Closes the functional gap the reporter hit on alchaincyf/huashu-design
(skills.sh/alchaincyf/huashu-design/huashu-design) without expanding
candidatePaths unconditionally, which would let an unrelated root
SKILL.md hijack a different skill URL in a multi-skill repo.

Try SKILL.md at the repo root before falling into the recursive tree
fallback added in #1432. Verify the frontmatter name matches the
requested skill so only genuine single-skill repos take the fast path.
For those repos this also shaves the recursive tree API call.

Also clarifies the candidate-path comment so the root case is
explicit.
2026-04-25 01:40:23 +08:00
Bohan Jiang
74593fdb88 fix(daemon): use CREATE_NEW_CONSOLE to stop grandchild console popups on Windows (#1521) (#1643)
* fix(daemon): use CREATE_NEW_CONSOLE to stop grandchild console popups on Windows (#1521)

CREATE_NO_WINDOW strips the console entirely. When the agent CLI then
spawns a console-subsystem grandchild (bash, cmd, netstat, findstr,
timeout) without itself passing CREATE_NO_WINDOW, Windows allocates a
brand-new visible console window per invocation — trading one popup per
agent run for N popups per tool call.

Switch to CREATE_NEW_CONSOLE + HideWindow=true so the agent gets a
hidden console that grandchildren inherit. Stdio pipes still work via
STARTF_USESTDHANDLES; no changes needed at the 17 hideAgentWindow call
sites.

Add a Windows-only regression test asserting CREATE_NEW_CONSOLE is set
and CREATE_NO_WINDOW is not, per the #1474 Windows-test follow-up.

Root-cause diagnosis by @matrenitski (verified against the shipped
multica.exe and the Claude Code CLI it spawns) in issue #1521.

* test(agent): use CREATE_NEW_CONSOLE-compatible flag in preservation test

CREATE_NEW_PROCESS_GROUP is silently ignored by Windows when combined
with CREATE_NEW_CONSOLE, so asserting it 'survives' was only bitwise-true,
not semantically meaningful. Switch the example to
CREATE_UNICODE_ENVIRONMENT (documented compatible) and also assert a
non-flag field (NoInheritHandles) survives to exercise full struct
preservation.
2026-04-25 01:40:15 +08:00
Bohan Jiang
60fdc82824 fix(cli): resolve assignee by exact name or ShortID to avoid substring collisions (#1642)
`multica issue assign --to <name>` matched agent/member names with a plain
`strings.Contains` check, so an exact match on `reviewer` became ambiguous
whenever a longer agent like `peer-reviewer` also existed. There was also
no way to disambiguate by ID.

Rework `resolveAssignee` to bucket candidates by priority:
1. Full UUID or 8-char ShortID (matches `truncateID` output) — case-insensitive.
2. Case-insensitive exact name (with surrounding whitespace trimmed).
3. Substring fallback — preserves the existing partial-name UX.

The first non-empty bucket wins. Ambiguity inside a higher-priority bucket
still errors and short-circuits lower-priority matching.

All six call sites (`issue assign/update/create/list`, `issue subscriber`,
`project`) are fixed by this single change.

Fixes #1620
2026-04-25 01:05:29 +08:00
Naiyuan Qing
c3ae212b40 fix(markdown): treat CJK full-width punctuation as URL boundary (#1630)
linkify-it only recognizes ASCII characters as URL boundaries. In Chinese
or Japanese text a URL followed by "。" (or any other full-width
punctuation) was greedily swallowed into the URL along with everything up
to the next whitespace, producing hrefs like
`https://.../pull/1623。merge` that 404 when clicked.

Truncate the detected URL at the first CJK full-width punctuation
character and re-scan the tail, so adjacent URLs separated only by
full-width punctuation are still each linked individually. The
terminator character set mirrors the fix applied in mattermost/marked#22.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:47:47 +08:00
Joey
d17b2bfb8c feat(cli): 添加更新下载超时配置选项 (#1622)
- 在 update 命令中添加 --download-timeout 标志用于设置下载超时时间
- 实现 UpdateViaDownloadWithTimeout 函数支持自定义下载超时
- 添加 updateDownloadTimeoutOrDefault 辅助函数处理超时值验证
- 设置默认下载超时时间为 120 秒
- 添加 updateDownloadTimeoutOrDefault 函数的单元测试
- 验证超时参数必须大于零的错误处理逻辑
2026-04-24 17:05:23 +08:00
devv-eve
13d9d7df1b fix: pass autopilot run-only context to agents
Fix run-only autopilot tasks so agents receive autopilot context instead of empty issue instructions. Add regression coverage for run-only terminal event sync.
2026-04-24 16:36:04 +08:00
Naiyuan Qing
71b2032174 feat(skills): restore page description, link to docs, polish intro layout (#1618)
* feat(skills): restore page description, link to docs, polish intro layout

The previous card-layout refactor (#1614) dropped the page-top
description entirely; without it the page jumps straight from the
PageHeader to a brand-colored banner that explains *how sharing works*,
with nothing answering "what IS a skill?". Bring the description back,
add a docs entry point, and tighten the visual hierarchy so the intro
block reads as one coherent unit above the table card.

- Restore a one-line description as the page's primary intro:
  "Instructions any agent in this workspace can use." — uses "any agent
  ... can use" (capability, not factual usage) since skills must be
  manually attached to take effect.
- Add an inline "Learn more about Skills →" link mirroring the
  onboarding docs-link pattern (muted underline, new tab) — opens
  https://multica.ai/docs/skills.
- Visual hierarchy: description is text-base + text-foreground (primary),
  link is text-xs + text-muted-foreground (auxiliary). Same line, eye
  follows weight order.
- Banner padding bumped from px-3 py-2 to px-4 py-3 so it breathes and
  its inner text lands at the same x as the table content.
- Wrap description + banner in a shared `pl-4 space-y-3` so they read as
  one intro block, indented to align with the table card's content.
- Loading skeleton updated to mirror the new structure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skills): keep docs link underline subtle, only animate text color on hover

The underline was inheriting text-decoration-color from the link's text,
so when hover bumped the text from muted to foreground the underline
got darker too — making the link feel more prominent on hover than at
rest, the opposite of what we want for a tertiary docs link.

Pin decoration-color to muted-foreground/30 explicitly so it stays
faint regardless of hover state. Only the text color transitions; the
underline stays as a constant low-key marker that the element is a link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:35:33 +08:00
Naiyuan Qing
f7fe0829f2 refactor(skills): wrap list as card, use shared PageHeader, add scroll fade (#1614)
The skills page rolled its own HeroHeader instead of the shared PageHeader,
which meant no mobile sidebar trigger and visual drift from other list
pages. The table was also edge-to-edge inside the dashboard container, so
it felt "naked" compared to the rest of the product.

- Replace custom HeroHeader with shared PageHeader (gives mobile hamburger
  and h-12 chrome for free); move "New skill" into the PageHeader as the
  page-level action.
- Keep search + scope filters in a toolbar, but move that toolbar *inside*
  a bordered, rounded card together with the table, so the whole unit
  reads as a single scrollable surface with internal padding.
- Use the existing useScrollFade hook on the row list so the top/bottom
  edges fade while scrolling.
- Drop `divide-y` in favor of `border-b` per row — divide-y leaves the
  last row without a bottom rule, which looks unfinished when only a
  couple of skills exist and the scroll area is taller than the content.
- Drop the redundant description paragraph from the old hero; keep the
  "Shared with your workspace" banner above the card since it carries
  non-obvious UX context.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:04:09 +08:00
LinYushen
9e1e3981fb fix(workspace): defense-in-depth owner check in DeleteWorkspace handler
Adds an owner check inside DeleteWorkspace as defense-in-depth and covers both router-level and direct handler paths.
2026-04-24 14:29:39 +08:00
Naiyuan Qing
c7e725ef66 feat: surface docs from onboarding + landing, unify Autopilot naming (#1613)
* docs(autopilot): rename Routines → Autopilots to match product UI

Unify naming between docs and product. Sidebar label, URL route,
CLI command, and onboarding copy all call this feature "Autopilot";
the docs were the only surface that diverged. Aligning the docs to
the product (rather than the reverse) because the 830+ code-side
references would be a much larger rename to propagate.

- Rename routines.mdx / routines.zh.mdx → autopilots.mdx / autopilots.zh.mdx
- Update meta.json / meta.zh.json index entries (routines → autopilots)
- Drop the reconciliation note ("docs say Routines, CLI says autopilot")
  that shipped in the original routines.mdx and the cli.mdx section header
- Update cross-references in cli, how-multica-works, tasks,
  assigning-issues, chat, mentioning-agents, daemon-runtimes (EN + ZH)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): link to docs from key steps and starter tasks

Users who want to dig deeper now have a next hop from inside the flow
instead of having to dig through the help menu. Placed as secondary
links (muted, underline-offset-4) so they don't pull focus from the
primary CTA on each step.

Placement — one link per surface, placed in secondary regions:
- Welcome: "Learn how Multica works" below the subhead
- Questionnaire: "Learn how agents work" in the Why-we-ask aside
- Runtime aside (shared by desktop + web): "Learn about runtimes"
- Agent step: "Creating your first agent" in the About-agents aside
- StarterContentPrompt dialog: "Learn how Multica works"

Starter tasks (content/starter-content-templates.ts): added a single
"Learn about X" tail link per task, only on first occurrence of each
concept within a branch. 8 links on the agent-guided branch + 8 on
the self-serve branch + 1 on the welcome issue header (17 total).

URL scheme: absolute https://multica.ai/docs/{slug} throughout —
absolute so desktop (Electron) opens them in the system browser, and
the /en prefix is omitted because the docs middleware redirects it
away (English is the default, Chinese is /zh/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(landing): add docs link to footer and how-it-works section

Docs were previously reachable only from the in-app help menu. Landing
now surfaces them in two places, both locale-aware (/docs for English,
/docs/zh for Chinese):

- Footer Resources group: Documentation link was pointing at the
  GitHub repo; replaced with the real docs URL
- How-It-Works section CTA row: added "Read the docs" between the
  primary CTA and the GitHub link, same ghost styling

Locale resolution: href is picked per-render based on the landing's
current locale (cookie-driven via useLocale). The docs app itself
does not auto-detect language, so we must pick the right path
explicitly when emitting the link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): clean up Autopilot rename leftovers and link formatting

- comments.mdx: "not routine updates" → "not day-to-day updates"
  (adjectival holdover now that the feature is renamed Autopilot;
  zeroes out remaining "routine" mentions in user-facing docs)
- starter-content-templates.ts: move the arrow inside the markdown
  link — "[text →](url)" instead of "→ [text](url)" — so the arrow
  is part of the clickable region. 17 occurrences.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): drop docs link from welcome screen and starter-content dialog

"Learn how Multica works" was showing up too often in the first two
screens users see. Keep the link in the post-import welcome issue
header (where users actually have time to explore); remove it from
the two earlier surfaces where it competes with the primary CTA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:27:53 +08:00
Naiyuan Qing
fe84e29b64 fix(ui): stop menu hover from overriding icon colors (#1612)
Menu primitives (context/dropdown/menubar/select/command) had rules like
`focus:**:text-accent-foreground` and `*:[svg]:text-destructive` that forced
descendant svg colors on focus, overriding icons that set their own color
(e.g. StatusIcon's `text-warning`). Remove them so icon color comes from
inheritance only: colored icons keep their color on hover, uncolored icons
still inherit the item's focus/destructive color as before.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:26:58 +08:00
Naiyuan Qing
4f40f70ea7 fix(skills): remove double-flicker on CreateSkillDialog close (#1610)
CreateSkillDialog used a controlled \`open\` prop while staying mounted,
so closing meant a data-open → data-closed flip on the already-mounted
Popup plus a tail re-render from \`useEffect([open])\` resetting \`method\`.
Visible as a double-blink: first the close animation, then a second
fade when the reset effect fired.

Align with the CreateIssue / CreateProject pattern: parent conditionally
renders the dialog and \`<Dialog open>\` is hard-coded. Close now unmounts
the component and Base UI's Portal owns the single exit animation. The
per-open method reset becomes unnecessary — fresh mount, fresh state.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:24:50 +08:00
LinYushen
99154d97b9 Restrict /health/realtime metrics exposure (MUL-1342) (#1608)
* Restrict /health/realtime metrics exposure (MUL-1342)

The realtime metrics endpoint was registered on the public router with
no authentication, exposing per-event/per-scope counters, redis.last_error,
and redis.node_id to anonymous callers. This enables information disclosure
and traffic profiling.

Move the handler behind a token + loopback policy:

- If REALTIME_METRICS_TOKEN is set, require Authorization: Bearer <token>
  using a constant-time compare. Reject other callers with 401 plus a
  WWW-Authenticate hint.
- If the env var is unset, only serve loopback callers and return 404 to
  remote clients so the endpoint is not enumerable. This keeps local dev
  workflows working without configuration.

The handler is extracted into health_realtime.go with focused unit tests
covering the token, loopback, and rejection paths. .env.example documents
the new variable.

Refs: https://github.com/multica-ai/multica/issues/1606

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fail closed for proxied /health/realtime requests (MUL-1342)

Addresses review on PR #1608: when the server runs behind a reverse
proxy (Caddy / Nginx -> localhost:8080), public callers reach the Go
handler with RemoteAddr=127.0.0.1, so the previous loopback shortcut
exposed the metrics surface in self-hosted deployments.

The no-token path now treats any forwarding header
(X-Forwarded-For / -Host / -Proto, X-Real-Ip, Forwarded) as a
'this request was proxied, can't attribute, fail closed' signal and
returns 404. Direct loopback callers without those headers still work
for local dev. Token-gated path is unchanged.

Tests cover all listed proxy headers (incl. multi-hop XFF chain and
RFC 7239 Forwarded) over both 127.0.0.1 and ::1, plus a regression
case ensuring an empty/whitespace forwarding header does not break
direct loopback access. .env.example updated to call out that proxied
deployments must configure REALTIME_METRICS_TOKEN.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 14:04:10 +08:00
Naiyuan Qing
7067d8f125 refactor(skills): redesign list page and add skill detail page (#1607)
* feat(core): add skill detail path and query helpers

- paths.workspace(slug).skillDetail(id) → /:slug/skills/:id
- skillDetailOptions(wsId, skillId) for fetching a single skill
- selectSkillAssignments(agents) folds the cached agent list into
  Map<skillId, Agent[]>; returns a stable reference so consumers can
  memoize against agent-array identity without re-rendering on unrelated
  agent updates

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(views): add cross-platform openExternal helper

On Electron, route through window.desktopAPI.openExternal so the
http/https-only guard in the main process kicks in — direct window.open
inside Electron opens a new renderer window instead of handing the URL
to the OS shell. On web, fall back to window.open with noopener+noreferrer.
SSR-safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(skills): extract edit-permission hook and origin helper

- use-can-edit-skill: mirrors the server's rule (admin/owner ∨ creator)
  so the UI can hide/disable actions instead of waiting for a 403. Takes
  wsId explicitly per the repo rule for workspace-aware hooks.
- lib/origin: discriminated view over Skill.config.origin (manual /
  runtime_local / clawhub / skills_sh) so consumers don't spread JSONB
  parsing across the UI tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(skills): rewrite skills list page and collapse import UI

- SkillsPage rewritten: new hero header, single table layout with
  columns (Name / Used by / Source · Added by / Updated), agent avatar
  stack per skill, filter tabs aligned with Issues/MyIssues header
  (Button variant=outline + Tooltip + bg-accent active state).
- CreateSkillDialog: dedicated dialog for the manual/import entry
  points, replaces the inline row-triggered dialog.
- runtime-local import: dialog variant deleted; panel is now the single
  entry point, embeddable inside CreateSkillDialog. Panel covered by a
  new test.
- Deleted runtime-local-skill-row (no longer needed — row rendering
  lives in SkillsPage directly) and the old skills-page.test.tsx
  (structure diverged beyond salvaging; will be re-added alongside the
  detail-page tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skills): add skill detail page and wire routes on web and desktop

- SkillDetailPage: dedicated view for a single skill (name, description,
  origin, assignments, file listing). Uses skillDetailOptions and the
  new origin / use-can-edit-skill helpers.
- apps/web: /:workspaceSlug/skills/:id Next.js route.
- apps/desktop: /:slug/skills/:id added to the memory router under
  WorkspaceRouteLayout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(skills): bump runtime-local-skill-import-panel timeouts for CI

The test chains a five-step async cascade (runtime list → setSelectedRuntimeId
effect → skills query → auto-select effect → row render). Comfortable on
local (~600ms) but tight against RTL's 1 s default on CI where jsdom +
Vitest import takes ~100s. Bump findByText and the two waitFor calls to
5 s each — no production behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:51:58 +08:00
devv-eve
9ed1fa95fc feat(server): add readiness health endpoints (#1605)
* feat(server): add readiness health endpoints

* fix(server): cache readiness checks

* fix(server): raise readiness cache ttl

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-24 13:50:24 +08:00
Naiyuan Qing
147fb2ee66 fix(autopilot): confirm before deleting autopilot or trigger (#1604)
Destructive actions in the autopilot detail page fired immediately on
click. Wrap "Delete autopilot" and per-trigger delete with AlertDialog
confirmation, matching the existing issue-delete pattern.

Also fix a latent bug in trigger deletion where the success toast was
shown synchronously after mutate(), so failures still reported success —
switch to mutateAsync + try/catch.
2026-04-24 13:11:52 +08:00
L.Amar
9c177562e2 fix(daemon/repocache): make bare repo cache keys collision-resistant 2026-04-24 13:04:08 +08:00
Naiyuan Qing
5bab95ad26 fix(issues): unify board card hover and active visual (#1603)
Hover and popup-open states now share the same bg-accent + border-accent
treatment. Drop the shadow-md hover (invisible in dark mode) and the
multi-property transition in favor of a single transition-colors.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:55:30 +08:00
Naiyuan Qing
0bd6ba9354 fix(issues): cleaner board card hover with shadow elevation (#1600)
Replace translucent tinted hover (border-accent/50 + bg-accent/20) with
a single-dimension shadow lift. The previous overlay was visually weak
because --accent is nearly identical to --card, so a 20% tint rendered
as almost no change. Active (popup-open) state now uses solid bg-accent
so hover and active are distinguished by different dimensions —
elevation vs color — instead of competing on the same axis.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:15:07 +08:00
Naiyuan Qing
40cea8454d feat(autopilot): redesign modal — simpler schema, consistent schedule UI (#1595)
Drop priority and project_id from autopilot. project_id was never exposed
in the UI and priority duplicated the agent's own task queue priority.

Redesign the create/edit modal as a Runbook (left) + Configuration (right)
layout. Rework the Schedule section around a single visual shell so every
picker aligns pixel-for-pixel on the same row:

- TimeInput (new): segmented HH:MM control adapted from openstatusHQ/time-picker,
  driven by keyboard (ArrowUp/Down to step, ArrowLeft/Right to jump segment,
  digit typing with a 2s two-digit window). Replaces <input type="time">,
  whose native UI broke the design system. Supports a minuteOnly variant
  for hourly schedules.
- TimezonePicker (new): searchable Popover with a fixed-width left check
  slot so rows stay aligned and GMT offsets never collide with the selected
  indicator.
- Runbook editor now lives in a bordered card, giving the placeholder an
  input surface instead of bare document flow.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:05:33 +08:00
Naiyuan Qing
d54daa62c5 feat(issues): right-click context menu + unified issue actions (#1594)
* feat(issues): add right-click context menu on list rows and board cards

Extract the detail page's ⋯ dropdown (~180 lines of inline JSX) into a
shared `useIssueActions` hook plus two thin wrappers so the same action
set (status / priority / assignee / due date / sub-issue ops / pin / copy
link / delete) can be mounted as both a DropdownMenu and a Base UI
ContextMenu. Right-click on any list row or board card now opens the
full action menu without entering the detail page.

Shell-level modals replace the detail-page-local state for set-parent /
add-child / delete-confirm / backlog-agent-hint, so any trigger (detail
page, list, board) can open them through `useModalStore`. Detail page
detects its own deletion via a query-transition effect, avoiding the
need to smuggle callbacks through the store.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(issues): hover and active styling on list rows and board cards

Mirror the sidebar's same-color/different-intensity pattern for the new
right-click context menu states. Base UI adds `data-popup-open` to the
ContextMenuTrigger when the menu is open; `hover:not-data-[popup-open]`
suppresses hover feedback on the already-active item.

List rows apply the pattern directly to background color (`accent/60`
hover, `accent` active). Board cards additionally modulate the card's
border and a lighter background tint (`accent/20` hover, `accent/40`
active) so the card's own bg/border/shadow identity stays intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(modals): show target issue banner in SetParent/AddChild pickers

When triggered from an issue's action menu, the IssuePickerModal now
displays a banner at the top showing "Setting parent of" / "Adding
sub-issue to" followed by the originating issue's status, identifier,
and title. Previously the operation target was only implied by the
modal's sr-only title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(modals): create-issue gains ⋯ overflow menu with parent issue linkage

Add a dropdown-menu with "Set parent issue..." / "Remove parent" at the
end of the property pill row. The ⋯ button is always the last DOM child
of the row so it stays at the tail even when the row wraps to multiple
lines. Menu state reflects current selection — unset shows a single
"Set parent…" entry, set shows the current identifier plus a separate
Remove option.

When a parent is set (either via the new menu or via `data.parent_issue_id`
from a "Create sub-issue" trigger), a chip appears in the pill row
showing "Sub-issue of {identifier}" with the same click-to-change /
click-×-to-clear semantics. This replaces the old header breadcrumb
disclosure that was neither editable nor visible in the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(issues): group relationship actions under "More" submenu

Nest Create sub-issue / Set parent issue / Add sub-issue inside a
`More >` submenu in the issue actions menu (both Dropdown and
Context variants). Top-level keeps Status/Priority/Assignee/Due date
category submenus plus Pin and Copy link; the relationship ops are
lower-frequency and will grow with future relation types (blocks,
duplicates, related) that fit the same category.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(modals): create-issue adds Add sub-issue with deferred linking

The create modal's ⋯ menu gains an "Add sub-issue..." entry that queues
existing issues as children of the new one. Picked issues appear as
chips in the pill row (downward arrow, distinct from the upward parent
chip), each individually removable.

Linking is deferred because the new issue's ID doesn't exist at pick
time. Once createIssueMutation resolves, we run updateIssueMutation
for every queued child in parallel and surface any partial failures
via toast — the new issue itself is already committed and never rolls
back. Parent and child pickers exclude each other so a single issue
can't occupy both relations simultaneously.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(issues): add MoreHorizontal icon to "More" submenu trigger

The "More" label was visually misaligned because every other top-level
entry has a leading icon. Use MoreHorizontal (same icon as the outer ⋯
trigger — semantically "more options, nested") and drop the `inset`
padding hack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(modals): drop target-issue banner from IssuePickerModal

The banner sat directly above the search input and rendered the target
issue with bolder styling than the "Setting parent of" / "Adding sub-issue
to" caption, which made it read like a pre-selected search result rather
than a context label. Users opening the modal from a menu item already
carry the context, so the extra chrome was redundant.

Remove the contextIssue / contextLabel API from IssuePickerModal and
drop the now-unused issueDetailOptions query in SetParentIssueModal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(modals): exclude current parent from create-issue parent picker

Re-opening the parent picker to change the already-set parent used to
show that parent in the results — picking it was a silent no-op. Mirror
the child picker's exclude-list construction so the current parent is
always filtered out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:48:46 +08:00
469 changed files with 35829 additions and 6528 deletions

View File

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

View File

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

View File

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

View File

@@ -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 |
@@ -192,6 +195,10 @@ 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 |
### Self-Hosted Server

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";
@@ -214,6 +214,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);

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ 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.

View File

@@ -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&apos;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 &ldquo;{truncateValue(first.message, 48)}
&rdquo; 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)}
</>
);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -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&apos;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>
);
}

View File

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

View File

@@ -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({});
});
});

View 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,
};
}

View File

@@ -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,
};

View File

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

View 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} />;
}

View 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} />;
}

View 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]);
}

View File

@@ -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 ?? "/",

View File

@@ -10,6 +10,8 @@ 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";
@@ -18,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";
@@ -118,6 +119,11 @@ 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",
@@ -125,8 +131,12 @@ export const appRoutes: RouteObject[] = [
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: (

View File

@@ -101,7 +101,6 @@ interface TabStore {
const ROUTE_ICONS: Record<string, string> = {
inbox: "Inbox",
chat: "MessageSquare",
"my-issues": "CircleUser",
issues: "ListTodo",
projects: "FolderKanban",

View File

@@ -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.";
}
}

View File

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

View File

@@ -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 生态用户 |
另外 5Hermes、Kimi、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
另外 6Hermes、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) —— 创建完之后怎么用起来

View File

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

View File

@@ -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) —— 让智能体定时自动开工

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ Multica 有三种令牌,对应三种使用场景:浏览器 Web UI、命令
2. 输入验证码server 签发 JWT cookie浏览器或交换出 PATCLI
<Callout type="warning">
**自部署运维注意**如果环境变量 `APP_ENV` 不是 `production`,验证码恒为 `888888`——任何人能登录任何账号。详见 [自部署的认证配置](/auth-setup)。
**自部署运维注意**公网部署时保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。如果启用固定本地测试验证码,在 `APP_ENV` production 时,任何能请求验证码的人都能用该固定值登录。详见 [自部署的认证配置](/auth-setup)。
</Callout>
### Google OAuth

View 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

View File

@@ -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可以手动触发一次
- UIRoutine 详情页点"手动运行"
- UIAutopilot 详情页点"手动运行"
- 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` 和手动触发是端到端可用的**。
## 下一步

View File

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

View File

@@ -59,5 +59,5 @@ import { Callout } from "fumadocs-ui/components/callout";
## 下一步
- [**Routines**](/routines) —— 让智能体定时自动开工
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
- [**分配 issue 给智能体**](/assigning-issues) —— 把话题放回 issue 看板上

View File

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

View File

@@ -74,15 +74,13 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
## RoutinesCLI 命令名:`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>` | 查看运行历史 |

View File

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

View File

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

View File

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

View File

@@ -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) —— 在自己服务器上跑一套

View File

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

View File

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

View File

@@ -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、chatMultica 会自动重新排队;Routines 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`。对可重试的来源issue、chatMultica 会自动重新排队;Autopilots 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
</Callout>
## 一次能并发跑多少任务
@@ -108,4 +108,4 @@ Multica 对并发有两层限额:
## 下一步
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照

View File

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

View File

@@ -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 自动起它,但行为一样)

View File

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

View File

@@ -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>
### 数据库连接池

View File

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

View File

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

View File

@@ -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) |
## 运行时:在哪里跑,跑几家工具

View File

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

View File

@@ -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) 登记邮箱以获取通知。

View File

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

View File

@@ -54,5 +54,5 @@ import { Callout } from "fumadocs-ui/components/callout";
## 下一步
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
- [**Routines**](/routines) —— 让智能体定时自动开工
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
- [**评论**](/comments) —— `@mention` 的语法、picker、`@all` 的语义

View File

@@ -22,7 +22,7 @@
"assigning-issues",
"mentioning-agents",
"chat",
"routines",
"autopilots",
"---Inbox---",
"inbox",
"---Self-hosting & ops---",

View File

@@ -22,7 +22,7 @@
"assigning-issues",
"mentioning-agents",
"chat",
"routines",
"autopilots",
"---收件箱---",
"inbox",
"---自部署运维---",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 页的提示)

View File

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

View File

@@ -64,4 +64,4 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
- [守护进程与运行时](/daemon-runtimes) —— 智能体到底跑在哪、怎么判断在线 / 离线
- [执行任务](/tasks) —— 一次"智能体工作"的完整生命周期
- [AI 编程工具对照](/providers) —— 10 款工具的完整对比(含每款的 Skill 注入路径)
- [AI 编程工具对照](/providers) —— 11 款工具的完整对比(含每款的 Skill 注入路径)

View File

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

View File

@@ -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) —— 触发执行任务的四种方式

View File

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

View File

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

@@ -0,0 +1 @@
.vercel

View File

@@ -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} />;
}

View File

@@ -1 +0,0 @@
export { ChatPage as default } from "@multica/views/chat";

View File

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

View File

@@ -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} />;
}

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ export type LandingDict = {
steps: { title: string; description: string }[];
cta: string;
ctaGithub: string;
ctaDocs: string;
};
openSource: {
label: string;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,802 @@
# Agent / Runtime 状态系统重设计
> **文档定位**:这是一份完整的设计 + 实施方案。任何一个新加入的工程师 / 设计师 / 产品,看完这份文档应该能:
> - 理解我们要解决的问题、为什么这么解决
> - 知道每个阶段做什么、按什么顺序做、产出什么
> - 在不读代码的前提下能独立讨论方案
>
> 本文档是 [agent-status-design-brief.md](./agent-status-design-brief.md) 和 [agent-status-redesign-plan.md](./agent-status-redesign-plan.md) 的合并升级版,达成共识后会取代它们。
---
## 目录
1. [背景与目标](#一背景与目标)
2. [核心思想](#二核心思想)
3. [状态系统规范](#三状态系统规范)
4. [数据架构](#四数据架构)
5. [跨平台策略](#五跨平台策略)
6. [设计语言](#六设计语言)
7. [实施分阶段](#七实施分阶段)
8. [验收标准](#八验收标准)
9. [边界与不做的事](#九边界与不做的事)
10. [风险与注意事项](#十风险与注意事项)
11. [参考](#十一参考)
---
## 一、背景与目标
### 1.1 Multica 的产品定位提醒
Multica 是 AI-native 的任务管理平台——agent 是和人对等的"同事"。一个工作区里同时有人和 agent 在协作,相互分配任务、评论、订阅。
理解这个定位很重要,因为它直接决定了状态系统的需求:**用户对 agent 的预期跟"对同事的预期"是相同的**——我希望随时知道这个同事现在能不能接活、在不在线、是不是出问题了。
### 1.2 当前的核心问题
**所有界面都在直接展示后端的原始字段,缺少"用户视角"的状态翻译层。**
具体表现(按用户感知严重度排序):
1. **Agent 列表的 "Idle" 绿点会骗人**——daemon 已经死了agent 仍然显示 Idle。用户分配任务后没有任何反馈长时间困惑"为什么 agent 不动"。
2. **Runtime 列表只有一个圆点**——"刚断线 5 分钟"和"3 个月前断线"视觉一模一样,用户判断不出严重程度。
3. **Issue 详情页多个 agent 同时工作时只有 1 个可见**——其他 agent 的卡片埋在下方滚动区,没有总览。
4. **任务失败时只有红色 X**——`agent_error`agent 自己挂了)和 `runtime_offline`daemon 离线)处理方式完全不同,但视觉上不区分。
5. **Chat 发消息后只有一个 spinner 转数分钟**——无法区分"排队"、"思考"、"调用工具"、"生成回答"四个阶段。
### 1.3 根本原因
后端字段是**任务调度的内部状态**`agent.status` = idle/working/blocked/error不是给用户看的。当前前端把后端字段直接渲染给用户就把"调度内部视角"暴露成了"用户视角"。
但用户不关心 agent 的内部调度状态,用户关心的是:
- **能不能用?**(在线 / 离线)
- **如果在用,在干什么?**(工作中 / 排队中 / 失败了)
- **如果不能用,为什么?**daemon 离线 / CLI 没装 / 任务超时)
这些问题的答案,**没有任何一个能从单一字段直接得到**——它们都需要把多个数据源聚合后才能算出来。
### 1.4 目标
**做一套"用户视角"的状态系统**,覆盖三类对象:
- **Agent**5 态available / working / pending / failed / offline跨界面一致
- **Runtime**4 态online / recently_lost / offline / about_to_gc
- **Task**阶段化queued / dispatched / thinking / using_tool / generating / completed / failed
完成这套系统后,下列现有界面会自然变好:
- Agents 列表 / 详情:看到真实可用性
- Runtimes 列表 / 详情:看到机器健康度
- Issue Detail多 agent 全景 + 失败原因显示
- 跨界面 hover cardissue assignee / autopilot / chat / @mention):状态一致
- Chat分阶段进度
---
## 二、核心思想
### 2.1 一句话
**把"用户视角的状态"做成前端的派生量**——后端只暴露真相(任务存在、心跳到达),前端按 UI 需要把这些真相聚合成"用户能理解的状态"。
### 2.2 三个设计原则
#### 原则 1派生函数住在前端不污染后端
`agent.status` / `runtime.status` 这些后端字段是 **物理事实**
- "task X 现在 running"
- "daemon Y 45 秒前发了心跳"
而 "Available / Working / Pending / Failed / Offline" 这些是 **UI 翻译**
- "FAILED 状态保持 2 分钟" 是设计决策
- "蓝色表示 working" 是视觉决策
- 不同界面可能要不同视角issue 里看"任务阶段"、列表里看"是否可分配"
**UI 翻译应该住在前端,跟着设计需求一起迭代。** 把它放进后端,每改一次都要 migration + WS payload 兼容 + 老客户端处理,迭代周期从分钟变周。
#### 原则 2服务器状态住在 TanStack Query cache不复制进 Zustand
我们的全局状态有两套:
- **Zustand** 管 client stateUI 选中、筛选器、modal 开关)
- **TanStack Query cache** 管 server state来自 API 的所有数据)
TQ cache 不是组件级缓存,**它本身就是 server state 的全局状态管理**。跨组件共享、按 key 索引、自动去重。
派生状态是 server data 的纯函数。结果不存——每次组件渲染时按需用 `useMemo` 算一遍。算的成本是几个 filter + some 调用,可忽略。
#### 原则 3聚合在前端做但要避免 N+1
派生状态需要 3 份原始数据:
- agents 列表
- runtimes 列表
- 当前活跃任务列表active tasks
朴素做法:每个组件 `useAgentTasks(agentId)`——一个 issues 列表 30 个 agent 头像 = 30 次请求。这就是 N+1。
正解:**进工作区时一次性拉"全工作区的活跃任务"**(数据量天然不大,活跃任务永远是少数),存进 TQ cache。所有组件共享这一份缓存按 agentId 在内存里 filter——零额外请求。
这是把"per-agent 的数据需求"转换成"全工作区的集合数据需求"。集合数据天然只需要 1 次请求。
---
## 三、状态系统规范
### 3.1 Agent 五态
| 状态 | 颜色 | 用户语义 | 出现条件 |
|---|---|---|---|
| **Available** | 🟢 绿 | 在线空闲,可以接活 | runtime 在线 + 没有活跃任务 |
| **Working** | 🔵 蓝 | 正在干活 | runtime 在线 + 至少一个任务在执行 |
| **Pending** | 🟡 黄 | 任务排着但没在跑 | runtime 在线 + 0 个执行中 + ≥1 个排队 |
| **Failed** | 🔴 红 | 最近一次失败 | 最近 2 分钟内有任务失败 |
| **Offline** | ⚫ 灰 | Daemon 离线,不可用 | runtime 离线(包含 CLI 未安装) |
**复合维度**:当 agent 是 Working 但同时有任务排队,主状态保持 Working旁边带 `+N` 角标("还有 N 个排队")。
**派生规则**(按优先级匹配,命中即返回):
```ts
type AgentPresence = "available" | "working" | "pending" | "failed" | "offline"
function deriveAgentPresence(input: {
agent: Agent
runtime: AgentRuntime
recentTasks: AgentTask[] // 该 agent 最近 N 个任务
now: number // 当前时间戳
}): AgentPresence {
// 1. Runtime 离线(含 CLI 未安装)→ offline
if (input.runtime.status === "offline") return "offline"
// 2. 最近窗口内有 failed task → failed
const recentFailed = input.recentTasks.find(
t => t.status === "failed" &&
(input.now - new Date(t.completed_at).getTime()) < FAILED_WINDOW_MS
)
if (recentFailed) return "failed"
// 3. 有 running task → working
if (input.recentTasks.some(t => t.status === "running")) return "working"
// 4. 有 queued/dispatched task → pending
if (input.recentTasks.some(t => t.status === "queued" || t.status === "dispatched")) {
return "pending"
}
// 5. Otherwise → available
return "available"
}
```
**复合维度的派生**
```ts
function deriveAgentPresenceDetail(...): {
presence: AgentPresence
runningCount: number
queuedCount: number
failureReason?: TaskFailureReason // 仅 presence === "failed" 时有值
}
```
**待确认常量**
- `FAILED_WINDOW_MS = 2 * 60 * 1000`2 分钟)。
- 短到避免污染(任务失败的红点不会一直黏着)
- 长到让用户能看见(不会还没看就消失)
- **未来可能扩展**2 分钟内强提示(红色动效),之后降级为 tooltip 的"最近失败"摘要——这样既不刺眼,又不丢信息。本期先用固定 2 分钟。
### 3.2 Runtime 四态
| 状态 | 触发条件 | 用户语义 |
|---|---|---|
| **Online** | 最近 45 秒内有心跳 | 健康,能接任务 |
| **Recently Lost** | 离线但 < 5 分钟 | 可能短暂网络抖动 |
| **Offline** | 离线 5 分钟 ~ 7 天 | 长期离线,需要排查 |
| **About to GC** | 离线接近 7 天阈值 | 系统将自动清理 |
```ts
function deriveRuntimeHealth(runtime: AgentRuntime, now: number): RuntimeHealth {
if (runtime.status === "online") return "online"
const lastSeen = runtime.last_seen_at
? new Date(runtime.last_seen_at).getTime()
: 0
const offlineFor = now - lastSeen
if (offlineFor < 5 * 60 * 1000) return "recently_lost"
if (offlineFor > 6 * 24 * 3600 * 1000) return "about_to_gc" // 7d - 1d
return "offline"
}
```
> **关于 CLI 未安装等"runtime 在线但跑不了"的场景**:归并到 `offline`tooltip 写明具体原因("CLI 未安装"、"Daemon 启动中")。不为此引入第六态——状态空间膨胀代价高,分辨率应该用 tooltip / detail 而不是顶层枚举。
### 3.3 Task 阶段化
用于 Issue Detail 和 Chat 显示当前任务在哪一步:
```ts
type TaskStage =
| "queued"
| "dispatched" // dispatched 但进程没起来
| "thinking" // running最后一条 message 是 thinking
| "using_tool" // running最后一条是 tool_use / tool_result
| "generating" // running最后一条是 text
| "completed"
| "failed"
| "cancelled"
```
派生函数读 task 状态 + 最后一条 message 的 type 即可决定阶段。
### 3.4 失败原因映射
`task.failure_reason` 来自后端 migration 0555 个值映射到中文标签:
```ts
const FAILURE_REASON_LABEL: Record<TaskFailureReason, string> = {
agent_error: "Agent 执行报错",
timeout: "执行超时",
runtime_offline: "Daemon 离线",
runtime_recovery: "Daemon 重启回收",
manual: "用户取消",
}
```
每种原因要给用户对应的处理建议(具体文案待设计师定)。
> ⚠️ **当前差异**:后端 schema 已有 `failure_reason`migration 055但前端 `packages/core/types/agent.ts` 的 `AgentTask` 接口**未暴露此字段**。阶段 0 必须同步:
> - 前端类型补 `failure_reason: TaskFailureReason | null`
> - 检查后端 `ListAgentTasks` / 新增的 `ListActiveTasksByWorkspace` 是否 SELECT 了这个字段
---
## 四、数据架构
### 4.1 数据流
```
后端真相
┌────────────┬─────────────┬──────────────┐
│ agents │ runtimes │ active_tasks│
│ (HTTP) │ (HTTP) │ (HTTP) │
└─────┬──────┴──────┬──────┴───────┬──────┘
│ │ │
└─────────────┴──────────────┘
TanStack Query cache
(全局共享)
派生函数(纯函数)
┌───────────────┴───────────────┐
▼ ▼
AgentPresence RuntimeHealth
│ │
▼ ▼
组件渲染5 态视觉) 组件渲染4 态视觉)
──────────────────────────────────────────────
实时更新
后端 WS 事件 ──→ 前端 invalidate query ──→ 重拉 ──→ 派生重算 ──→ UI 更新
桌面端额外:本机 IPC ──→ setQueryData 直接预填 ──→ 亚秒级响应
```
### 4.2 三个 query
```ts
// 进工作区时一次性拉
useQuery(['ws', wsId, 'agents']) // listAgents
useQuery(['ws', wsId, 'runtimes']) // listRuntimes
useQuery(['ws', wsId, 'active-tasks']) // ★ 新增getActiveTasksForWorkspace
```
**`active-tasks` 是新增 query**——返回当前工作区所有 status ∈ {queued, dispatched, running} 的任务。这份数据天然小(活跃任务不会很多),值得做成"全工作区一次拉"。
> ⚠️ **当前差异**:后端**没有此 endpoint**。现有只有 `listAgentTasks(agentId)`per-agent和 `getActiveTasksForIssue(issueId)`per-issue。阶段 0 必须新增 `GET /api/workspaces/:slug/active-tasks`——它仍然返回原始 task 列表,不做任何派生,**不违反"零后端聚合"原则**。
#### Runtime → Agents 是 reverse query
后端没有 "runtime 服务的 agent 列表" API。Agent 持有 `runtime_id` 外键,但 Runtime 没有反向关联。
**前端处理**:从已缓存的 `agents` 列表 `filter(a => a.runtime_id === rtId)` 即可。这不算 N+1agents 列表本来就要拉),是免费的 join。
### 4.3 WS 事件接线表
| WS 事件 | 触发的 invalidate |
|---|---|
| `agent:created` / `agent:archived` / `agent:updated` | `['ws', wsId, 'agents']` |
| `agent:status` | `['ws', wsId, 'agents']`(即使我们不用这个值,缓存 fresh 仍要) |
| `daemon:register` | `['ws', wsId, 'runtimes']` |
| `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` | `['ws', wsId, 'active-tasks']` |
| `task:progress` | (考虑节流,避免高频任务刷新缓存) |
| `daemon:heartbeat` | **故意忽略** —— 后端发送但前端不订阅,避免每 15 秒过度 refetch。后果runtime online → recently_lost 切换最坏延迟 75 秒45s sweeper + 30s 间隔)。设计上接受这个延迟。 |
**关键不变量**:每个派生函数依赖的字段都必须有对应 WS 事件覆盖。任何字段没事件覆盖,状态会卡住。每次新增派生维度都要回头检查这张表。
### 4.4 桌面端 IPC 桥接
桌面端通过 Electron IPC 直接读本机 daemon。这份数据
- 比 server WS **快 75 秒**IPC 亚秒server sweep 最坏 75s
- 包含 server 看不到的 `starting` / `stopping` / `cli_not_found` 等中间态
**不修改派生函数签名**——桌面端把 IPC 数据用 `setQueryData` 直接写进 runtime cache
```ts
// 仅桌面端apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts
window.electron.onDaemonStatus((status) => {
queryClient.setQueryData(
['ws', wsId, 'runtimes'],
(old) => old?.map(rt =>
rt.id === status.runtimeId ? mergeDaemonStatus(rt, status) : rt
)
)
})
```
派生函数完全不知道数据从哪来——它只读 cache。**桌面端自动获得亚秒级体验,零派生函数改动**。
---
## 五、跨平台策略
### 5.1 状态系统是平台无关的
派生函数、5 态/4 态规范、UI 视觉——**两端共享同一套**。设计稿只画一份。
### 5.2 数据源平台相关
| 平台 | Runtime 数据来源 | 状态变化感知延迟 |
|---|---|---|
| Web | server WS / HTTP | 最坏 75 秒 |
| Desktop本机 daemon | IPC + server | < 1 秒 |
| Desktop别人的 daemon | server WS / HTTP | 跟 Web 一样 |
数据源不同不影响 UI——派生函数读 cachecache 是"哪边更新就更新"。
### 5.3 操作能力平台相关
| 操作 | Web | Desktop自己机器 | Desktop别人机器 |
|---|---|---|---|
| 看状态 | ✅ | ✅ | ✅ |
| 重启 daemon | ❌ | ✅ | ❌ |
| 看 daemon logs | ❌ | ✅ | ❌ |
| 看 CLI 安装详情 | ❌ | ✅ | ❌ |
实现方式:组件按 `isLocalDaemon && isOwner` 条件渲染按钮。**不需要写进派生函数 / 状态系统**,是 UI 局部决策。
### 5.4 Daemon 卡片视觉对齐
桌面端 settings 里的"本机 daemon 卡片"必须跟云端 runtime 列表项视觉一致。同一台机器同一个概念,不能两套设计。
---
## 六、设计语言
### 6.1 直接复用 Skills 界面PR #1607、#1614、#1618、#1610
Skills 界面已经在 2026-04 完成重设计是当前产品的视觉锚点。Agents / Runtimes 直接照搬其规则:
1. **统一页头** `PageHeader`h-12 + mobile sidebar trigger
2. **响应式网格列表**`grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.2fr)_minmax(0,6rem)_auto]`
3. **每行三层信息**:主标题 → 描述line-clamp-1 muted→ 元数据xs muted
4. **关联对象用头像堆栈**:最多 3 + `+N`size=22 + `ring-2 ring-background` + `-space-x-1.5`
5. **卡片化列表 + 卡片内工具栏**:搜索和 scope tab 在 `CardToolbar`h-12不在页面级
6. **创建用多步 dialog**chooser → 表单可回退宽度按方法切换300ms 过渡
7. **空状态分文案**:图标 + 标题 + 三行说明 + 清晰 CTA
8. **长列表 `useScrollFade`**:上下边缘淡出
9. **头像统一 `ActorAvatar`**:传 `size`,自动支持 agent / 人
10. **权限检查 hook 化**`useCanEdit...`UI 提前隐藏/禁用
### 6.2 状态视觉的三个层级
每种派生状态在三个层级要保持一致:
- **Dot**(圆点):列表项、头像旁,最紧凑
- **Badge**(徽章):详情页头部、卡片角落,带图标 + 文字
- **Tooltip / Hover Card**:鼠标悬停展开完整信息
**跨界面一致性**:同一个 agent 无论出现在哪agents 列表 / issue assignee picker / autopilot 编辑 / chat 选择面板 / 评论 @),状态视觉必须完全一致。
---
## 七、实施分阶段
### 阶段 0 — 数据层地基(无 UI 改动)
**目标**:派生函数 + cache + WS + IPC 桥接全部就位。UI 暂不动。完成后所有 UI 阶段都能放心假设"派生状态可用、零额外请求"。
#### 后端工作Go
| 文件 | 改动 |
|---|---|
| `server/pkg/db/queries/agent_task.sql` | 新增 sqlc query`ListActiveTasksByWorkspace`SELECT 所有字段含 `failure_reason`,过滤 status ∈ {queued, dispatched, running} |
| `server/pkg/db/agent_task.sql.go` | `make sqlc` 自动生成 |
| `server/internal/handler/agent.go`(或新建 task handler | `ListActiveTasksByWorkspace` handler权限校验用户必须是 workspace member |
| `server/cmd/server/router.go` | 注册路由 `GET /api/workspaces/{slug}/active-tasks` |
| **核查**:现有 `ListAgentTasks` query | 确认 SELECT `failure_reason` 字段;如未 SELECT补上 |
#### 前端类型补全
| 文件 | 改动 |
|---|---|
| `packages/core/types/agent.ts` | `AgentTask` 接口加 `failure_reason: TaskFailureReason \| null`;新增 `TaskFailureReason = "agent_error" \| "timeout" \| "runtime_offline" \| "runtime_recovery" \| "manual"` |
#### 前端 API client
| 文件 | 改动 |
|---|---|
| `packages/core/api/client.ts` | 新增方法 `getActiveTasksForWorkspace(wsSlug): Promise<AgentTask[]>` |
#### 前端派生函数 + 类型
| 文件 | 内容 |
|---|---|
| `packages/core/agents/types.ts`(如不存在则新建) | `AgentPresence` / `AgentPresenceDetail` / `RuntimeHealth` 等类型 |
| `packages/core/agents/derive-presence.ts` | `deriveAgentPresence` / `deriveAgentPresenceDetail` 纯函数 |
| `packages/core/agents/derive-presence.test.ts` | 5 态全分支 + 边界 caseruntime null / tasks 空 / 时钟边界) |
| `packages/core/runtimes/derive-health.ts` | `deriveRuntimeHealth` |
| `packages/core/runtimes/derive-health.test.ts` | 4 态全分支 |
#### 前端 query + hook
| 文件 | 内容 |
|---|---|
| `packages/core/agents/active-tasks-query.ts` | `activeTasksOptions(wsId)` query options |
| `packages/core/agents/use-agent-presence.ts` | `useAgentPresence(agentId)` hook读 3 份 cache → 派生 |
| `packages/core/runtimes/use-runtime-health.ts` | `useRuntimeHealth(runtimeId)` hook |
| `packages/core/runtimes/use-runtime-agents.ts` | `useRuntimeAgents(runtimeId)` hook从 agents cache filter 出绑定的 agents |
#### 前端 WS 接线
| 文件 | 改动 |
|---|---|
| `packages/core/realtime/agent-runtime-sync.ts` | 新增专用 sync。订阅 `agent:*` / `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` / `daemon:register` → invalidate 对应 query。**显式不订阅 `daemon:heartbeat`**(接受 75 秒延迟) |
| `packages/core/realtime/use-realtime-sync.ts`(如已有全局 hook | 集成新 sync |
#### 桌面端 IPC 桥接
| 文件 | 内容 |
|---|---|
| `apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts` | 监听 `window.daemonAPI.onStatus(...)`,用 `queryClient.setQueryData` 把本机 daemon status merge 进对应 runtime cache |
#### 完成标准
- [ ] 后端 `GET /api/workspaces/:slug/active-tasks` 通 curl 测试,返回 active tasks 列表
- [ ] `deriveAgentPresence` / `deriveRuntimeHealth` 单测全部通过
- [ ] 控制台调用 `useActiveTasks(wsId)` 能拿到全工作区活跃任务
- [ ] 控制台调用 `useAgentPresence(agentId)` 能拿到正确的 5 态状态
- [ ] WS 接线表里所有事件都能正确 invalidate手测覆盖
- [ ] 关本机 daemon 后桌面端 runtime cache **1 秒内**变 offline
- [ ] 不动任何 UI 文件——这阶段 zero UI delta
### 阶段 1 — Agents + Runtimes 列表页
**目标**:两个列表用上派生状态,互相能看到对方。
#### 设计师产出(先于代码)
- 5 态 dot / badge / tooltip 三层视觉规范
- Working + 排队角标的复合视觉
- Failed 状态的 2 分钟时间窗口动效
- Runtime 4 态的视觉差异(不能再是同一个浅灰圆点)
#### 改造文件
| 文件 | 改动 |
|---|---|
| `packages/views/agents/components/agent-list-item.tsx` | 替换 `statusConfig[agent.status]``useAgentPresence(agentId)` |
| `packages/views/agents/components/agents-page.tsx` | 接 WS 订阅 |
| `packages/views/agents/config.ts` | 删除 `statusConfig`,新建 `presenceConfig` |
| `packages/views/runtimes/components/runtime-list.tsx` | 用派生 4 态;展示 last_seen、关联 agent 数、当前任务数 |
| `packages/views/runtimes/components/runtimes-page.tsx` | 接 WS 订阅 |
| `apps/desktop/.../local-daemon-card.tsx` | 视觉对齐云端 runtime 卡片 |
### 阶段 2 — Agents + Runtimes 详情页
**目标**详情页头部、profile card 状态联动Runtime token usage 信息架构整理。
#### 改造文件
| 文件 | 改动 |
|---|---|
| `packages/views/agents/components/agent-detail.tsx` | 头部 status badge 用派生 |
| `packages/views/agents/components/agent-profile-card.tsx` | 状态行和 runtime 行联动;展示当前任务数 + 最近失败原因 |
| `packages/views/runtimes/components/runtime-detail.tsx` | Token usage 主次重排核心指标置顶5 个图表折叠 / 下沉 |
| `packages/views/runtimes/components/usage-section.tsx` | API 调用按时间窗口拉(不再总是 90 天) |
### 阶段 3 — Issue Detail 任务展示
**目标**:多 agent 全景视图;任务阶段化;失败原因显式。
#### 新增文件
| 文件 | 内容 |
|---|---|
| `packages/core/agents/derive-task-stage.ts` | `deriveTaskStage` |
| `packages/core/agents/derive-task-stage.test.ts` | 单测 |
| `packages/views/issues/components/agent-task-row.tsx` | 单 agent 单任务一行 |
#### 改造文件
| 文件 | 改动 |
|---|---|
| `packages/views/issues/components/agent-live-card.tsx` | 从"sticky 一个 + 折叠列表"改为"每个 agent 一行" |
| `packages/views/issues/components/agent-transcript-dialog.tsx` | 失败时展示 failure_reason |
### 阶段 4 — 跨界面 Hover Card
**目标**:所有 agent 头像出现的位置都用统一的 hover card。
#### Hover Card 必须显示的内容(按重要度排序)
1. 派生 5 态状态
2. Runtime 健康(在线性 + last_seen 相对时间)
3. 当前任务N running / M queued
4. 最近失败(如果有):原因 + 时间
5. Agent 名称 + description
6. 关联 skills前 3 个 + `+N`
7. Owner
#### 必须接入的位置
| 位置 | 当前状态 |
|---|---|
| Agents 列表 / 详情 | ✅ 已有 |
| Issue Assignee Picker | ❌ 仅头像无状态 |
| Issue Detail 头部 assignee | ❌ 仅头像无状态 |
| Issue 列表 / 看板的分配头像 | ❌ 仅头像 |
| Autopilot 列表 / 编辑assignee | ❌ 仅头像 |
| Project lead picker | ❌ 仅头像 |
| Chat 选择 agent 面板 | ❌ 待确认 |
| 评论里的 @agent | ❌ 仅头像 |
#### 实施
`ActorAvatar` 组件挂载 hover card——一处改动上面所有位置自动获得统一卡片。
**N+1 风险已经被阶段 0 的"全工作区 active-tasks"消除**——hover card 只读 cache零额外请求。
### 阶段 5 — Chat 状态分阶段(独立 PR
工作量较大,跟流式渲染相关,单独排期。
#### 改造文件
| 文件 | 改动 |
|---|---|
| `packages/views/chat/components/chat-message-list.tsx` | `AssistantMessage``deriveTaskStage` 替代单 spinner |
| `packages/views/chat/components/chat-page.tsx` | WS 断线重连后的消息回拉 fallback |
#### 必须解决
- 取代单 spinner按阶段显示
- Failed task 显示原因
- WS 断线重连后能拉回历史消息
#### 加分项
- Typing indicatorgenerating 阶段的逐字感)
- 全局任务进度 FAB
- Stop 按钮的明确反馈
---
## 八、验收标准
### 阶段 0
- [ ] `deriveAgentPresence` / `deriveRuntimeHealth` 单测覆盖所有分支 + 边界 caseruntime null / tasks 空 / 时钟边界)
- [ ] 控制台调用 `useActiveTasks(wsId)` 能拿到数据
- [ ] WS 事件接线表里每个事件都能正确 invalidate手测
- [ ] 桌面端关本机 daemon 后 runtime cache 在 1s 内变 offline
### 阶段 1
- [ ] Daemon 关闭后Agent 列表项 75 秒内变成 Offline 灰点
- [ ] Agent 跑任务时,列表项变成 Working 蓝点;排队 N 个时带 `+N` 角标
- [ ] Agent 任务失败后,列表项 2 分钟内显示 Failed 红点 + tooltip 含失败原因2 分钟后自动恢复
- [ ] Runtime 列表能区分 Online / Recently Lost / Offline / About to GC 四态
- [ ] Runtime 列表行展示关联 agent 数 + 当前任务数
- [ ] 桌面端本机 daemon 卡片视觉跟云端 runtime 列表项一致
- [ ] 全局 grep `agent.status` 在 views 层无引用
### 阶段 2
- [ ] Agent 详情头部状态跟列表一致
- [ ] Profile card 状态行 + runtime 行不再自相矛盾
- [ ] Runtime 详情页能在不展开图表的前提下看到本期成本
- [ ] Token usage API 按选中的时间窗口拉(不再总是 90 天)
### 阶段 3
- [ ] 同一 issue 多 agent 工作时,每个 agent 一行实时状态
- [ ] queued / dispatched / running 三态视觉差异清晰
- [ ] 任务失败时,行内显示中文 failure reason + 处理建议
- [ ] 不支持 live log 的 provider 显式说明"等任务完成后查看结果"
### 阶段 4
- [ ] 所有展示 agent 头像的位置 hover 都能看到完整状态卡
- [ ] 渲染 30+ agent 头像的页面hover 不触发任何新 HTTP 请求
### 阶段 5
- [ ] Chat 中能看到任务在哪个阶段
- [ ] WS 断线后重连能补齐历史消息
- [ ] Failed task 显示原因
---
## 九、边界与不做的事
明确**不在本次范围内**
1. **不改后端 schema**——`agent.status` / `blocked` / `error` 字段保留(迁移风险大,无收益)
2. **不让后端做派生**——所有派生在前端做(迭代速度 + UI 解耦)
3. **不新增 WS 事件类型**——只订阅现有但未用的(如 `agent:status`
4. **不动 `agent_runtime.status` 的 online/offline 二态**——前端从这两态 + `last_seen_at` 派生四态
5. **不重构 Skills 界面**——它已经是参考样板
6. **Phase 5 不做"逐字流式渲染"**——后端 stream-json 是批量推送typing indicator 是视觉技巧
7. **不做"agent 健康综合评分"**——只暴露原始信号,不算综合分
8. **不为 CLI 未安装等场景引入第六态**——归到 offline 的 tooltip 子分类
9. **不在 Zustand 里存派生结果**——server data 不能复制进 store
---
## 十、风险与注意事项
### 10.1 WS 事件覆盖完整性
派生函数的"实时性"靠的是依赖 query 都被 WS 正确 invalidate。一个事件没接好状态就会卡住。
**缓解**:阶段 0 的接线表是必须维护的"契约"。每次新增派生维度,回头检查这张表,确保新依赖的字段有事件覆盖。
### 10.2 时钟一致性
Failed 状态依赖客户端时间和 `completed_at` 的差。客户端时钟漂移会让 2 分钟窗口不准。
**缓解**2 分钟 ± 30 秒不影响判断,可接受。需要时可用 server 在 WS 心跳里携带的时间作为参考。
### 10.3 旧字段引用残留
`statusConfig` 删除后,遗漏的引用会运行时错误。
**缓解**
- TypeScript 严格模式 + 改类型
- 全局 grep 验证
- 长期防御:在 `packages/core/agents/types.ts``agent.status` 从外部消费的 Agent 类型里 omit 掉,仅保留在 RawAgent 内部类型里给 API 层用
### 10.4 跨界面状态一致性
不同地方调用同一个 agent 的派生函数,必须结果一致。
**缓解**:所有调用方走 `useAgentPresence(agentId)` 这个唯一 hook不允许直接调派生函数。Hook 集中管理输入数据收集。
### 10.5 active-tasks 数据量
如果工作区某天有 1000+ 活跃任务,全工作区一次拉的设计会受影响。
**缓解**
- 当前活跃任务有天然上限(受 `max_concurrent_tasks` × agent 数量约束)
- 监控加上cache 大小超过阈值时上报
- 必要时再考虑 windowing最近 N 个)或 server 端聚合
---
## 十一、参考
- [产品全景文档](./product-overview.md) —— Agent / Runtime / Daemon 的产品定位
- [Skills 界面源代码](../packages/views/skills/) —— 设计语言样板
- 关键 PR#1607Skills 重设计)、#1614Card + PageHeader#1618(描述恢复)、#1610Dialog 闪烁修复)
- 后端关键代码:
- `server/internal/service/task.go` —— `agent.status` 更新逻辑(`ReconcileAgentStatus`
- `server/cmd/server/runtime_sweeper.go` —— Runtime 心跳 / sweeper 时间常量
- `server/migrations/055_task_lease_and_retry.up.sql` —— Task `failure_reason` 五态
- `server/migrations/037_fix_pending_task_unique_index.up.sql` —— 一个 issue 多 agent 处理的设计依据
---
## 附录 A当前现状清单保留作为重设计前的存档
> 这部分原本在 design-brief.md 里,迁移过来作为重设计前的现状记录。设计师在画稿前可以贴上当前界面截图作为对照。
### A.1 Agent 字段可见性
| 字段 | 当前状态 | 备注 |
|---|---|---|
| `name` / `avatar_url` / `description` | ✅ | 列表 + 详情都展示 |
| `archived_at` | ✅ | 列表项灰显 + 详情头部 banner |
| `status`idle/working/... | ✅ 但语义错误 | **要替换成派生 5 态** |
| `runtime_mode`local/cloud | ✅ 图标 | 列表项右侧 Cloud / Monitor 图标 |
| `instructions` | ✅ | Instructions tab |
| `custom_env` / `custom_env_redacted` | ✅ | Env tab |
| `custom_args` | ✅ | Custom Args tab |
| `visibility`workspace / private | ✅ | Settings tab |
| `max_concurrent_tasks` | ✅ | Settings tab |
| `model` | ✅ | Settings tab |
| `runtime_id` 关联 | ✅ | Settings tab |
| `skills` | ✅ | Skills tab + profile card 前 3 个 |
| `owner_id` | ✅ | Profile card |
| `created_at` / `updated_at` | 🆕 | 后端有UI 完全没展示 |
| `archived_by` | 🆕 | 后端有UI 隐藏 |
### A.2 Runtime 字段可见性
| 字段 | 当前状态 | 备注 |
|---|---|---|
| `name` | ✅ | 列表 + 详情头部 |
| `provider` | ✅ Logo | 9 种 |
| `runtime_mode` | ✅ 文字 | `RuntimeModeIcon` 组件存在但从未被调用 |
| `status`online/offline | ✅ 圆点 + 徽章 | 离线圆点浅色主题下几乎不可见 |
| `last_seen_at` | ✅ 仅详情页 | **列表完全看不到** |
| `device_info` | ✅ 详情页 | 没有人类可读化 |
| `daemon_id` | ✅ mono 字体 | 不可复制 |
| `metadata.cli_version` | ✅ | CLI 更新部分 |
| `metadata.launched_by` | ✅ | "Managed by Desktop" |
| `owner_id` | ✅ | 头像 + 名字 |
| `created_at` / `updated_at` | ✅ | ISO 时间戳 |
### A.3 桌面端 IPC 数据
```
DaemonStatus (本地 IPC)
├─ state: running / stopped / starting / stopping / installing_cli / cli_not_found
├─ pid, uptime
├─ daemonId, deviceName, serverUrl
├─ agents: 当前运行的 agent IDs
├─ workspaceCount
└─ profile
```
### A.4 截图占位区
设计师拿到这份文档后,请把以下界面的当前截图贴在对应位置:
- **Agents 界面**:列表页 / 详情页(每 tab 一张)/ 创建对话框
- **Runtimes 界面**:列表页 / 详情页(含 5 个图表)/ 桌面端 daemon 卡片
- **Issue Detail**:无任务执行时 / 单 agent 执行中 / 多 agent 并发 / 全屏 transcript dialog
### A.5 Token Usage 5 个图表的数据细节
Runtime 详情页底部 token usage 部分5 个图表 + 1 张表格全部展开,没有主次。阶段 2 改造时按下表理解每个图表的数据契约:
| 图表 | 数据源 | 时间粒度 | 维度 | 度量 |
|---|---|---|---|---|
| **Activity Heatmap** | `getRuntimeUsage?days=90` | 日 | date | 4 级强度,按 token 总量百分位分级 |
| **Hourly Activity** | `getRuntimeTaskActivity` | 小时0-23 | hour | 任务数 |
| **Daily Token Chart** | 同 Heatmap客户端聚合 | 日 | date | input/output/cacheRead/cacheWrite 总和 |
| **Daily Cost Chart** | 同上 + 客户端定价计算 | 日 | date | 美元成本,按 model pricing 表 |
| **Model Distribution** | 同上聚合 by model | 全周期 | model | tokens 占比 + cost |
**当前实现的问题**API 总是取 90 天数据,客户端做 7d/30d 过滤——浪费服务端资源,首次加载慢。改造时按选中窗口拉。
### A.6 Issue Detail 任务展示的当前实现
主要组件:`packages/views/issues/components/agent-live-card.tsx`
#### 已有能力
- 多 agent 并发执行同一 issue 时,第一个卡片是 sticky顶部固定其他在下方滚动
- 每个 task 卡片可展开 timeline显示 tool_use / tool_result / thinking / text / error 五种消息类型
- 实时滚动WS 事件 `task:message` 到达时追加 timeline
- 工具调用计数 badge
- Stop 按钮可取消任务
- 全屏 transcript dialog支持事件类型筛选 + 复制)
- 已完成/失败/取消的任务进入 TaskRunHistory 折叠区
#### 数据来源
| 数据 | API |
|---|---|
| 当前 issue 的活跃 task 列表 | `getActiveTasksForIssue(issueId)` |
| 每个 task 的历史消息 | `listTaskMessages(taskId)` |
| 实时消息流 | WS `task:message` |
| 状态变化 | WS `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` |
| 取消任务 | `cancelTask(issueId, taskId)` |
阶段 3 在此基础上重构成"每个 agent 一行的全景视图"。

View File

@@ -0,0 +1,530 @@
# Agents & Runtimes 界面重设计 · 设计师 Brief
> **文档定位**:这份文档专门给负责重设计 Agents 和 Runtimes 界面的 UI/UX 设计师。
>
> 看完这份文档,你应该能:
> - 理解我们在解决什么问题、目标体感是什么
> - 知道每个界面有哪些数据可用、哪些是新展示的、哪些需要工程补
> - 知道有哪些现有交互不能动(避免破坏用户已建立的习惯)
> - 知道可以参考哪些已经做完的设计Skills 是样板)
> - 直接进入设计稿环节
>
> **必读伴侣文档**[Agent / Runtime 状态系统重设计](./agent-runtime-status-redesign.md) —— 完整的工程方案、状态规范、实施阶段。本 brief 是它的"设计师视角切片"。
---
## 目录
1. [一句话目标](#一一句话目标)
2. [必读:状态视觉规范](#二必读状态视觉规范)
3. [Agents 界面](#三agents-界面)
4. [Runtimes 界面](#四runtimes-界面)
5. [跨界面统一Agent Hover Card](#五跨界面统一agent-hover-card)
6. [跨平台差异处理](#六跨平台差异处理)
7. [设计语言参考Skills 界面](#七设计语言参考skills-界面)
8. [工程会同步交付的能力](#八工程会同步交付的能力)
9. [设计师产出清单](#九设计师产出清单)
10. [附录:截图占位区](#十附录截图占位区)
---
## 一、一句话目标
**当前所有界面都在直接展示后端字段,缺少"用户视角"的状态翻译层。**
用户看到 `Idle` / `Online` / spinner但这些词没有回答"agent 现在能不能用 / 在做什么 / 出问题了没"。我们要做的是一套**用户视角的状态系统**:让用户一眼就知道一个 agent / runtime 的健康状况,跨界面一致。
---
## 二、必读:状态视觉规范
这套规范是所有界面的共同基础。**先输出这套规范,再画具体界面**。
### 2.1 Agent 五态
| 状态 | 颜色 | 用户语义 | 出现条件 |
|---|---|---|---|
| **Available** | 🟢 绿 | 在线空闲,可以接活 | runtime 在线 + 没有活跃任务 |
| **Working** | 🔵 蓝(品牌色) | 正在干活 | runtime 在线 + 至少一个任务在执行 |
| **Pending** | 🟡 黄 | 任务排着但没在跑 | runtime 在线 + 0 个执行中 + ≥1 个排队 |
| **Failed** | 🔴 红 | 最近一次失败 | 最近 2 分钟内有任务失败 |
| **Offline** | ⚫ 灰 | Daemon 离线,不可用 | runtime 离线 |
**复合维度**:当 agent 是 Working 但同时有任务排队,主状态保持 Working旁边带 `+N` 角标("还有 N 个排队")。
**Failed 状态特别说明**:失败显示**保持 2 分钟**,之后自动恢复(避免红点黏太久)。设计上要表达"这是临时强提示"。
### 2.2 Runtime 四态
| 状态 | 触发条件 | 用户语义 |
|---|---|---|
| **Online** | 最近 45 秒内有心跳 | 健康 |
| **Recently Lost** | 离线但 < 5 分钟 | 可能短暂网络抖动 |
| **Offline** | 离线 5 分钟 ~ 7 天 | 长期离线,需排查 |
| **About to GC** | 离线接近 7 天阈值 | 系统将自动清理 |
> **关于 CLI 未安装等"runtime 在线但跑不了"的场景**:归并到 Offlinetooltip 写明具体原因("CLI 未安装"、"Daemon 启动中")。**不要为这些子情况新设状态色**,色彩枚举太多反而失去信号。
### 2.3 视觉表达分三层
每种状态在以下三个层级要保持一致:
- **Dot**(圆点):列表项、头像旁的小圆点,最紧凑场景
- **Badge**(徽章):详情页头部、卡片角落,带图标 + 文字
- **Tooltip / Hover Card**:鼠标悬停时展开完整信息
**跨界面一致性**:同一个 agent无论出现在哪agents 列表 / issue assignee picker / autopilot 编辑 / chat 选择面板 / 评论 @),状态视觉**必须完全一致**。这是这次设计能立住的关键。
---
## 三、Agents 界面
### 3.1 当前界面长什么样
主要文件:
- `packages/views/agents/components/agents-page.tsx` — 列表页容器
- `packages/views/agents/components/agent-list-item.tsx` — 列表项
- `packages/views/agents/components/agent-detail.tsx` — 详情页(含 tabsInstructions / Skills / Tasks / Environment / Custom Args / Settings
- `packages/views/agents/components/agent-profile-card.tsx` — 详情顶部的 profile 卡片
- `packages/views/agents/components/create-agent-dialog.tsx` — 创建对话框
**当前的视觉语言跟 Skills 重设计前一样——这次目标是迁到 Skills 风格。**
### 3.2 当前的核心痛点
按用户感知严重度排列:
1. **列表上的 "Idle" 绿点会骗人**——daemon 已经死了agent 仍然显示 Idle。用户分配任务后没有任何反馈。**根因**`agent-list-item.tsx` 完全忽略 runtime 在线性,只读 `agent.status` 这个后端字段。
2. **`agent-profile-card` 状态行和 runtime 行不联动**——状态显 Idleruntime 显 Offline自相矛盾。
3. **跨界面状态展示不一致**——issue assignee picker、autopilot picker、chat 选择 agent 时**完全不显示状态**,用户做选择时不知道哪个能用。
4. **创建 agent 时无法预知能否立即跑起来**——dialog 显示了 runtime 在线点,但不知道 model 是否合法、初始化会不会失败。
5. **Archived agent 和 active agent 混在同一列表**——切换视图全靠手动按钮,不够清晰。
6. **零 WS 订阅**——离线/上线必须手动刷新页面才能感知。
### 3.3 重设计目标
#### 必须达成
- 列表项一眼能看出 agent 是否真的可用(融合 runtime 在线性的派生 5 态)
- Hover card 内必须看到派生状态、当前任务数、runtime 健康、最近失败原因
- 跨界面一致:所有显示 agent 头像的位置都用同一个 hover card详见第 5 节)
- 创建/编辑 agent 时,能预看"如果保存agent 会变成什么状态"
- 实时更新——状态变化 75 秒内反映到 UI
#### 加分项
- Archived agent 独立 tab/折叠区,不再和 active 混在一起
- 列表支持按"最近活动时间"排序
- 支持按状态筛选("显示所有 Failed"、"显示所有 Pending"
### 3.4 可用数据清单(设计稿可以放心假设可用)
> **图例**:✅ = 当前已展示;🆕 = 已可用但当前 UI 没用;🔧 = 工程会在阶段 0 补上;📡 = 实时事件可用
#### Agent 主体字段(来自 `Agent` type
| 字段 | 类型 | 当前状态 | 说明 |
|---|---|---|---|
| `name` / `avatar_url` / `description` | string | ✅ | 已展示 |
| `archived_at` / `archived_by` | string | ✅ / 🆕 | 列表灰显archived_by 后端有但 UI 隐藏 |
| `runtime_mode` | "local" / "cloud" | ✅ 图标 | Cloud / Monitor 图标区分 |
| `instructions` | string | ✅ | Instructions tab |
| `custom_env` / `custom_env_redacted` | KV / bool | ✅ | Env tab权限控制隐藏值 |
| `custom_args` | string[] | ✅ | Custom Args tab |
| `visibility` | "workspace" / "private" | ✅ | Settings tab |
| `max_concurrent_tasks` | number | ✅ | Settings tab默认 6 |
| `model` | string | ✅ | Settings tab |
| `runtime_id` | string | ✅ 选择器 | Settings tab |
| `skills` | Skill[] | ✅ | Skills tab + profile card 前 3 个 |
| `owner_id` | string | ✅ | Profile card 显示名字 |
| `created_at` / `updated_at` | string | 🆕 | **后端有UI 完全没展示** —— 可以做"最近创建"、"最近修改"标签 |
| ~~`status`~~idle/working/blocked/error/offline | enum | ✅ 但**已废弃**展示 | 后端字段保留但 UI 完全不读 |
#### 派生数据(工程在阶段 0 提供,设计稿可放心引用)
| 派生信息 | 来源 |
|---|---|
| **Agent 派生 5 态状态** | 🔧 由 agent + runtime + active tasks 派生 |
| **当前 running 任务数** | 🔧 派生 |
| **当前 queued 任务数** | 🔧 派生 |
| **最近一次失败的原因**5 种 enum | 🔧 派生 |
| **关联 runtime 健康状态** | 🔧 派生 |
| **runtime last_seen 相对时间** | 🔧 已有工具函数 |
**失败原因 5 个枚举(中文文案待你定)**
- `agent_error` — Agent 执行报错
- `timeout` — 执行超时
- `runtime_offline` — Daemon 离线
- `runtime_recovery` — Daemon 重启回收
- `manual` — 用户取消
每种原因用户的处理方式不同UI 应给出对应建议(你来设计文案)。
#### 实时事件WS
| 事件 | 状态 | 用途 |
|---|---|---|
| `agent:status` | 📡 后端发,工程接 | agent 字段变化 |
| `agent:created` / `agent:archived` / `agent:restored` | 📡 工程接 | 列表增删 |
| `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` | 📡 工程接 | 状态派生关键信号 |
| `daemon:register` | 📡 工程接 | runtime 上下线 |
设计上**不需要为"加载/loading"过度设计**——状态变化是实时的,几乎不会出现"等数据"的 loading 态。
### 3.5 不能动的现有交互(必须保留)
- 创建 agent dialog 的 chooser → 表单两步流(见 Skills 的 `create-skill-dialog`
- 各 tab 的编辑能力Instructions / Env / Custom Args / Settings 全部可编辑
- Archive / Restore 操作
- Tasks tab展示该 agent 的历史 task 列表(按状态分组:活跃 → 完成)
- Skills tab可挂载/卸载 skills
### 3.6 关键问题留给你定的
1. **Archived agent 怎么收纳**:独立 tab、折叠区、还是 segment 切换?
2. **状态筛选**是 chips 还是 dropdown
3. **Failed 红点的 2 分钟动效**:脉冲?颜色渐变?
4. **复合状态Working + 排队 N**角标位置dot 旁 / 头像下角 / Badge 内嵌)?
5. **创建 dialog 的"预览状态"**:怎么不打扰主流程的同时让用户知道"这个 agent 创建出来会是什么色"
---
## 四、Runtimes 界面
### 4.1 当前界面长什么样
主要文件:
- `packages/views/runtimes/components/runtimes-page.tsx` — 容器,含 owner filtermine/all
- `packages/views/runtimes/components/runtime-list.tsx` — 列表
- `packages/views/runtimes/components/runtime-detail.tsx` — 详情头部
- `packages/views/runtimes/components/usage-section.tsx` — Token usage 主区
- `packages/views/runtimes/components/charts/` — 5 个图表组件
- `packages/views/runtimes/components/update-section.tsx` — CLI 更新流程
- `apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx` — 桌面端独有:本机 daemon 卡片(通过 IPC独立于 server runtime 列表)
### 4.2 当前的核心痛点
按严重度排列:
1. **离线圆点几乎不可见**——浅色主题下 `bg-muted-foreground/40` 视觉上消失。
2. **列表无 last_seen**——无法区分"刚断线 5 分钟"和"3 个月前断线"。
3. **看不到 runtime 服务了哪些 agent / 当前有几个 task 在跑**——runtime 在用户心智里成了"孤岛"。
4. **7 天 GC 阈值无任何 UI 提示**——runtime 突然消失,用户不知道为什么。
5. **桌面端 daemon 卡片和云端 runtime 卡片视觉分裂**——同一台机器同一概念,两套设计。
6. **Token usage 信息过载**——5 个图表 + 1 张表全部展开,普通用户找不到"本月花了多少钱"。
7. **无 ping / 诊断按钮**——遇到断线没法主动验证。
8. **`RuntimeModeIcon` 死代码**——本地 vs 云端在列表项里没有图标区分。
### 4.3 重设计目标
#### 必须达成
- 列表项一眼区分四态Online / Recently Lost / Offline / About to GC
- 列表项展示**关联 agent 数量** + **当前任务数**runtime 不再是孤岛)
- 桌面端 daemon 卡片和云端 runtime 卡片用统一视觉语言
- Token usage 区分主次核心指标本期成本、token 总量)放顶部,详细图表折叠或下沉
#### 加分项
- Runtime 健康综合评分(在线 + 心跳新鲜度 + 任务负载)
- 7 天 GC 倒计时提示
- Ping / 诊断按钮
- 高使用量 runtime 的视觉强调(成本警告、利用率热图 sparkline
- Local vs Cloud 图标区分(启用废弃组件 `RuntimeModeIcon`
### 4.4 可用数据清单
#### Runtime 主体字段(来自 `RuntimeDevice` type
| 字段 | 类型 | 当前状态 | 说明 |
|---|---|---|---|
| `name` | string | ✅ | 列表 + 详情头部 |
| `provider` | string | ✅ Logo | 9 种claude / codex / opencode / openclaw / hermes / gemini / pi / cursor 等 |
| `runtime_mode` | "local" / "cloud" | ✅ 文字 | 列表项里没图标(死代码) |
| `status` | "online" / "offline" | ✅ 圆点 | 浅色主题下离线几乎不可见 |
| `last_seen_at` | string | ✅ 仅详情页 | **列表完全看不到** |
| `device_info` | string | ✅ 详情页 | 显示原始字符串如 `darwin-arm64`,无人类可读化 |
| `daemon_id` | string | ✅ mono 字体 | 不可复制 / 不可点击 |
| `metadata.cli_version` | string | ✅ | CLI 更新部分用 |
| `metadata.launched_by` | string | ✅ | 桌面端启动时显示 "Managed by Desktop" |
| `owner_id` | string | ✅ | 头像 + 名字 |
| `created_at` / `updated_at` | string | ✅ | 详情页底部 ISO 时间戳 |
#### 派生数据(工程阶段 0 提供)
| 派生信息 | 来源 |
|---|---|
| **Runtime 4 态健康** | 🔧 由 status + last_seen_at 派生 |
| **服务的 agent 列表 + 数量** | 🔧 前端 joinagent 持有 runtime_id列表数据已在 cache |
| **当前在跑 task 数** | 🔧 前端 filter active-tasks |
| **last_seen 相对时间字符串**"5 minutes ago" | 🔧 工具函数已有 |
#### Token Usage 5 个图表的数据契约
| 图表 | 数据源 | 时间粒度 | 维度 | 度量 |
|---|---|---|---|---|
| **Activity Heatmap** | `getRuntimeUsage?days=90` | 日 | date | 4 级强度,按 token 总量百分位分级 |
| **Hourly Activity** | `getRuntimeTaskActivity` | 小时0-23 | hour | 任务数 |
| **Daily Token Chart** | 同 Heatmap客户端聚合 | 日 | date | input/output/cacheRead/cacheWrite 总和 |
| **Daily Cost Chart** | 同上 + 客户端定价 | 日 | date | 美元成本 |
| **Model Distribution** | 同上聚合 by model | 全周期 | model | tokens 占比 + cost |
**当前实现的问题**API 总是取 90 天数据,客户端做 7d/30d 过滤——浪费服务端资源。改造时按选中窗口拉。
#### 实时事件
| 事件 | 状态 |
|---|---|
| `daemon:register` | 📡 已订阅,触发列表刷新 |
| `daemon:heartbeat` | 📡 后端发但前端**故意忽略**(防过度刷新)。设计时假设状态变化最坏 75 秒内可见 |
#### 桌面端独有的本机 IPC 数据(仅桌面端可见)
```
DaemonStatus (本机 IPC亚秒级实时)
├─ state: running / stopped / starting / stopping / installing_cli / cli_not_found
├─ pid, uptime, daemonId, deviceName, serverUrl
├─ agents: 当前运行的 agent IDs
├─ workspaceCount
└─ profile
```
工程会把这份数据自动喂进 cache**设计上不要为"桌面端有更多数据"做特殊视觉**——派生状态对设计师而言是统一的,只是桌面端响应更快。但桌面端**对自己的本机 daemon 有更多操作能力**(见第 6 节)。
### 4.5 不能动的现有交互(必须保留)
- Owner filtermine/all toggle
- Delete runtime 操作(含权限检查)
- CLI 更新流程(`update-section.tsx`):检查更新、触发更新、查看更新状态
- 5 个图表的数据展示(信息架构可以重排,但数据本身要保留)
- 桌面端start/stop/restart 本机 daemon 的按钮
### 4.6 关键问题留给你定的
1. **列表项到底放多少信息**last_seen + agent count + active tasks 放哪?避免拥挤
2. **桌面端 daemon 卡片和云端 runtime 卡片"视觉对齐"的尺度**100% 同模板?还是同卡片框 + 内容差异化?
3. **Token usage 主次怎么排**:本期成本数字 + 单图表 + "查看更多" 折叠?或者 dashboard 化?
4. **About to GC 怎么提示**横幅badge倒计时
5. **Ping / 诊断按钮的位置**:详情页头部?右上角菜单?
6. **关联 agent 列表展示**:堆叠头像?文字 "3 agents"?还是子区块?
---
## 五、跨界面统一Agent Hover Card
### 5.1 现有组件
`packages/views/agents/components/agent-profile-card.tsx`——已经存在,但只在 Agents 主页 hover 时出现。
这次会把它升级成**统一 hover card**,挂到所有展示 agent 头像的地方。
### 5.2 必须出现的位置
| 位置 | 当前状态 |
|---|---|
| Agents 列表 / 详情 | ✅ 已有 |
| Issue Assignee Picker | ❌ 仅头像无状态 |
| Issue Detail 头部 assignee | ❌ 仅头像无状态 |
| Issue 列表 / 看板的分配头像 | ❌ 仅头像 |
| Autopilot 列表 / 编辑assignee | ❌ 仅头像 |
| Project lead picker | ❌ 仅头像 |
| Chat 选择 agent 面板 | ❌ 待确认 |
| 评论里的 @agent | ❌ 仅头像 |
### 5.3 卡片必须显示什么(按重要度)
1. **派生 5 态状态**(不是 `agent.status` 原始值)
2. **Runtime 健康**:在线性 + last_seen 相对时间
3. **当前任务**N running / M queued
4. **最近失败**(如果有):原因 + 时间
5. **Agent 名称 + description**
6. **关联 skills**(前 3 个 + `+N`
7. **Owner**
### 5.4 设计要点
- 卡片宽度跨多种使用场景要适配issue 列表很窄、设置页很宽)
- 触发延迟hover delay跟 Skills 已有的卡片保持一致
- 暗色主题下信息层级要清晰
---
## 六、跨平台差异处理
### 6.1 状态视觉是平台无关的
派生 5 态 / 4 态、视觉规范、hover card——**两端共享同一套设计**。设计稿只画一份。
### 6.2 数据响应速度差异(不影响视觉)
| 平台 | Runtime 状态变化感知延迟 |
|---|---|
| Web | 最坏 75 秒 |
| Desktop看自己机器 | < 1 秒IPC |
| Desktop看别人机器 | 最坏 75 秒(跟 Web 一样) |
设计上不需要透出"快慢"——用户感知不到这是 IPC 还是 server。
### 6.3 操作能力差异(影响按钮可见性)
| 操作 | Web | Desktop自己机器 | Desktop别人机器 |
|---|---|---|---|
| 看状态 | ✅ | ✅ | ✅ |
| 重启 daemon | ❌ | ✅ | ❌ |
| 看 daemon logs | ❌ | ✅ | ❌ |
| 看 CLI 安装详情 | ❌ | ✅ | ❌ |
**设计要点**:操作按钮在不该有权限的位置应该**直接隐藏**,不要灰显(避免视觉噪音)。
### 6.4 桌面端独有的"本机 daemon 卡片"
当前桌面端有一个独立卡片显示本机 daemon。重设计后
- 视觉上跟云端 runtime 列表项**用同一套视觉语言**
- 但承载更多本地操作(重启、看日志、安装 CLI、profile 切换)
- 位置:列表顶部 sticky / 列表头部突出 / 右侧独立 panel —— 由你定
---
## 七、设计语言参考Skills 界面
### 7.1 Skills 是这次重设计的视觉锚点
Skills 界面已经在 2026-04 完成重设计PR #1607#1614#1618#1610)。这次 Agents 和 Runtimes **直接照搬 Skills 的视觉语言**,保持产品体感一致。
参考目录:`packages/views/skills/`
### 7.2 必须复用的 10 条规则
1. **统一页头** `PageHeader`h-12 + mobile sidebar trigger
2. **响应式网格列表**`grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.2fr)_minmax(0,6rem)_auto]`,不用 flexbox
3. **每行三层信息**主标题font-medium→ 描述line-clamp-1 muted→ 元数据xs muted
4. **关联对象用头像堆栈**:最多 3 + `+N`size=22 + `ring-2 ring-background` + `-space-x-1.5`
5. **卡片化列表 + 卡片内工具栏**:搜索和 scope tab 在 `CardToolbar`h-12不在页面级
6. **创建用多步 dialog**chooser → 表单可回退Dialog 宽度按方法切换manual/url 用 `!max-w-md`runtime 用 `!max-w-2xl`300ms 平滑过渡
7. **空状态 / 筛选无结果分别有详细文案**:图标 + 标题 + 三行说明 + 清晰 CTA
8. **长列表加 `useScrollFade`**:滚动容器上下边缘淡出
9. **头像统一用 `ActorAvatar`**:传 `size`,自动支持 agent / 人员
10. **权限检查 hook 化**`useCanEdit...`UI 提前隐藏/禁用操作按钮
---
## 八、工程会同步交付的能力
阶段 0数据层地基完成后**设计稿可以放心假设以下能力都到位**
- 任意位置都能拿到一个 agent 的派生 5 态状态
- 任意位置都能拿到 agent 的当前任务数running / queued
- 任意位置都能拿到 agent 关联的 runtime 4 态健康
- 任意位置都能拿到 runtime 服务的 agent 列表 + 数量
- 任意位置都能拿到 runtime 当前任务数
- 状态变化是实时的(订阅 WS 事件后会自动更新)
- 桌面端会自动获得"亚秒级"响应——不需要为此画两套稿
### 已有 API不需要新加可放心引用
- `listAgents` / `getAgent` / `createAgent` / `updateAgent` / `archiveAgent` / `restoreAgent`
- `listAgentTasks(agentId)` — 单 agent 历史任务
- `listRuntimes` / `deleteRuntime`
- `getRuntimeUsage(runtimeId, { days })` — token 用量
- `getRuntimeTaskActivity(runtimeId)` — 小时级活动
### 工程要在阶段 0 补的(设计稿可以假设有,但要知道这是新增)
- **后端**`GET /api/workspaces/:slug/active-tasks` — 全工作区活跃任务一次拉
- **后端**:诊断 / Ping API如果你的设计稿用到工程要评估优先级
- **前端类型**`AgentTask.failure_reason` 字段5 枚举agent_error / timeout / runtime_offline / runtime_recovery / manual暴露到前端类型
- **前端**:派生函数(`deriveAgentPresence` / `deriveRuntimeHealth`+ 全工作区 active-tasks query + WS 接线
### 工程不会做的(设计稿不要假设有)
- 不引入"agent 健康综合评分"——只暴露原始信号
- 不做"从历史 task 自动推断 agent 类型"等 AI 派生
- 不为 runtime 引入新状态色(稳定在 4 态)
- 不做后端聚合 API除了 active-tasks 这个补全)
---
## 九、设计师产出清单
按优先级排列,**P0 必须先于 P1**。
### P0 — 状态视觉规范(基础,所有界面共用)
- 5 态颜色 tokenAvailable / Working / Pending / Failed / Offline
- 4 态颜色 tokenOnline / Recently Lost / Offline / About to GC
- Dot / Badge / Tooltip 三层视觉规范
- 复合维度Working + 排队角标)的视觉表达
- Failed 状态的 2 分钟时间窗口动效(强提示 + 自动消失)
### P0 — Agents 界面
- 列表页(含派生状态、关联 runtime 健康、最近活动时间)
- 详情页头部 + Profile Card状态行联动
- 创建对话框(保留两步流,加入 runtime 在线状态预览)
### P0 — Runtimes 界面
- 列表页(暴露 last_seen、关联 agents、当前 task 数)
- 详情页头部4 态 badge、device info 人类可读化)
- Token usage 信息架构重整:核心指标置顶,详细图表下沉/折叠
### P1 — Hover Card 跨界面统一
- 一个适配多场景宽度的卡片设计
- 7 项内容的信息层级
- hover 触发交互(与 Skills 一致)
### P1 — 桌面端本机 daemon 卡片
- 视觉对齐云端 runtime 卡片
- 本机操作按钮(重启 / 日志 / CLI 安装)的位置
- Profile 切换(如果做多 profile
### P2 — 加分项
- Runtime 健康综合评分(视觉化)
- 7 天 GC 倒计时
- 高使用量 runtime 的成本警告
- Local vs Cloud 图标区分
- Agent archived 独立 tab/折叠区
- 列表按"最近活动"排序、按状态筛选
---
## 十、附录:截图占位区
> 设计师拿到这份文档后,请把以下三个界面的当前截图贴在对应位置,作为重设计前的现状记录,方便对比。
### 10.1 Agents 界面
- 列表页截图__待贴__
- 详情页截图(每个 tab 一张Instructions / Skills / Tasks / Environment / Custom Args / Settings__待贴__
- Profile Card 截图__待贴__
- 创建对话框截图chooser + form 两步__待贴__
- Hover card 当前样式截图__待贴__
### 10.2 Runtimes 界面
- 列表页截图mine / all 两态__待贴__
- 详情页截图(含 5 个图表全部展开__待贴__
- 桌面端 daemon 卡片截图__待贴__
- CLI 更新流程截图__待贴__
### 10.3 跨界面 agent 头像出现的位置
- Issue Assignee Picker__待贴__
- Issue Detail 头部 assignee__待贴__
- Issue 列表 / 看板__待贴__
- Autopilot 列表 / 编辑__待贴__
- Project lead picker__待贴__
- Chat agent 选择面板__待贴__
- 评论 @agent__待贴__
---
## 参考文档
- [Agent / Runtime 状态系统重设计(主文档)](./agent-runtime-status-redesign.md) — 完整工程方案、状态规范、实施阶段
- [产品全景文档](./product-overview.md) — 理解 agent / runtime / daemon 在整个产品里的位置
- [Skills 界面源代码](../packages/views/skills/) — 直接参考的设计语言样板
- 相关 PR#1607Skills 重设计)、#1614Card + PageHeader#1618(描述恢复)、#1610Dialog 闪烁修复)

View File

@@ -147,7 +147,7 @@ multica issue assign <issue-id> --agent <agent-slug>
**关键约定**
- **Callout**`<Callout type="info|warning|tip">...</Callout>`。warning 用于陷阱(如 888888info 用于补充说明tip 用于最佳实践
- **Callout**`<Callout type="info|warning|tip">...</Callout>`。warning 用于陷阱(如固定测试验证码info 用于补充说明tip 用于最佳实践
- **代码块**shell 命令用 \`\`\`bash配置用 \`\`\`yaml / \`\`\`envJSON 用 \`\`\`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>
- 任务一直 queuedruntime 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

View File

@@ -85,7 +85,7 @@ Multica = **人 + AI agent 在同一个看板上协作的任务管理平台**。
| 7 | **The Daemon** | 分布式执行的灵魂poll + heartbeat + concurrent execution | 每 30s heartbeat75s 无心跳 → 离线;启动时调 `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=3chat 和 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/`、etcskill 不参与执行,只参与上下文注入 |
| 10 | **Skills** | 工作区 skill + 本地 skill按 provider 的注入路径 | 8 种 provider 有不同 skill 根路径Claude=`.claude/skills/`、Codex=`$CODEX_HOME/skills/`、Pi=`.pi/skills/`、etcskill 不参与执行,只参与上下文注入 |
| 11 | **MCP** | 独立协议;怎么给 agent 配 MCP server和 skill 的区别 | **目前只 Claude Code 真用**——其他 provider 收到 McpConfig 但 CLI 没对应 flagJSONB 明文存储,非 owner redact |
| 12 | **Autopilots** | 让 agent 自动开工的调度器;两种执行模式;三种触发;并发策略 | **Webhook trigger 字段有但没接路由**——第一版不文档化concurrency policy 只对 `run_only` 模式生效;`create_issue` 模式由 issue FSM 自然 gate |
| 13 | **Chat** | 和 issue comment 的区别session 复用 | **完全沙盒**——chat 里的 agent 不能发 comment 到 issuesession_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 runtimesone 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 个问题 → 推荐部署路径 |

View File

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

View File

@@ -0,0 +1,424 @@
import { describe, expect, it } from "vitest";
import type { Agent, AgentRuntime, AgentTask, TaskFailureReason } from "../types";
import {
buildPresenceMap,
deriveAgentAvailability,
deriveAgentPresenceDetail,
deriveLastTaskState,
} 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. The whole point of splitting from LastTaskState is that
// these tests can ignore task fixtures entirely.
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("deriveLastTaskState", () => {
// Task dimension only — runtime status is not consulted.
it("returns idle when no tasks at all", () => {
const r = deriveLastTaskState([]);
expect(r.state).toBe("idle");
expect(r.runningCount).toBe(0);
expect(r.queuedCount).toBe(0);
});
it("returns running when at least one task is running", () => {
const r = deriveLastTaskState([makeTask({ status: "running" })]);
expect(r.state).toBe("running");
expect(r.runningCount).toBe(1);
});
it("returns running when only queued / dispatched tasks exist (no running yet)", () => {
const r = deriveLastTaskState([
makeTask({ status: "queued" }),
makeTask({ id: "t2", status: "dispatched" }),
]);
expect(r.state).toBe("running");
expect(r.runningCount).toBe(0);
expect(r.queuedCount).toBe(2);
});
it("returns running even when an older terminal exists (active wins over historical)", () => {
const r = deriveLastTaskState([
makeTask({
id: "old-failed",
status: "failed",
completed_at: "2026-04-27T10:00:00Z",
}),
makeTask({ id: "new-running", status: "running" }),
]);
expect(r.state).toBe("running");
});
it("returns the latest terminal state when no tasks are active (latest = failed)", () => {
const r = deriveLastTaskState([
makeTask({
id: "old",
status: "completed",
completed_at: "2026-04-27T10:00:00Z",
}),
makeTask({
id: "new",
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
]);
expect(r.state).toBe("failed");
expect(r.lastTaskCompletedAt).toBe("2026-04-27T11:30:00Z");
});
it("returns the latest terminal state when no tasks are active (latest = completed)", () => {
const r = deriveLastTaskState([
makeTask({
id: "old",
status: "failed",
completed_at: "2026-04-27T10:00:00Z",
}),
makeTask({
id: "new",
status: "completed",
completed_at: "2026-04-27T11:30:00Z",
}),
]);
expect(r.state).toBe("completed");
});
it("surfaces failure_reason on a failed latest terminal", () => {
const reason: TaskFailureReason = "runtime_offline";
const r = deriveLastTaskState([
makeTask({
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
failure_reason: reason,
}),
]);
expect(r.state).toBe("failed");
expect(r.failureReason).toBe(reason);
});
it("leaves failureReason undefined when the failed terminal has empty failure_reason", () => {
const r = deriveLastTaskState([
makeTask({
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
failure_reason: "",
}),
]);
expect(r.state).toBe("failed");
expect(r.failureReason).toBeUndefined();
});
it("returns cancelled when the latest terminal is cancelled", () => {
// Under the new model cancelled is a real state — the dot is
// availability-driven so honestly surfacing it doesn't lie.
const r = deriveLastTaskState([
makeTask({
status: "cancelled",
completed_at: "2026-04-27T11:30:00Z",
}),
]);
expect(r.state).toBe("cancelled");
});
it("ignores terminals without completed_at (treated as not-terminal)", () => {
// Defensive: a malformed row (no completed_at) shouldn't derail the
// latest-terminal scan. With nothing else in flight, idle.
const r = deriveLastTaskState([makeTask({ status: "failed", completed_at: null })]);
expect(r.state).toBe("idle");
});
});
describe("deriveAgentPresenceDetail", () => {
// Composition: the two dimensions are derived independently and the
// detail object exposes both. No cross-axis override (the old "unstable
// overrides failed" rule is gone — they coexist now).
it("composes online + running 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.lastTask).toBe("running");
expect(detail.runningCount).toBe(1);
expect(detail.queuedCount).toBe(1);
expect(detail.capacity).toBe(6);
});
it("composes online + failed — agent is reachable but last task failed (no longer sticky red dot)", () => {
// The whole motivation for the split: this combination was previously
// collapsed to a single red "failed" state, hiding the fact that the
// runtime is fine. Now the two dimensions are visible separately.
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime(),
tasks: [
makeTask({
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
failure_reason: "agent_error",
}),
],
now: NOW,
});
expect(detail.availability).toBe("online");
expect(detail.lastTask).toBe("failed");
expect(detail.failureReason).toBe("agent_error");
expect(detail.lastTaskCompletedAt).toBe("2026-04-27T11:30:00Z");
});
it("composes unstable + running — runtime hiccup with queued tasks still in flight", () => {
// Previously "unstable" overrode "working"; now both signals are
// surfaced. The UI shows amber dot AND running chip — user sees both
// "connection issue" and "queue is paused".
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime({
status: "offline",
last_seen_at: "2026-04-27T11:59:00Z",
}),
tasks: [makeTask({ status: "queued" })],
now: NOW,
});
expect(detail.availability).toBe("unstable");
expect(detail.lastTask).toBe("running");
expect(detail.queuedCount).toBe(1);
});
it("composes offline + idle for a brand-new agent on a dead runtime", () => {
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.lastTask).toBe("idle");
});
it("handles a missing runtime by reporting offline + the task-driven last state", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: null,
tasks: [
makeTask({
status: "completed",
completed_at: "2026-04-27T11:30:00Z",
}),
],
now: NOW,
});
expect(detail.availability).toBe("offline");
expect(detail.lastTask).toBe("completed");
});
it("leaves failureReason / lastTaskCompletedAt undefined when not relevant", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime(),
tasks: [makeTask({ status: "running" })],
now: NOW,
});
expect(detail.failureReason).toBeUndefined();
expect(detail.lastTaskCompletedAt).toBeUndefined();
});
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: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
],
now: NOW,
});
const a = map.get("a");
const b = map.get("b");
expect(a?.availability).toBe("online");
expect(a?.lastTask).toBe("running");
expect(b?.availability).toBe("online");
expect(b?.lastTask).toBe("failed");
});
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");
// Task dimension still resolves independently — running task counts.
expect(o?.lastTask).toBe("running");
});
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: "failed",
completed_at: "2026-04-27T11:00:00Z",
}),
],
now: NOW,
});
expect(map.get("a")?.availability).toBe("unstable");
expect(map.get("b")?.availability).toBe("unstable");
// Last-task remains independent: a is running (queued), b is failed.
expect(map.get("a")?.lastTask).toBe("running");
expect(map.get("b")?.lastTask).toBe("failed");
});
});

View File

@@ -0,0 +1,166 @@
// 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. LastTaskState — derived from the task snapshot only.
//
// They are computed independently and assembled into AgentPresenceDetail.
// No cross-dimension override logic — that was the source of the previous
// model's "sticky red dot" confusion.
import { deriveRuntimeHealth } from "../runtimes/derive-health";
import type { Agent, AgentRuntime, AgentTask, TaskFailureReason } from "../types";
import type {
AgentAvailability,
AgentPresenceDetail,
LastTaskState,
} 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
}
interface LastTaskResult {
state: LastTaskState;
runningCount: number;
queuedCount: number;
failureReason?: TaskFailureReason;
lastTaskCompletedAt?: string;
}
// Single pass: count actives + track latest terminal by completed_at. A
// running OR queued task means the agent is currently busy ("running"
// state); only when nothing is in flight do we fall through to the latest
// terminal (which can be completed / failed / cancelled). With no terminal
// history at all, we report `idle`.
//
// Cancelled is no longer filtered out — under the new model the dot is
// availability-driven so honestly surfacing "cancelled" doesn't risk
// lying about whether the agent works. The previous "exclude cancelled
// to keep red sticky" hack is gone.
export function deriveLastTaskState(tasks: readonly AgentTask[]): LastTaskResult {
let runningCount = 0;
let queuedCount = 0;
let latestTerminal: AgentTask | null = null;
let latestTerminalAt = -Infinity;
for (const t of tasks) {
if (t.status === "running") {
runningCount += 1;
} else if (t.status === "queued" || t.status === "dispatched") {
queuedCount += 1;
} else if (t.completed_at) {
const ts = new Date(t.completed_at).getTime();
if (!Number.isNaN(ts) && ts > latestTerminalAt) {
latestTerminalAt = ts;
latestTerminal = t;
}
}
}
if (runningCount + queuedCount > 0) {
return { state: "running", runningCount, queuedCount };
}
if (!latestTerminal) {
return { state: "idle", runningCount: 0, queuedCount: 0 };
}
const completedAt = latestTerminal.completed_at ?? undefined;
if (latestTerminal.status === "failed") {
return {
state: "failed",
runningCount: 0,
queuedCount: 0,
failureReason: latestTerminal.failure_reason || undefined,
lastTaskCompletedAt: completedAt,
};
}
if (latestTerminal.status === "cancelled") {
return {
state: "cancelled",
runningCount: 0,
queuedCount: 0,
lastTaskCompletedAt: completedAt,
};
}
// completed
return {
state: "completed",
runningCount: 0,
queuedCount: 0,
lastTaskCompletedAt: completedAt,
};
}
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 last = deriveLastTaskState(input.tasks);
return {
availability,
lastTask: last.state,
runningCount: last.runningCount,
queuedCount: last.queuedCount,
capacity: input.agent.max_concurrent_tasks,
failureReason: last.failureReason,
lastTaskCompletedAt: last.lastTaskCompletedAt,
};
}
// 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 + each agent's
// most recent terminal task. Comes straight from getAgentTaskSnapshot()
// — no pre-filtering needed.
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;
}

View File

@@ -0,0 +1,6 @@
export * from "./types";
export * from "./derive-presence";
export * from "./queries";
export * from "./use-agent-presence";
export * from "./use-agent-activity";
export * from "./use-workspace-presence-prefetch";

View File

@@ -0,0 +1,84 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const agentTaskSnapshotKeys = {
all: (wsId: string) => ["workspaces", wsId, "agent-task-snapshot"] as const,
list: (wsId: string) => [...agentTaskSnapshotKeys.all(wsId), "list"] as const,
};
export const agentActivityKeys = {
all: (wsId: string) => ["workspaces", wsId, "agent-activity"] as const,
last30d: (wsId: string) => [...agentActivityKeys.all(wsId), "30d"] as const,
};
export const agentRunCountsKeys = {
all: (wsId: string) => ["workspaces", wsId, "agent-run-counts"] as const,
last30d: (wsId: string) => [...agentRunCountsKeys.all(wsId), "30d"] as const,
};
// Workspace-scoped agent task snapshot — every active task plus each agent's
// most recent terminal task. This is the single shared source of truth that
// powers per-agent presence derivation across the app. One fetch per
// workspace; all agent dots / hover cards / list rows derive presence from
// this cache with zero additional network traffic.
//
// The 30s staleTime is a safety net only; the primary freshness signal is
// WS task events, which invalidate this query immediately. Without WS,
// presence still updates within 30s on focus / mount.
export function agentTaskSnapshotOptions(wsId: string) {
return queryOptions({
queryKey: agentTaskSnapshotKeys.list(wsId),
queryFn: () => api.getAgentTaskSnapshot(),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
});
}
// Workspace-wide daily task activity for the last 30 days, anchored on
// completed_at. One fetch backs both the Agents-list sparkline (which
// only uses the trailing 7 buckets via `summarizeActivityWindow`) and
// the agent detail "Last 30 days" panel. WS task lifecycle events
// invalidate this query in useRealtimeSync; the staleTime is a
// tab-focus safety net.
export function agentActivity30dOptions(wsId: string) {
return queryOptions({
queryKey: agentActivityKeys.last30d(wsId),
queryFn: () => api.getWorkspaceAgentActivity30d(),
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
});
}
// Workspace-wide 30-day run counts for the Agents-list RUNS column. Same
// single-fetch / WS-invalidate pattern as activity24hOptions.
export function agentRunCounts30dOptions(wsId: string) {
return queryOptions({
queryKey: agentRunCountsKeys.last30d(wsId),
queryFn: () => api.getWorkspaceAgentRunCounts(),
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
});
}
export const agentTasksKeys = {
all: (wsId: string) => ["workspaces", wsId, "agent-tasks"] as const,
detail: (wsId: string, agentId: string) =>
[...agentTasksKeys.all(wsId), agentId] as const,
};
// All tasks for a single agent (the agent detail page consumer). Powers both
// the inspector's 7-day throughput stats and the Tasks tab list — shared so
// they don't fetch twice. WS task events invalidate this via the existing
// task-prefix invalidation in useRealtimeSync.
export function agentTasksOptions(wsId: string, agentId: string) {
return queryOptions({
queryKey: agentTasksKeys.detail(wsId, agentId),
queryFn: () => api.listAgentTasks(agentId),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
});
}

View File

@@ -0,0 +1,69 @@
// Derived presence types for agents — the user-facing state we display
// across the UI (list dots, hover cards, status lines). Computed in the
// front-end from raw server data (agent + runtime + recent tasks); the
// back-end never knows about these enums.
//
// Two orthogonal dimensions, derived independently:
//
// 1. AgentAvailability — "Can this agent take work right now?"
// Depends only on runtime reachability. The dot colour everywhere in
// the app reflects this single dimension; never sticky-red because of
// a past task outcome.
//
// 2. LastTaskState — "What was the last thing this agent did?"
// Depends only on the workspace task snapshot. Surfaced as text + icon
// on focused surfaces (hover card, agent detail, agent list, runtime
// detail). Never colours the dot.
//
// The previous single 5-state union conflated the two: a runtime-healthy
// agent whose last task failed would show a red dot indistinguishable from
// a daemon-dead agent. Splitting them lets each signal be unambiguous.
import type { TaskFailureReason } from "../types";
// Runtime-reachability dimension. `unstable` is the transient amber state
// during the runtime sweeper's grace window (offline < 5 min); it decays
// into `offline` with no new server data, hence the 30s presence tick on
// the consuming hooks.
export type AgentAvailability =
| "online" // 🟢 runtime online and reachable
| "unstable" // 🟡 runtime recently_lost (< 5 min) — transient
| "offline"; // ⚫ runtime long offline / missing / never registered
// Last-task dimension. Active and terminal merged into one enum because
// only one applies at a time: while there's any in-flight task the state
// is `running`; once everything terminates we read the latest outcome;
// with no history at all, `idle`.
//
// `running` covers both `running` and `queued/dispatched` tasks because
// from the user's perspective "agent is busy" is the same answer; the
// running/queued counts on the detail object preserve the breakdown.
//
// `cancelled` is included as a discrete state (vs. folding into the
// previous filter that excluded cancelled from terminal selection). With
// the dot no longer colour-coded by task state, surfacing "cancelled"
// honestly is fine — it doesn't risk lying about availability.
export type LastTaskState =
| "running" // ≥1 task running or queued right now
| "completed" // latest terminal: completed
| "failed" // latest terminal: failed
| "cancelled" // latest terminal: cancelled
| "idle"; // no active task and no terminal history
export interface AgentPresenceDetail {
availability: AgentAvailability;
lastTask: LastTaskState;
runningCount: number;
queuedCount: number;
// Mirrors agent.max_concurrent_tasks — pulled into the detail so the UI
// can render `running / capacity` ratios without re-fetching the agent.
capacity: number;
// Set only when lastTask === "failed". The label lookup happens at the
// UI layer; deriving exposes the raw classifier so the UI can choose copy.
failureReason?: TaskFailureReason;
// Wall-clock timestamp of the latest terminal task. Set whenever
// lastTask is one of completed / failed / cancelled. Used to render
// "Last run: failed · 12 min ago" copy. Undefined for `running` (no
// terminal yet) and `idle` (no history).
lastTaskCompletedAt?: string;
}

View File

@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import type { Agent, AgentActivityBucket } from "../types";
import {
buildActivityMap,
deriveAgentActivity,
summarizeActivityWindow,
} from "./use-agent-activity";
const DAY = 24 * 60 * 60 * 1000;
// Fixed anchor — derivation uses local-time start of "today", a real
// clock would drift. 12:00 also keeps "today" stable across odd timezones.
const NOW = new Date("2026-04-28T12:00:00").getTime();
function bucket(
agentId: string,
daysAgo: number,
taskCount: number,
failedCount = 0,
): AgentActivityBucket {
const t = new Date(NOW);
t.setHours(0, 0, 0, 0);
return {
agent_id: agentId,
bucket_at: new Date(t.getTime() - daysAgo * DAY).toISOString(),
task_count: taskCount,
failed_count: failedCount,
};
}
const fullHistoryAgent: Agent = {
id: "a1",
workspace_id: "w",
runtime_id: "r1",
name: "Old Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "cloud",
runtime_config: {},
custom_env: {},
custom_args: [],
custom_env_redacted: false,
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 1,
model: "",
owner_id: null,
skills: [],
// Older than the window so daysSinceCreated saturates at DAYS.
created_at: new Date(NOW - 100 * DAY).toISOString(),
updated_at: new Date(NOW).toISOString(),
archived_at: null,
archived_by: null,
};
describe("deriveAgentActivity", () => {
it("places buckets in oldest→newest slots across 30 days", () => {
const buckets = [
bucket("a1", 29, 1), // slot 0
bucket("a1", 0, 5), // slot 29
];
const result = deriveAgentActivity(
buckets,
fullHistoryAgent.created_at,
NOW,
);
expect(result.buckets).toHaveLength(30);
expect(result.buckets[0]).toEqual({ total: 1, failed: 0 });
expect(result.buckets[29]).toEqual({ total: 5, failed: 0 });
expect(result.daysSinceCreated).toBe(30);
});
it("clamps daysSinceCreated for young agents", () => {
const created = new Date(NOW - 3 * DAY - 60 * 1000).toISOString();
const result = deriveAgentActivity([bucket("fresh", 1, 4)], created, NOW);
expect(result.daysSinceCreated).toBe(3);
});
it("treats sub-day-old agents as daysSinceCreated = 0", () => {
const created = new Date(NOW - 2 * 60 * 60 * 1000).toISOString();
const result = deriveAgentActivity([bucket("fresh", 0, 1)], created, NOW);
expect(result.daysSinceCreated).toBe(0);
// Today's bucket still records — pre-life days simply look like zero
// days, which is on purpose.
expect(result.buckets[29]).toEqual({ total: 1, failed: 0 });
});
it("ignores buckets older than the 30-day window", () => {
const result = deriveAgentActivity(
[bucket("a1", 60, 99)],
fullHistoryAgent.created_at,
NOW,
);
expect(
result.buckets.reduce((s, b) => s + b.total, 0),
).toBe(0);
});
it("zero-fills when the agent has no buckets", () => {
const result = deriveAgentActivity(
[],
fullHistoryAgent.created_at,
NOW,
);
expect(result.buckets).toHaveLength(30);
expect(result.buckets.every((b) => b.total === 0 && b.failed === 0)).toBe(
true,
);
});
});
describe("summarizeActivityWindow", () => {
it("rolls up totals across the trailing N buckets", () => {
// 5 runs total over the 30-day series.
const result = deriveAgentActivity(
[
bucket("a1", 25, 1), // outside 7d, inside 30d
bucket("a1", 6, 1), // inside 7d
bucket("a1", 0, 3, 1), // inside 7d
],
fullHistoryAgent.created_at,
NOW,
);
const last7 = summarizeActivityWindow(result, 7);
expect(last7.totalRuns).toBe(4);
expect(last7.totalFailed).toBe(1);
expect(last7.buckets).toHaveLength(7);
const last30 = summarizeActivityWindow(result, 30);
expect(last30.totalRuns).toBe(5);
expect(last30.totalFailed).toBe(1);
expect(last30.buckets).toHaveLength(30);
});
it("returns an empty summary for missing activity", () => {
const summary = summarizeActivityWindow(undefined, 7);
expect(summary.buckets).toEqual([]);
expect(summary.totalRuns).toBe(0);
expect(summary.totalFailed).toBe(0);
expect(summary.windowDays).toBe(7);
});
it("clamps an oversized window to the available bucket count", () => {
const result = deriveAgentActivity(
[bucket("a1", 0, 2)],
fullHistoryAgent.created_at,
NOW,
);
const summary = summarizeActivityWindow(result, 1000);
expect(summary.buckets).toHaveLength(30);
expect(summary.totalRuns).toBe(2);
});
it("returns no buckets when window is 0", () => {
const result = deriveAgentActivity(
[bucket("a1", 0, 5)],
fullHistoryAgent.created_at,
NOW,
);
const summary = summarizeActivityWindow(result, 0);
expect(summary.buckets).toEqual([]);
expect(summary.totalRuns).toBe(0);
});
});
describe("buildActivityMap", () => {
it("groups buckets by agent and yields a derivation per agent", () => {
const agents: Agent[] = [
fullHistoryAgent,
{ ...fullHistoryAgent, id: "a2" },
];
const buckets: AgentActivityBucket[] = [
bucket("a1", 0, 3),
bucket("a2", 1, 2, 1),
bucket("a1", 2, 4),
];
const map = buildActivityMap(agents, buckets, NOW);
expect(map.size).toBe(2);
expect(summarizeActivityWindow(map.get("a1"), 30).totalRuns).toBe(7);
expect(summarizeActivityWindow(map.get("a2"), 30).totalRuns).toBe(2);
expect(summarizeActivityWindow(map.get("a2"), 30).totalFailed).toBe(1);
});
it("emits a zero-filled entry for an agent with no buckets", () => {
const agents: Agent[] = [fullHistoryAgent];
const map = buildActivityMap(agents, [], NOW);
const a = map.get("a1");
expect(a?.buckets).toHaveLength(30);
expect(summarizeActivityWindow(a, 30).totalRuns).toBe(0);
});
});

View File

@@ -0,0 +1,204 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Agent, AgentActivityBucket } from "../types";
import { agentListOptions } from "../workspace/queries";
import { agentActivity30dOptions } from "./queries";
const DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;
/** One day's tally for the sparkline. */
export interface ActivityBucket {
total: number;
failed: number;
}
export interface AgentActivity {
/**
* 30 daily buckets, oldest → newest. Days with no activity are
* zero-filled. Each surface picks how much of the tail to render: the
* Agents list uses 7, the agent detail uses all 30. Reading is the
* caller's job (see `summarizeActivityWindow` for the standard
* tail-slice + roll-up).
*/
buckets: ActivityBucket[];
/**
* Days the agent has existed, capped at DAYS. Pure cosmetic — used by
* tooltip copy ("Created 3 days ago"). The sparkline doesn't change
* shape for young agents on purpose; pre-life days look the same as
* zero days.
*/
daysSinceCreated: number;
}
/**
* Window-sized roll-up of an agent's activity series. Both the Agents
* list (windowDays=7) and the detail "Last 30 days" panel (windowDays=30)
* read through this so the totals can never drift from the bars they
* label.
*/
export interface ActivityWindowSummary {
/** Trailing-N buckets from the activity series (newest end). */
buckets: ActivityBucket[];
/** Sum of `bucket.total` across the window. */
totalRuns: number;
/** Sum of `bucket.failed` across the window. */
totalFailed: number;
/** Echo of the input window — the renderer uses it for copy. */
windowDays: number;
}
const EMPTY: AgentActivity = {
buckets: Array.from({ length: DAYS }, () => ({ total: 0, failed: 0 })),
daysSinceCreated: DAYS,
};
const EMPTY_SUMMARY: ActivityWindowSummary = {
buckets: [],
totalRuns: 0,
totalFailed: 0,
windowDays: 0,
};
/**
* Workspace-wide activity map keyed by `agent.id`. Single-pass batch:
* one fetch + one derivation pass backs every row's sparkline on the
* list AND the detail panel — adding rows costs O(1) HTTP and O(N)
* compute (not O(N) HTTP).
*/
export function useWorkspaceActivityMap(wsId: string | undefined): {
byAgent: Map<string, AgentActivity>;
loading: boolean;
} {
const { data: agents, isPending: agentsPending } = useQuery({
...agentListOptions(wsId ?? ""),
enabled: !!wsId,
});
const { data: buckets, isPending: bucketsPending } = useQuery({
...agentActivity30dOptions(wsId ?? ""),
enabled: !!wsId,
});
const byAgent = useMemo(() => {
if (!agents || !buckets) return new Map<string, AgentActivity>();
return buildActivityMap(agents, buckets, Date.now());
}, [agents, buckets]);
return { byAgent, loading: agentsPending || bucketsPending };
}
export function buildActivityMap(
agents: readonly Agent[],
buckets: readonly AgentActivityBucket[],
now: number,
): Map<string, AgentActivity> {
// Group buckets by agent once so per-agent derivation is O(buckets) not
// O(agents × buckets).
const bucketsByAgent = new Map<string, AgentActivityBucket[]>();
for (const b of buckets) {
const list = bucketsByAgent.get(b.agent_id);
if (list) list.push(b);
else bucketsByAgent.set(b.agent_id, [b]);
}
const out = new Map<string, AgentActivity>();
for (const agent of agents) {
out.set(
agent.id,
deriveAgentActivity(
bucketsByAgent.get(agent.id) ?? [],
agent.created_at,
now,
),
);
}
return out;
}
/**
* Pure derivation: filter the workspace-wide buckets to one agent and
* normalise to a fixed 30-element series ending at `now`. Exported for
* unit-testing and direct reuse on surfaces that already have the
* workspace-wide buckets in hand.
*/
export function deriveAgentActivity(
buckets: readonly AgentActivityBucket[],
agentCreatedAt: string,
now: number,
): AgentActivity {
const series: ActivityBucket[] = Array.from({ length: DAYS }, () => ({
total: 0,
failed: 0,
}));
// Newest slot is the start of "today" in local time; we walk back DAYS
// slots so index 0 = oldest, index DAYS-1 = today.
const today = startOfDay(now);
for (const b of buckets) {
const ts = new Date(b.bucket_at).getTime();
if (Number.isNaN(ts)) continue;
const daysAgo = Math.floor((today - startOfDay(ts)) / DAY_MS);
if (daysAgo < 0 || daysAgo >= DAYS) continue;
const slot = DAYS - 1 - daysAgo;
series[slot]!.total += b.task_count;
series[slot]!.failed += b.failed_count;
}
const createdAt = new Date(agentCreatedAt).getTime();
const ageMs = Number.isFinite(createdAt) ? now - createdAt : Infinity;
const daysSinceCreated = Math.min(
DAYS,
Math.max(0, Math.floor(ageMs / DAY_MS)),
);
return {
buckets: series,
daysSinceCreated,
};
}
/**
* Take the trailing N buckets and roll up totals over them. This is the
* single entry point both surfaces (list + detail) read through, so the
* numbers can never disagree with the bars they label.
*
* `windowDays` is clamped to the available bucket count, so passing a
* value larger than `activity.buckets.length` returns the full series
* rather than an out-of-range slice.
*/
export function summarizeActivityWindow(
activity: AgentActivity | undefined,
windowDays: number,
): ActivityWindowSummary {
if (!activity) return { ...EMPTY_SUMMARY, windowDays };
const safeWindow = Math.min(
Math.max(0, windowDays),
activity.buckets.length,
);
// `slice(-0)` returns the full array (JS quirk: -0 === 0), so guard
// explicitly when no window is requested.
const slice =
safeWindow === 0 ? [] : activity.buckets.slice(-safeWindow);
let totalRuns = 0;
let totalFailed = 0;
for (const b of slice) {
totalRuns += b.total;
totalFailed += b.failed;
}
return { buckets: slice, totalRuns, totalFailed, windowDays };
}
function startOfDay(ts: number): number {
// Local-time day boundary. The back-end truncates to UTC midnight, but
// the user's mental model is "today/yesterday in the timezone they're
// looking at"; using local matches that and keeps "today" stable across
// a working session even when buckets cross UTC midnight.
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
export const __EMPTY_ACTIVITY = EMPTY;

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