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.
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).
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.
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.
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.
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.
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.
* 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>
* 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.
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.
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.
* 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.
* 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.
- 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.
* 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.
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>
* 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.
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.
Fixesmultica-ai/multica#1723.
* 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.
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.
* 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.
#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.
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.
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
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.
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.
* 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.
* 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>
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.
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.
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
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.
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.
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.
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.
* 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.
`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
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>
* 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>
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>
* 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>
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>
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>
* 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>
* 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>
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.
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>
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>
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>
* 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>
* chore(docs-site): add @multica/ui bridge and dev:docs script
Link @multica/ui as a workspace dep of @multica/docs so the docs app can
consume the shared design tokens (tokens.css, base.css) via a relative
import — same pattern the web and desktop apps use. Add a top-level
pnpm dev:docs script for a one-command docs dev server (port 4000).
Preparation for the docs site rewrite tracked in docs/docs-outline.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(docs-site): apply Multica tokens and pure-sans typography
Replace Fumadocs' neutral color preset with a @theme inline bridge that
maps the --color-fd-* chrome tokens to Multica's --background / --foreground
/ --border / --sidebar-* etc. Sidebar, nav, cards now pick up Multica's
cool-gray palette automatically, and switching Multica's .dark flips
Fumadocs chrome with it.
Typography: pure sans (36px / weight 600 / tight tracking h1, h2+h3 tuned
to match), landing continuity without serif display.
Code blocks: pinned to near-black (oklch(0.12 0.01 250)) regardless of
page theme so they read as a continuation of the landing hero surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(plan): add rewrite plan and outline tracker
Two planning documents for the docs site rewrite:
- docs/docs-rewrite-plan.md — strategic rationale (positioning, reader
personas, design principles, visual direction, phase breakdown).
- docs/docs-outline.md — execution tracker. 25 v1 pages with per-page
entries (source files, audience, what-to-write, what-not-to-write,
⚠️ verify-before-drafting). Workflow: claim via Owner + Status,
read source, verify checklist, draft, review, ship.
Language: zh only for v1. Outline is the source of truth for scope and
status; the earlier "EN first, ZH as Phase 10" line in rewrite-plan.md
is superseded.
Welcome (§1.1) is claimed under this tracker and currently in 👀 review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(docs-site): write first Welcome page (zh) — §1.1
Implements §1.1 Welcome per docs/docs-outline.md. Chinese-first (per
outline language decision); terms translated to their clearest Chinese
equivalents (issue → 任务, agent → 智能体, daemon → 守护进程, etc.),
product proper nouns and commands kept in English.
Voice: reference-style, not marketing. Follows google-gemini/docs-writer
skill rules (BLUF opener, second-person, active voice, no hype, overview
prose before every list).
Content:
- Opens by describing Multica as a 任务协作 platform and how humans + AI
智能体 share the same 工作区
- Two interaction modes: 分配任务 and 聊天
- 智能体在哪里运行: local daemon (today), cloud runtime (soon, waitlist).
10 providers listed from source (server/pkg/agent/*.go).
- Three usage paths split into back-end (Cloud / Self-host) and client
(Desktop) choices — Desktop bundles CLI and auto-starts daemon.
- Status: 👀 In review.
Also simplifies content/docs/meta.json to just ["index"] (placeholder
page entries removed; IA skeleton will be populated in Phase 2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(docs-site): wire up client-side Mermaid rendering
Add a <Mermaid> React component under apps/docs/components/ that dynamic-
imports the mermaid package in useEffect and renders the resulting SVG.
Deps added: mermaid@^11.14.0 and next-themes@^0.4.6 (transitively present
via fumadocs-ui but needs explicit declaration to be importable).
Design choices:
- Client-side render (not build-time). No Playwright / browser automation
in CI. Mermaid bundle (~400 KB) is loaded only on pages that use the
component, thanks to the dynamic import.
- Theme flips automatically — useTheme() from next-themes re-invokes
mermaid.initialize() with the correct theme on .dark toggle.
- SSR safe: the component returns a "Rendering diagram…" placeholder on
the server; the SVG appears after hydration.
- securityLevel "strict" — diagrams render as static SVG with no inline
script or event handlers.
Usage in mdx (explicit import, same pattern as Cards/Callout):
import { Mermaid } from "@/components/mermaid";
<Mermaid chart={`
graph LR
User --> Server
`} />
Verified by a scratch /app/mermaid-test/ route that compiled to 4665
modules and returned HTTP 200 (cleanup done pre-commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(docs-site): adopt v2 editorial palette and typography
Replace the Linear/Vercel-style cool-gray token override with a warm
editorial palette (bg matches landing #f7f7f5, brand-color primary via
Multica's existing --brand hue 255) and wire Source Serif 4 for heading
typography. Italic is avoided sitewide — Chinese italic renders as a
synthetic slant against upright-designed glyphs and reads as broken;
emphasis is carried by serif/sans contrast, brand color, and weight.
Sidebar adopts the product app's active-fill pattern (solid
sidebar-accent background, no ::before mark). Code blocks drop the
always-dark hero treatment and follow page theme so the reading column
stays coherent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(docs-site): add editorial MDX components
New components/editorial.tsx exposes Byline, NumberedCards/NumberedCard,
and NumberedSteps/Step — the "wow moment" pieces from v2-editorial
(ruled-divider bylines, No. 01 serif card numbering, large serif step
counters). All escape prose via not-prose so they run their own type
scale.
DocsHero is rewritten as an editorial showpiece: title accepts ReactNode
so callers can pass a brand-color em accent, eyebrow becomes a small
uppercase sans label, lede uses serif at 20px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(docs-site): rewrite welcome page as editorial showpiece
Welcome page now opens with an editorial hero (eyebrow + serif h1 with
brand-color em accent on "共处一方。" + serif lede), a ruled byline
strip carrying the section / updated / read-time metadata, and then
flows into prose.
The three deployment paths switch from fumadocs's <Cards> to
<NumberedCards> so each gets a No. 01/02/03 label, and the "next steps"
list becomes a <NumberedSteps> block with large serif counters. These
are the highest-impact visual moments on the page; the rest of the
guide pages still get the global editorial chrome without needing
per-page code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(docs-site): add bilingual flat content tree with i18n routing
Restructures the docs site from nested topic folders (cli/, getting-started/,
developers/, guides/) into a flat content tree, and adds Chinese alongside
English. The old nested structure forced contributors to think about both
the topic AND the user-journey grouping; the flat tree lets a single
meta.json control reading order with separator labels, and lets the same
slug serve both languages via the `foo.zh.mdx` parser convention.
Routing
- New `app/[lang]/` segment hosts layout, home, slug page, and not-found
- Self-contained basePath-aware middleware (fumadocs's built-in middleware
isn't basePath-aware, so its rewrite/redirect targets break under /docs)
- `hideLocale: 'default-locale'` keeps English URLs prefix-less; Chinese
lives under /docs/zh/
- Sitemap excluded from middleware matcher so crawlers don't get rewritten
into a non-existent locale-prefixed sitemap route
- Default-language redirect preserves search string (UTM safety)
- Home page declares its own generateStaticParams (Next layout params
don't cascade) so /docs/ and /docs/zh are SSG, not dynamic per request
SEO
- New app/sitemap.ts emits hreflang alternates for every page
- absoluteDocsUrl normalizes the home `/` so canonical URLs don't carry a
trailing slash that mismatches the page's own canonical link
- apps/web/app/robots.ts now advertises the docs sitemap
Search
- CJK tokenizer registered for the zh locale (Orama's English regex strips
Han characters; without this Chinese search either returns empty or
throws)
Chrome
- Custom DocsSettings replaces fumadocs's default icon-only sidebar footer
with two labelled buttons (language + theme), matching the editorial
design language
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The live timeline was rendered in a separate <div> from the persisted
messages list. When the streamed task finished and its ChatMessage
landed, the live <div> unmounted and a new <MessageBubble> mounted —
two different DOM elements showing the same content. useAutoScroll's
ResizeObserver + MutationObserver fired on both the unmount and the
mount, causing the visible jump-then-re-render.
Merge the two paths: inject a synthetic assistant message with the
pending task_id while streaming, and key every assistant bubble by
task_id. When the real message arrives (same task_id), React preserves
the DOM element across the invalidate → refetch window — no remount,
no double scroll, no flicker.
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
The ChatInput wrapper toggled between pb-8 (empty state) and pb-4
(has messages), causing a 16px vertical jump the moment hasMessages
flipped. EmptyState already centers itself inside flex-1, so the
extra padding wasn't needed — collapse to a single pb-4.
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
* feat(chat): Chat V2 — sidebar entry + main-area page
Replace the floating drawer + FAB with a first-class workspace route
`/:slug/chat`. Sidebar gets a single `Chat` entry under Inbox with an
unread dot; session history lives inside the Chat tab via a popover
rather than leaking into the global sidebar (keeps Multica's "nouns in
the nav" semantic — Inbox / Issues / Projects are work objects, Chat is
a tool).
- Add `paths.workspace(slug).chat()` + update link-handler route set.
- New `ChatPage` view with PageHeader, history popover, centered
messages/composer column, and empty-state starter prompts.
- Delete `ChatWindow`, `ChatFab`, resize helpers, and standalone
`ChatSessionHistory` (history now embedded in the popover).
- Drop `isOpen`/`toggle`/`showHistory`/resize fields from `useChatStore`
— the page is a route now, not an overlay.
- Wire the new `/chat` route on web (App Router) and desktop
(react-router + tab-store icon mapping).
Addresses MUL-1322.
* fix(chat): align composer width with message column
The ChatPage wrapper added px-4 on top of ChatInput's own px-5, making
the composer 32px narrower than the messages column. Drop the outer
px-4 so both share the same max-w-3xl outer + px-5 inner padding
provided by ChatMessageList / ChatInput.
* fix(chat): taller default composer (~3 lines visible, 8 max)
min-h 4rem → 7rem, max-h 10rem → 15rem. Empty state previously
showed only 1 text row after pb-9 for the action bar; raise the
floor so there's visible writing room and lift the ceiling so a
longer draft can grow before scrolling kicks in.
* fix(chat): restore anchor + in-flight indicator + cold-start session restore
Three issues surfaced by review:
1. ContextAnchorButton always disabled on /:slug/chat — useRouteAnchorCandidate
only matches issue/project/inbox pathnames, so moving chat to its own route
dropped 'bring the page I was on into the conversation'. Track the last
anchor-eligible location globally (new useAnchorTracker mounted in AppSidebar
+ lastAnchorLocation on useChatStore) and substitute it when on /chat.
2. No global 'Multica is working' cue after ChatFab deletion. Subscribe the
sidebar Chat entry to pendingChatTasksOptions and swap the unread dot for a
spinner while any chat task is in flight.
3. ChatPage restore effect latched didRestoreRef before the sessions query
resolved, so cold-start direct nav to /chat landed on the empty state even
when the server had an active session. Wait for isSuccess before locking
the ref.
* fix(chat): clear lastAnchorLocation on workspace rehydration
The pathname captured in workspace A would otherwise be reused against
workspace B's wsId, triggering a cross-workspace issue/project fetch
and silently leaking anchor context into chat messages.
---------
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
* feat(daemon): harden agent mention-loop instructions
Two agents that mention each other via `mention://agent/<id>` can fall into
an infinite reply loop — each says "I'm done" in prose but keeps
`@mentioning` the other, which re-enqueues their run. Adding hard caps on
agent-to-agent turns conflicts with Multica's design principle of giving
agents the same authorship freedom as humans, so this change hardens the
instructions that the harness injects instead.
- Replace the terse "mentions are actions" blurb with a full Mentions
protocol: `side-effecting` warning, explicit "when NOT to mention"
(replying to another agent, sign-offs, thanks) and "when a mention IS
appropriate" (human escalation, first-time delegation, user asked).
- Add a pre-workflow decision step for comment-triggered runs: decide
whether a reply is warranted at all, decide whether to include any
`@mention`, and clarify that the post-a-comment rule is mandatory *if*
you reply — silence is a valid exit for agent-to-agent threads.
- Thread the triggering comment's author kind + display name
(`TriggerAuthorType` / `TriggerAuthorName`) from the claim endpoint
through the daemon task type, per-turn prompt, and CLAUDE.md workflow.
When the author is another agent, both surfaces now name that agent
and warn against sign-off mentions.
- Soften the old closing line that told agents to `always` use the
mention format — the word generalized to member/agent mentions and
encouraged the very behavior that causes loops.
Refs GH#1576, MUL-1323.
* fix(daemon): remove MUST-respond conflict and sanitize trigger author name
Addresses two blocking points on PR #1581:
1. buildCommentPrompt told the agent "You MUST respond to THIS comment"
and unconditionally appended the reply command — directly conflicting
with the new agent-to-agent silence-as-valid-exit workflow. Models
were likely to keep following the older must-reply rule and fall back
into the loop this PR is trying to close.
Rewrite the header as "Focus on THIS comment — do not confuse it
with previous ones" (keeps the anti-stale-comment signal) and change
BuildCommentReplyInstructions to open with "If you decide to reply,
post it by running exactly this command" so the reply command is
available but conditional across both prompt surfaces.
2. Raw agent/user display names were being embedded directly into the
high-priority prompt and CLAUDE.md via TriggerAuthorName. Agent and
member names are only validated as non-empty at write time, so a
name containing newlines, backticks, or fake mention markup would
turn the field into a cross-agent prompt-injection surface.
Add execenv.SanitizePromptField — strip control runes, collapse
whitespace, drop markdown structural characters (backtick, asterisk,
brackets, pipe, angle brackets, hash, backslash), truncate to 64
runes — and apply it at both embed sites (per-turn prompt and
CLAUDE.md). Defense-in-depth at the consumption layer so this works
for already-stored names without a migration.
Tests: TestSanitizePromptField covers the policy; TestBuildPromptSanitizesAgentName
plants an attack payload in TriggerAuthorName and checks the rendered prompt
does not leak the newline-anchored injection or the fake mention markup.
TestBuildPromptCommentTriggered*{,ByMember} updated to lock in the
conditional reply-command framing.
* refactor(daemon): trim redundant CLAUDE.md preamble and drop name sanitizer
Per PR #1581 feedback:
1. Remove the `if ctx.TriggerAuthorType == "agent"` preamble block in
runtime_config.go. It duplicated what workflow steps 4 and 5 already
say ("Decide whether a reply is warranted", "Never @mention the
agent you are replying to as a thank-you or sign-off"), so the
signal lands the same without the extra ~7 lines of CLAUDE.md. The
per-turn prompt preamble in prompt.go stays — that surface has no
numbered workflow below it and would otherwise lose the
silence-as-exit signal.
2. Delete execenv.SanitizePromptField + its test. Workspace agents are
created by trusted team members, so the cross-agent name-injection
surface it defended isn't realistic in the current trust model.
3. Drop TriggerAuthorType/Name from execenv.TaskContextForEnv and stop
populating them in daemon.go — they're no longer read by the
execenv package. The same fields on daemon.Task stay because
prompt.go still needs them to label the triggering author in the
per-turn prompt.
Tests simplified to match the leaner shape: CLAUDE.md regression
guards now assert that the anti-loop phrases live in the numbered
workflow, and the sanitizer-specific tests are removed.
* feat(agents): show profile card on agent avatar hover
Hovering an agent avatar now opens a preview card with name, status,
runtime mode + connectivity, model, skills, and owner. Wired through
the shared ActorAvatar wrapper so every render site gets it; opt-out
via disableHoverCard in pickers and the agent's own detail header
where the card would be redundant or interfere with click selection.
* fix(agents): keyboard-focusable hover card + opt out on settings avatar
- Make the agent profile-card hover trigger focusable (tabIndex=0 with
visible focus ring), so keyboard users can open the card. Drops
cursor-default so the trigger inherits the parent control's cursor
instead of fighting it.
- Disable the hover card on the agent settings avatar — it's a
click-to-upload target on the agent's own settings page, where the
card would be redundant and the trigger conflicted with the upload
affordance.
* fix(agents): scope hover-card tab stop to standalone avatars only
Detect a focusable ancestor (link/button/role=button/tabindex>=0) at
mount and only flip the agent profile-card trigger to tabIndex=0 when
none exists. Avatars rendered inside an existing focusable parent (issue
list rows wrapped in AppLink, button-style cards, etc.) keep the trigger
unfocusable so they don't add redundant nested tab stops or bloat
keyboard navigation. Standalone avatars (e.g. comment author, issue
detail meta) remain keyboard-accessible with a focus-visible ring.
---------
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
All chart components used `hsl(var(--chart-X))` but `--chart-X` holds a
full oklch value, not bare HSL components — making the expression invalid
CSS. Browsers silently fell back to black, so bars/areas/heatmap cells were
invisible against the dark background.
- Replace `hsl(var(--chart-X))` with `var(--color-chart-X)` across all
runtime chart components and the landing feature section
- Fix heatmap opacity using `color-mix(in oklch, ...)` instead of the
invalid `hsl(var(--chart-3) / 0.3)` syntax; switch to foreground color
so cells blend with the neutral theme in both light and dark mode
- Raise dark-mode chart-2 through chart-5 lightness values so they
contrast clearly against the dark background
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous note claimed the frontend's auto-derived WebSocket URL
worked on LAN without extra configuration. It does not: Next.js
`rewrites()` only proxy HTTP requests, so the `Upgrade` handshake
required for WebSocket never reaches the Go backend, and real-time
features (chat streaming, live issue updates, notifications) silently
fail when accessing the app via a non-localhost host.
Replace the incorrect sentence with a dedicated subsection that points
users at the reverse-proxy recipe (already documented above, includes
the correct /ws Upgrade headers) and, for setups without a proxy,
documents the build-time NEXT_PUBLIC_WS_URL + selfhost.build.yml
override path.
Refs: GH #1522
The expand button relied on the parent row's inherited color, which
flipped to text-foreground via group-focus-within while the editor was
focused. The attach and submit buttons set text-muted-foreground on
themselves and stayed muted regardless of focus, so expand was the only
one changing color — inconsistent with the "default muted" convention
the other icon-buttons in this editor follow.
Give expand its own text-muted-foreground and drop the now-unused color
classes from the button row container.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(daemon/gc): tighten GC defaults + flex duration suffix
Driven by user feedback in #1539 (40 GB VPS filling within 24h of heavy
AI-coding usage): the existing TTLs were sized for desktop/laptop
deployments and are too lenient for small-disk, long-running daemons.
- GCTTL: 5d → 24h. Done/canceled issues almost never need a multi-day
grace period in AI-coding workflows.
- GCOrphanTTL: 30d → 72h. Covers crash-leftover and pre-GC directories
without a month-long wait.
- Issue-deleted orphans (API returns 404) are now cleaned on the next GC
cycle regardless of mtime. The issue row is gone; there is nothing
left to protect.
- parseFlexDuration: accept a `d` (day) suffix in addition to the stdlib
time.ParseDuration syntax. MULTICA_GC_TTL=5d now works; previously only
120h was accepted.
* fix(daemon/gc): address review — 404 safety + decimal/overflow in duration parser
Two issues flagged in PR review:
1. 404-immediate-clean is unsafe. The /gc-check endpoint returns 404 for
both "issue deleted" AND "daemon token has no access to the workspace"
(anti-enumeration, see requireDaemonWorkspaceAccess). Clean-on-404
would let a scoped-down daemon token wipe taskDirs whose issues are
still live. Restore the mtime gate against GCOrphanTTL. With the new
72h default we still shrink the original 30d window dramatically
without the cross-workspace hazard. Lock the behavior in with a new
test that asserts a recent 404 is skipped.
2. parseFlexDuration mishandled decimals and swallowed Atoi errors:
"0.5d" → 7m12s (regex matched only the "5d"), "1.5d" → 1h7m12s,
and 20+ digit day values Atoi-errored silently to 0. Match the full
decimal number with `\d*\.\d+|\d+` and parse with ParseFloat so
fractional days and oversized inputs both go through
time.ParseDuration correctly — fractions as sub-hour durations,
overflow as a returned error.
Review follow-up on PR #1557: the server-side change started returning
500 when the store write failed, but the daemon's handleLocalSkillList /
handleLocalSkillImport were discarding the ReportLocalSkill*Result error
return. Net effect was a silent drop — the daemon moved on, the request
stayed in "running" on the server, and the user saw the same "daemon did
not respond within 30 seconds" timeout the store refactor was supposed
to kill.
Fix: route both report calls through reportLocalSkillResultWithRetry,
which retries on 5xx + network errors with 0 / 0.5s / 2s / 4s backoff
(total ~6.5s, well inside the 60s server-side running timeout), stops
on 4xx (request expired / cross-workspace rejection — retry won't help),
bails on context cancel, and logs Error on exhaustion so ops has a
footprint to grep for.
Tests (server/internal/daemon/local_skill_report_test.go, 6 new cases):
- 500 twice then success -> 3 attempts, second retry lands
- 404 -> exactly 1 attempt (permanent, no retry)
- import 502 then success -> 2 attempts
- All-500 -> burns through all backoff slots then gives up with ERROR log
- Context cancel mid-backoff -> exactly 1 attempt, cancellation logged
- Smoke: report paths hit /api/daemon/runtimes/<rt>/local-skills{,import}/<req>/result
localSkillReportBackoffs is var-assignable so tests can swap in zero-delay
schedules without paying real sleep latency.
#1558 fixed the expand button covering trailing text, but also collapsed
the reply editor's "empty = 1 line, has content = 2 lines" behavior by
making the button row a permanent flex sibling below the editor.
Restore the original absolute-positioned button row on both editors:
- comment-input: back to `pb-8` container + `absolute bottom-1 right-1.5`
buttons (pre-#1558 layout; never had the overlap bug).
- reply-input: absolute buttons + `pb-7` gated on `!isEmpty || isExpanded`.
Empty → single-line compact; any content → two-row layout with buttons
below text (no overlap by construction).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(daemon): suppress agent terminal windows on Windows (#1471)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add hideAgentWindow to detectCLIVersion and avoid SysProcAttr overwrite
- Add missing hideAgentWindow(cmd) call in detectCLIVersion (claude.go:554)
so --version checks don't flash console windows on Windows.
- Refactor hideAgentWindow to preserve existing SysProcAttr fields
instead of overwriting the entire struct.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add an ArrowUpRight glyph next to Docs and Change log to signal they
open externally, and reorder so Feedback (internal modal) sits at the
bottom.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): shared-state runtime local-skill stores (MUL-1288)
Fixes the bug Bohan surfaced on MUL-1288: behind prod's multi-node API the
runtime-local-skill list/import flow would intermittently time out or 404.
Root cause: LocalSkillListStore and LocalSkillImportStore were per-process
sync.Mutex+map, so when the frontend POST, the daemon heartbeat and the
frontend GET landed on different API instances, each saw a different
pending set. Confirmed against production daemon logs — the failed
request_id never showed up in the daemon's "runtime local skills
requested" log, even though other requests around the same window worked.
Per Yushen's guidance (server must stay stateless; state lives in
storage), migrate both stores to Redis so every node agrees on the same
pending set.
What changed
- LocalSkillListStore / LocalSkillImportStore are now interfaces. Methods
take context.Context and return error.
- InMemoryLocalSkill{List,Import}Store — renamed from the existing types,
kept as the default for single-node dev and the in-process test suite.
- RedisLocalSkill{List,Import}Store — new. Keyed on
mul:local_skill:{list,import}:<id> (JSON record, TTL = retention), with
a per-runtime ZSET mul:local_skill:{list,import}:pending:<runtime_id>
(score = created_at UnixNano) providing cross-node ordering. PopPending
wins the claim via ZREM == 1, so concurrent pops from different nodes
never return the same request twice.
- NewRouter gets an optional *redis.Client; when non-nil it swaps in the
Redis-backed stores. main.go hoists the existing Redis client (already
used by the realtime relay) so both subsystems share one client.
- Handler fields flip to interface types; handler.New still constructs
in-memory stores by default.
- Daemon heartbeat's PopPending call sites thread r.Context() through so
Redis operations inherit request cancellation. Errors warn instead of
poisoning the heartbeat response.
Tests
- Existing in-memory tests updated for the new signatures (ctx + error).
- New runtime_local_skills_redis_store_test.go covers:
- Create/Get/Complete round trip preserves skills payload
- PopPending across two *store instances sharing one rdb (the exact
regression: node A creates, node B pops)
- N concurrent PopPending on one record => exactly one winner
- Pending-timeout threshold transitions the record and removes the zset
member so a later PopPending doesn't return a timed-out request
- Import store round-trips CreatorID (which is json:"-" on the public
struct — needs a Redis envelope so ReportLocalSkillImportResult can
still attribute the created Skill)
- Per-runtime isolation — a PopPending for runtime B does not disturb
A's pending zset
- Tests skip gracefully if REDIS_TEST_URL is unset; CI now spins up a
redis:7-alpine service and exports the URL so the suite actually runs
there.
Out of scope
PingStore / UpdateStore / ModelListStore have the same shape and the
same latent bug (they just fire rarely enough to have gone unnoticed).
Migrating them to Redis is a follow-up — MUL-1288 is specifically the
local-skills break Bohan is blocked on.
* fix(skills): atomic Redis claim + surface store write failures (PR #1557 review)
Two real gaps GPT-Boy flagged:
1. RedisLocalSkill{List,Import}Store.PopPending was doing ZREM then SET as
two separate round-trips. If the SET failed for any reason — transient
Redis error, context cancellation, pod getting SIGKILL'd mid-call — the
request was already gone from the pending zset but the stored record
still said "pending", and no subsequent PopPending would re-dispatch
it. Exactly the "request disappears" class of bug this PR is supposed
to kill.
Fix: push the claim into a Lua script so Redis runs ZREM + SET as one
atomic unit. If ZREM returns 0 (another node won the race), SET is
skipped and the caller retries.
2. ReportLocalSkill{List,Import}Result handlers were logging Complete/Fail
store failures at Warn and still returning 200 OK. That made the
daemon think the report landed when it hadn't, leaving the request
stuck in "running" until the server-side timeout and — worse for the
import flow — leaving the just-created Skill row orphaned in Postgres
so every retry collided with the unique-name constraint.
Fix: escalate to Error + return 500 so the daemon (and monitoring) can
see the write failed. For the import flow, Complete failure after the
Skill row is already committed also triggers a best-effort DeleteSkill
so a daemon retry lands on a clean slate instead of hitting
"a skill with this name already exists" forever.
Tests
- New TestRedisLocalSkillListStore_PopPendingAtomicClaim asserts the
happy-path invariant: after one PopPending the record is "running"
AND a second PopPending returns nothing. Deliberately does NOT poke
Redis internals directly so the test survives any future key-layout
refactor.
- Existing cross-instance / concurrent / timeout / per-runtime tests
continue to pass against the Lua-based claim path (verified locally
against a scratch redis-server; 8/8 Redis tests green).
* fix(cli): make browser-login work from a machine that isn't the server
The #923 callback host fix only worked when the CLI and the self-hosted
server ran on the same box. In a cross-machine setup — `multica login`
from a laptop against a self-hosted server on a NAS — the flow silently
wedged on two issues:
1. The callback host was derived from `--app-url`, so the `cli_callback`
URL pointed at the server's IP and the browser could never reach
the CLI's local listener on the laptop. The OAuth token never came
back and subsequent `/api/workspaces` calls 401'd on stale state.
2. `net.Listen("tcp", ...)` on macOS can produce an IPv6-only socket.
Browsers and `curl` resolve `localhost`/`127.0.0.1` to IPv4 first and
get "connection refused" even when the URL is otherwise correct.
Changes:
- Derive the callback host from the CLI's own outbound interface by
dialing the server (UDP, no packets sent — just asks the kernel which
source IP it would use). Falls back to loopback for public app URLs
and to the app IP for offline detection.
- Add `--callback-host` flag on `login` and `setup self-host` so
reverse-proxy / FQDN users can override auto-detection — this is the
follow-up @hassaanz asked for on #923.
- Pin the callback listener to `tcp4` so macOS never lands on an
IPv6-only socket.
- `multica setup self-host`: when the user explicitly passes a remote
`--server-url` but omits `--app-url`, infer app URL from the server
host and warn instead of silently defaulting to `localhost:3000`.
Unit tests cover the binding-decision matrix (public, localhost, same-
machine LAN, cross-machine LAN, outbound-detect failure, flag override)
and the new setup helpers.
Reported by @RafeRoberts in #1494 with very clear repro details.
* fix(cli): prompt for app_url instead of guessing on remote server_url
Per GPT-Boy's review on MUL-1260: deriving app_url as
http://<server-host>:3000 breaks for the common api.example.com +
app.example.com split and for https-fronted deploys — the setup flow
would still open a broken login URL, just slightly later.
Replace the guess with an interactive prompt. If the user hits enter
(or stdin is unavailable), fail loudly with a clear usage hint instead
of proceeding with bad data.
The comment and reply editors positioned their three trailing buttons
(expand, attach, submit) with `absolute` and relied on `pr-14` /
`pb-8` magic numbers to reserve space. The reserved 56px is smaller
than the actual 80px button row, so the leftmost button (expand)
visibly overlaps the trailing characters of a long line of text.
Restructure the button row as a normal flex sibling below the editor.
Text can no longer flow under the buttons, and the layout no longer
needs the `pr-14` hack, `pb-8` padding, or the ResizeObserver that
toggled `pb-7` when content overflowed.
Also align the expand button in comment-input with the reply-input
version (`h-6 w-6` + `h-3.5 w-3.5` icon) so the two entry points
match.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(runtimes): remove Test Connection / runtime ping feature
The Test Connection action invoked a real single-turn agent run to verify
runtime connectivity. In practice it was expensive (reuses none of the
normal task exec env, so it also gave misleading results) and low value —
daemon heartbeat + Online status already covers the "is the runtime
alive" question. Dropping the whole end-to-end probe path:
- deletes server handler and in-memory PingStore
- drops pending_ping from the heartbeat response and daemon poll loop
- removes daemon.handlePing, PendingPing, ReportPingResult
- removes the CLI `multica runtime ping` command
- removes the PingSection UI block and RuntimePing types / api methods
* docs: fix runtime CLI subcommand list in product-overview
Answers "did the user have an AI CLI installed locally when they hit
Step 3" — currently unanswerable from the existing funnel because the
bundled daemon fails to register at all when zero CLIs are on PATH, so
`runtime_registered` is silent on that cohort. Splits the 40% of
`completion_path=runtime_skipped` into "had CLIs, skipped anyway" vs "no
CLIs available, had no choice" — the two cases need opposite product
fixes.
Fires once per Step 3 mount in `step-runtime-connect.tsx` (desktop
only), when the scanning phase resolves — either immediately on first
runtime registration or after the 5 s empty timeout. Reports
`runtime_count`, `online_count`, sorted `providers`, convenience
booleans (`has_claude` / `has_codex` / `has_cursor`), and `detect_ms`.
Also writes `has_any_cli` + `detected_cli_count` via `$set` as cohort
signals.
Not emitted from the web Step 3 (`step-platform-fork.tsx`) — web users
don't run the bundled daemon, so their runtime list can reflect
daemons on other machines and would corrupt the
"CLI installed locally" signal.
Refs MUL-1250.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(landing): add sticky date navigation to changelog page
Adds a right-side "On this page" nav that lists every release date and
scroll-spies the active entry as the user reads through the changelog.
Dates are formatted per locale (e.g. "April 22" / "4月22日").
* feat(landing): move changelog date nav to left as timeline sidebar
Moves the date navigation from the right to the left and restyles it
as a grouped timeline:
- Releases are grouped under a month-year header ("April 2026").
- A vertical rail connects a dot per release; the active dot is filled
with a soft halo ring, the row text goes full-opacity + semibold.
- Clicking a date smooth-scrolls to the release and pins the hash; a
short nav lock suppresses scroll-spy flicker while the page animates.
- Sidebar is sticky up to viewport height, scrollable when there are
many releases; on <lg the sidebar collapses and content falls back
to the existing centered layout.
- Entry headers now render the full localized date for clarity.
Label changed from "On this page" / "本页目录" to "All releases" /
"历史版本" to match the new nav-style role.
* fix(landing): align changelog nav day/version columns
Reserve a fixed-width right-aligned slot for the day number so
single-digit days (e.g. "1", "9") don't shift the version column.
---------
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
* fix(agents): drop auto-loading Local Runtime Skills section from Skills tab
Every visit to an agent's Skills tab fired POST
/api/runtimes/<id>/local-skills + a polling GET, which:
- Created noise on every tab open (the section was rarely the user's
reason for entering the tab — they came in for workspace skills).
- Currently 404s under the dev backend's multi-replica deploy because
the runtime-local-skills request store is in-process; the polling
GET frequently lands on a different replica than the POST. The
protocol fix is tracked separately; this PR just stops the
unsolicited polling.
Removes the entire `Local Runtime Skills` inline section, the
`runtimeLocalSkillsOptions` query, and the per-skill Import dialog
mount on this tab. Users who want to import a local skill go through
the Skills page's `+ Add Skill` → `From Runtime` tab — the same flow
that handles all other skill creation, only triggered explicitly.
Top blue callout stays — still accurate: local runtime skills are
auto-available to the agent, importing creates an editable workspace
copy.
* test(agents): replace stale Local Runtime Skills assertion with negative case
The previous test required the inline section + auto-loading runtime
local skills query, both removed in this PR. Replace it with a
regression test that asserts the section is gone, the per-row import
button is gone, and the top informational callout still renders so we
know the tab body actually mounted.
Drops the now-unused @multica/core/runtimes mock; if a future change
re-introduces that import, the missing mock would surface immediately.
* feat(realtime): phase 0 — extract Broadcaster interface + add metrics
Phase 0 of the WebSocket horizontal-scaling plan tracked in MUL-1138.
This change is intentionally behavior-preserving: it sets up the seams
needed for later phases (subscribe/unsubscribe protocol, scope-level
fanout, Redis Streams relay) without altering any wire protocol or
producer call sites.
What changed
- New realtime.Broadcaster interface covering the three fanout methods
producers already use on *Hub (BroadcastToWorkspace, SendToUser,
Broadcast). *Hub continues to satisfy it; a future Redis-backed
implementation can be dropped in without touching listeners.
- registerListeners now depends on realtime.Broadcaster instead of
*realtime.Hub, isolating the bus → realtime fanout layer behind an
interface.
- New realtime.Metrics singleton with atomic counters: connects,
disconnects, active connections, slow-client evictions, total
messages sent/dropped, and per-event-type send counters. Wired into
Hub register/unregister/broadcast paths and into every listener.
- New GET /health/realtime endpoint returning a JSON snapshot of the
metrics so we can observe baseline fanout pressure before phase 1.
Why phase 0 first
GPT-Boy's only-Redis plan and CC-Girl's review both call out the same
prerequisite: get a Broadcaster seam and visibility in place before
introducing scope-level subscriptions or a Redis relay. Doing this as
a standalone step keeps each later PR focused and trivially revertable.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(realtime): only-Redis fanout — scopes, subscribe protocol, Redis Streams relay (MUL-1138)
Implements the final-version plan agreed in MUL-1138 on top of phase 0:
* Hub: 4 scope types (workspace/user/task/chat), per-client subscription
set, subscribe/unsubscribe WS frames, ScopeAuthorizer hook for
task/chat scope auth, first/last-subscriber callbacks for the relay,
workspace+user auto-subscribe on connect.
* RedisRelay: Broadcaster impl that XADDs every event into
ws:scope:{type}:{id}:stream and XREADGROUPs only the scopes for which
this node has live subscribers. Per-node consumer group, heartbeat,
stale-consumer sweeper, MAXLEN cap, lag/disconnect metrics.
* Listeners: route task:* events to ScopeTask, chat:* events to
ScopeChat; workspace remains the default for everything else.
* events.Event: optional TaskID / ChatSessionID hints so the listener
layer can pick the right scope without re-parsing payloads.
* Handler: publishTask / publishChat helpers; chat + task message
publishers updated to use them.
* main.go: when REDIS_URL is set, wrap the hub with NewRedisRelay and
pass the relay (instead of the hub) to registerListeners. A
db-backed ScopeAuthorizer enforces that task/chat subscribes belong
to the caller's workspace.
* Metrics: per-scope subscribe/deny counters, redis connect state, node
id, lag/dropped counters surfaced via /health/realtime.
Behavior in single-node mode (REDIS_URL unset) is unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(realtime): address PR #1429 review must-fix items (MUL-1138)
- listeners: keep task/chat events on workspace fanout until the WS
client supports scope-subscribe + reconnect-replay. Routing them
through BroadcastToScope today (without any client subscriber) would
silently drop every chat / task message and break the live timeline,
chat unread badges, and pending-task UI. The server-side scope infra
(Hub subscribe/unsubscribe, ScopeAuthorizer, Redis Streams relay)
stays in place so flipping the switch in the client follow-up PR is
a one-line change.
- scope_authorizer: ScopeChat now enforces CreatorID == userID, mirroring
the HTTP layer (handler/chat.go: GetChatSession / SendChatMessage /
MarkChatSessionRead). Without this, any workspace member who learned a
session_id could subscribe to chat:message / chat:done /
chat:session_read for a peer's private chat. The same creator-only
check is applied to ScopeTask when the task is a chat task
(task.ChatSessionID set). Issue tasks remain workspace-scoped.
- Refactor scope authorizer to depend on a narrow scopeAuthQuerier
interface so its decisions can be unit-tested without a live DB.
- Add tests:
* listeners_scope_test.go pins the workspace-fanout fallback for
task:message / task:progress / chat:message / chat:done /
chat:session_read.
* scope_authorizer_test.go covers chat creator-only access, chat-task
creator-only access, and issue-task workspace-only access (creator
allowed, peer denied, cross-workspace denied, missing session
denied, empty userID denied).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
* fix: pass model to Hermes ACP session/new and add hermes to InjectRuntimeConfig
- hermes.go: include opts.Model in session/new params so Hermes uses
the configured model instead of its default (fixes local LLM failures)
- runtime_config.go: add "hermes" to the AGENTS.md provider list so
Hermes receives the Multica runtime instructions and skill discovery
Fixes: https://github.com/multica-ai/multica/issues/1195
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(hermes): drop false native-skill claim and add regression tests
The previous change added 'hermes' to the 'skills discovered automatically'
branch of buildMetaSkillContent, but resolveSkillsDir has no Hermes case so
skills still land in the .agent_context/skills/ fallback. AGENTS.md ended up
claiming native discovery while the files were somewhere else, which would
mislead Hermes (and future debuggers).
- Move 'hermes' to the fallback branch alongside 'gemini' so AGENTS.md points
Hermes at .agent_context/skills/ — matching where writeContextFiles actually
writes them.
- Extract buildHermesSessionParams so the session/new payload is unit-testable.
- Add regression tests covering:
* buildHermesSessionParams includes/omits 'model' correctly
* InjectRuntimeConfig('hermes') writes AGENTS.md with the fallback hint
* writeContextFiles('hermes') writes skills to .agent_context/skills/
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
DesktopNavigationProvider stubbed `searchParams` to an empty
URLSearchParams, so any shell-level consumer of useNavigation() that
looked at query params read blanks. The miss surfaced in focus-mode:
on /inbox?issue=<id>, ChatWindow's useRouteAnchorCandidate couldn't
see the selection, so the Focus button stayed disabled.
Mirror the full location (pathname + search) from the active tab's
router — same subscription pattern TabNavigationProvider already uses
~30 lines below. InboxPage itself was fine because it's rendered
inside TabNavigationProvider; the bug only hit components mounted at
the shell root (ChatWindow, ChatFab, and any future sibling).
No test: the fix is an identical copy of a production-shipped pattern
in the same file, and the mock surface needed to exercise the adapter
(useActiveTabRouter + memory router + tab store) exceeds the fix
itself. Verified via pnpm typecheck across all packages.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(feedback): add in-app feedback flow and Help launcher
Replaces the duplicated bottom-sidebar user popover and "What's new" links
with a single Help menu (Docs / Feedback / Change log) pinned to the
sidebar footer. Feedback opens a rich-text modal that POSTs to a new
/api/feedback endpoint; submissions land in a dedicated feedback table
with per-user hourly rate limiting (10/hr) to deter spam without adding
middleware infrastructure. User identity (avatar + name + email) moves
into the workspace dropdown header so the sidebar is no longer visually
redundant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(feedback): harden submit path and cap request body
- Read editor markdown via ref at submit time instead of debounced state,
so ⌘+Enter immediately after typing doesn't drop the last keystrokes.
- Block submission while images are still uploading; toast prompts the
user to wait instead of silently sending markdown with blob: URLs
that get stripped.
- Cap /api/feedback request body at 64 KiB via MaxBytesReader so an
authenticated client can't bloat the metadata JSONB column with an
oversized url field.
- Add Go handler tests covering happy path, empty-message rejection,
and the hourly rate limit boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(analytics): instrument feedback funnel
Adds two events pairing frontend intent with backend conversion so we
can compute a completion rate for the in-app Feedback modal:
- `feedback_opened` (frontend) — fires once on FeedbackModal mount.
Source is currently always "help_menu" but the type is a union so
future entry points have to extend it explicitly. Workspace id is
attached when present.
- `feedback_submitted` (backend) — fires from CreateFeedback after the
DB insert succeeds and the hourly rate-limit check has passed.
Message content itself is never sent to PostHog; the event carries
a coarse length bucket (0-100 / 100-500 / 500-2000 / 2000+), an
image-presence flag, and the client platform / version pulled from
X-Client-* headers via middleware.ClientMetadataFromContext.
Affects no existing funnel; seeds a new Feedback funnel for product
triage.
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-23 10:35:55 +08:00
388 changed files with 25724 additions and 5949 deletions
@@ -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:
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
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.
> **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 |
docker compose -f docker-compose.selfhost.yml up -d
```
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
### WebSocket for LAN / Non-localhost Access
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image, use the source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js rewrites proxy `/api`, `/auth`, and `/uploads` to the backend. **WebSockets do not**: Next.js rewrites only forward HTTP requests, not the `Upgrade` handshake a WebSocket needs. If you open the app on `http://<lan-ip>:3000`, real-time features (chat streaming, live issue updates, notifications) will fail to connect until you do one of the following:
1.**Put a reverse proxy in front of the stack (recommended).** Nginx or Caddy terminates the WebSocket upgrade and forwards it to the backend on port 8080. See the [Reverse Proxy](#reverse-proxy) section above — the Nginx example already includes a `location /ws { ... }` block with the correct `Upgrade` / `Connection` headers. Once a proxy is in place the browser connects directly through it, so no frontend rebuild is needed.
2.**Bake a WebSocket URL into the web image.** If you are not running a reverse proxy, rebuild the web image with `NEXT_PUBLIC_WS_URL` pointing straight at the backend (port 8080 must be reachable from the browser):
```bash
# In .env
NEXT_PUBLIC_WS_URL=ws://<lan-ip>:8080/ws
# Rebuild the web image so the build-time value is baked in
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
```
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
@@ -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
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`.
@@ -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).
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
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 |
@@ -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.
@@ -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).
@@ -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.
| `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`.
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.
@@ -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) |
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
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.
**`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
@@ -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
@@ -109,4 +109,4 @@ 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
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task
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
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))
@@ -270,6 +270,24 @@ funnel and used to size hosted-runtime interest.
`distinct_id` is the user's id.
### `feedback_submitted`
Fires from `CreateFeedback` after the `feedback` row is inserted and the
hourly per-user rate-limit check has passed. Retries within the same hour
that were rate-limited (429) don't emit. The free-text message is stored
in the DB and never broadcast.
| Property | Type | Description |
|---|---|---|
| `message_length_bucket` | string | `0-100` / `100-500` / `500-2000` / `2000+` — coarse bucket of `len(message)` so we can tell "quick note" from "bug report with repro steps" without leaking content. |
| `has_images` | bool | `true` when the markdown contains at least one `` image reference — signals bug reports with visual evidence. |
| `platform` | string | Client platform from `X-Client-Platform` header (`web` / `desktop`). Omitted when the header is absent. |
| `app_version` | string | Client version from `X-Client-Version` header. Omitted when absent. |
`distinct_id` is the submitter's user id; `workspace_id` is attached from
the modal's current-workspace context and may be empty when feedback is
sent from a pre-workspace surface.
### `starter_content_decided`
Fires on the atomic NULL → terminal state transition in both
* Issue labels — workspace-scoped, applied as many-to-many to issues.
*
* Labels are lightweight metadata (name + color) distinct from projects:
* projects group related work, labels are cross-cutting tags (bug, feature,
* performance, …). Colors are normalized to lowercase `#RRGGBB`.
*/
exportinterfaceLabel{
id: string;
workspace_id: string;
name: string;
/** Normalized lowercase hex color, e.g. `#3b82f6`. */
color: string;
created_at: string;
updated_at: string;
}
exportinterfaceCreateLabelRequest{
name: string;
color: string;
}
exportinterfaceUpdateLabelRequest{
name?: string;
color?: string;
}
exportinterfaceListLabelsResponse{
labels: Label[];
total: number;
}
exportinterfaceIssueLabelsResponse{
labels: Label[];
}
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.