New /download visitors were seeing grayed-out macOS buttons in the 20-ish
minutes after a tag push because CI only builds Linux/Windows — Mac is
still packaged manually and uploads tens of minutes later. Swap the
`/releases/latest` fetch for `/releases?per_page=2` and, when the latest
release is under an hour old, render the previous (fully-populated)
release instead. After the freshness window, page auto-switches to latest.
Frontend-only change — GitHub "latest" marker, electron-updater, and
homebrew paths are untouched.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Gemini CLI has no `models list` subcommand, so Multica can't do real
dynamic discovery. Instead, swap the static catalog from fixed version
names (2.0/2.5 only) to the CLI's own aliases (`auto`, `pro`, `flash`,
`flash-lite`, `auto-gemini-2.5`) plus explicit pins for Gemini 3
preview and 2.5 variants. Aliases are resolved inside the Gemini CLI
per user entitlement + quota, so new model releases light up without
a Multica redeploy. Default is `auto`, matching Google's recommended
selection.
Fixesmultica-ai/multica#1503.
The focus toggle was only disabled when focusMode was already ON
*and* the current page had no anchor. Off-state on the same page
stayed clickable — clicking turned it on, and the button instantly
greyed out, making the missing fourth state visible.
Decouple "clickable" from focusMode: the button is disabled whenever
the current page has no anchor, regardless of the persisted on/off
preference. Both the chip render (context-anchor.tsx:173) and send
path (chat-window.tsx:176) already guard on candidate presence, so
leaving focusMode=true on an unanchorable page has no side effects —
the preference is preserved for the next anchorable page.
Tooltip now reads "Nothing to share with Multica on this page"
whenever the button is disabled, regardless of focusMode.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-arch images were built on a single amd64 runner with QEMU
emulating arm64. The Next.js build (Dockerfile.web) under emulation
took 30+ minutes per release and was the long pole of the workflow.
Split each image build across two native runners (amd64 on
ubuntu-latest, arm64 on ubuntu-24.04-arm), push by digest, then
merge into a manifest list with docker buildx imagetools. QEMU is
no longer needed.
Backend and web each become a (matrix build + merge) pair, replacing
the previous single docker-images job. Per-platform GHA cache scopes
avoid cross-arch cache eviction.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All 15 files deleted here correspond to already-merged PRs —
their execution is done, their rationale lives in commit
messages and PR descriptions, and nothing in the tree references
them (grep across *.ts / *.tsx / *.go / *.mjs / *.json returns
zero hits).
Removed:
docs/download-redesign.md → PR #1500
docs/download-positioning.md → PR #1500
docs/onboarding-redesign-proposal.md → PR #1411
docs/workspace-url-refactor-proposal.md → PR #1131 / #1138
docs/plans/2026-04-07-tanstack-query-migration.md
docs/plans/2026-04-08-board-dnd-rewrite.md
docs/plans/2026-04-08-drag-upload-enhancement.md
docs/plans/2026-04-08-image-view-enhancement.md
docs/plans/2026-04-08-monorepo-extraction.md
docs/plans/2026-04-09-desktop-app.md
docs/plans/2026-04-09-monorepo-extraction.md
docs/plans/2026-04-09-upload-attachment-fixes.md
docs/plans/2026-04-15-workspace-slug-url-refactor.md
docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
docs/plans/2026-04-16-unify-workspace-identity-resolver.md
Empty `docs/plans/` directory goes with them (git drops empty
dirs automatically). Active, non-plan docs stay: analytics.md,
design.md, product-overview.md, codex-sandbox-troubleshooting.md.
Any future plan can live on a feature branch under
`.claude/plans/` (harness-scoped, not committed) or as a PR
description. No need to land them in-tree.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(views): extract IssueChip shared primitive from mention card
IssueMention (in editor NodeView) and IssueMentionCard shared 95% of their
markup — StatusIcon + identifier + title inside a bordered chip. They drifted
into two parallel implementations so changes had to be made in two places.
Extract the presentational chip into IssueChip. The navigable variants
(IssueMentionCard, the editor NodeView) become thin shells that layer
routing + cmd/shift behaviour onto the shared chip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat): add focus mode to share current page as context
Adds a Focus button next to the chat submit. When on, the chat auto-attaches
whatever the user is viewing (issue, project, or inbox-selected issue) as a
context prefix on outgoing messages, so the agent knows what "this" refers
to without the user pasting ids.
The attached object is derived from the route + react-query cache on every
render — no separate copy in state. Only the boolean focusMode is persisted
(global to the user, not per-workspace), matching the "my preference"
mental model.
The button has three visual states driven by two dimensions (focusMode +
whether the current route resolves to an anchorable object):
- off: ghost + muted, click turns on
- on + anchor: secondary (bright), click turns off
- on + none: disabled (nothing to attach here)
The derived anchor renders above the input as a chip — IssueChip for issues,
a new ProjectChip for projects — wrapped in AppLink so the visual target
matches the clickable target (mirrors IssueMentionCard's hover + navigation).
Prefix format reuses the editor's mention markdown:
Context: [MUL-1](mention://issue/<uuid>) — "Fix login bug"
Context: Project "Authentication"
so the agent sees an identical token whether the user @-mentioned inline or
focus-mode attached. Backend is untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(download): add redesign plan and copy positioning source of truth
Captures motivation (Desktop is Multica's native form; CLI is a
distinct scenario for servers/remote boxes, not a Desktop fallback),
four-step execution plan, and every touchpoint's current-vs-new
copy in EN + ZH. Subsequent UI steps read strings from the
positioning doc instead of inventing them inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): /download page with OS auto-detection
New landing-group route that serves as the single canonical download
destination. Auto-detects OS + arch via navigator.userAgentData
(Chromium) with UA-string fallback, then surfaces the matching
Desktop installer as the primary CTA. All platforms stay visible
below, plus a CLI section (positioned for servers / remote boxes /
headless setups, not as a lightweight Desktop) and a Cloud waitlist.
Version + asset URLs come from api.github.com/repos/.../releases/latest
with Vercel ISR (revalidate=300) so every release automatically
propagates — no manual redeploy. Optional GITHUB_TOKEN env var lifts
the 60/hr unauthenticated rate limit for local dev. Failure
degrades cleanly to "Version unavailable" + a link to GitHub
releases.
Also points landing hero + footer Download links at /download
(previously pointed at the GitHub releases page directly), and
re-exports CloudWaitlistExpand from @multica/views/onboarding so
the new Cloud section can reuse the existing form.
Intel Mac has no binary today (electron-builder targets mac arm64
only); the page is honest about it and routes Intel users to CLI.
i18n copy sourced verbatim from docs/download-positioning.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): rewrite Step 3 fork + web Welcome Desktop CTA
Welcome screen now self-segments: on web (runtimeInstructions
present), the primary CTA is "Download Desktop" with a benefit-led
subtitle ("Desktop bundles the runtime — nothing to install.
Continue on web to connect your own CLI.") that lets developers
with their own CLI recognize their path while guiding everyone
else toward the desktop app. Desktop branch drops the "3 minutes"
estimate in favor of the aha promise. Download button is a real
<a href> link so middle-click / copy-link / screen readers all
behave correctly.
Step 3 fork drops the stale isMac gate — Windows / Linux binaries
now ship, the macOS-only muted card was a lie. The single Desktop
card now routes to /download (not GitHub releases directly) so
users land on the auto-detect page. CLI card is reframed around
its real scenario (servers, remote dev boxes, headless) rather
than posing as a lightweight Desktop, and the CLI dialog's stall
tier redirects users to Desktop instead of Cloud waitlist when
the daemon never registers — Desktop is the genuine retreat.
cli-install-instructions gets a one-liner acknowledging the CLI's
server use case, mirroring the card copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web,auth): desktop promotion on login + solid landing hero download
LoginPage accepts a new `extra?: ReactNode` slot rendered below the
Google button. The web shell injects a hardcoded-EN "Prefer the
desktop app? Download →" nudge there — catching users at their
lowest-investment moment, before they've typed an email. Desktop's
login wrapper omits the slot (a download prompt inside the app
would be absurd), so only the web surface renders it.
Copy is English-only for now because the /login route sits outside
the landing group's LocaleProvider. Lifting locale detection into
the root layout would force every page dynamic and kill the Router
Cache — a trade-off not worth two strings. The `auth.login.extra*`
i18n keys added during Step 2 are removed for the same reason:
they're dead code without a LocaleProvider wrapping login.
Landing hero "Download Desktop" upgrades from ghost to solid and
swaps its handwritten monitor SVG for lucide-react's Download
icon. Both hero CTAs are now solid-weighted — the icon + distinct
label differentiates them. href already points to /download from
the Step 2 landing nav pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web/download): anchor dark LandingHeader with relative wrapper
LandingHeader's dark variant uses `absolute top-0 inset-x-0`, which
only reads correctly when wrapped by a positioned ancestor — see
multica-landing.tsx:14 for the canonical pattern. Without the
wrapper the header escaped to the initial containing block and
appeared fixed as users scrolled the page.
Also drops the <main> element around the body sections for
consistency with the rest of the landing group (neither
multica-landing nor about-page-client wraps in <main>).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(landing/hero): keep Download Desktop as ghost to preserve CTA hierarchy
Upgrading to solid alongside the existing "Start free trial" CTA
killed the primary / secondary distinction — both buttons were
white on dark, competing for attention. Revert to ghost so the
conversion CTA (trial) stays the visual primary. The lucide
Download icon swap stays (cleaner than the handwritten monitor
SVG).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(onboarding): update platform-fork assertions for /download route
The Desktop card in Step 3 now opens the new /download page instead
of GitHub releases, and the post-click feedback text changed to
match ("Continuing on the download page…" in place of "Downloading
Multica…"). Update the expectations and drop the isMac navigator
stub that was only needed when the component had a macOS-only
primary branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Merge origin/main into NevilleQingNY/download-redesign
Main added onboarding funnel analytics (#1489) that captures
`is_mac` as a dimension for each Step 3 path selection. This
branch had removed the `isMac` state because the UI no longer
branches on it (Windows / Linux desktop builds ship now). Git
auto-merged the two diffs into a file that referenced a deleted
variable.
Reintroduce `isMac` as a lazy client-only computation scoped to
analytics capture only — the UI stays platform-agnostic. Handlers
fire client-side so SSR safety isn't needed; a plain const reads
navigator on first render.
typecheck passes across all 6 packages; all 166 views tests
green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(analytics): instrument download funnel across 5 surfaces + /download
Closes the gap left by PR #1489: onboarding analytics captured Step
3 path selection but missed the four surfaces that advertise the
desktop app earlier in the funnel (landing hero, landing footer,
login, Welcome), and the /download page itself had zero coverage —
so we could see the last-mile path but not the top-of-funnel entry
nor the page-to-installer conversion.
Three new events, wired via `@multica/core/analytics`:
1. `download_intent_expressed` fires on any CTA pointing at
/download. `source` splits the five surfaces cleanly; every
authenticated emission also writes `platform_preference=desktop`
on the person (same convention Step 3 already uses).
2. `download_page_viewed` fires once per /download mount after OS
detect resolves. Carries `detected_os`, `detected_arch`,
`detect_confident` (Chromium userAgentData vs UA fallback), and
`version_available` so the Safari-on-Mac arm64-default cohort
and GitHub-rate-limited degraded sessions are each isolable.
Also $set_once's `first_detected_os/arch` on the person so every
downstream event gains a platform dimension without re-emitting.
3. `download_initiated` fires on every installer click — Hero's
primary CTA and each All Platforms matrix row. `primary_cta`
splits hero-recommended from manual picks; `matched_detect`
quantifies detect accuracy from the single event (no cross-join
to download_page_viewed needed).
Augments the existing `onboarding_runtime_path_selected` with a
`source: "step3"` property — literal today, reserved for future
surfaces reusing the same event name. `is_mac` kept for
backward-compat with PR #1489's dashboards; the new events use
`detected_os` + `detected_arch` instead.
New `setPersonPropertiesOnce` wire helper in
`packages/core/analytics/download.ts` for `$set_once` — mirrors
the backend's `Event.SetOnce` semantics.
docs/analytics.md update lands in the follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(analytics): document download_intent_expressed / page_viewed / initiated
Adds the three new download-funnel events to the frontend-only
section. Also notes the semantic shift on
onboarding_runtime_path_selected: its `path: "download_desktop"`
now signals Step 3 path choice, not actual download start —
download_intent_expressed is the new canonical "user expressed
intent to download desktop" signal across surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(analytics): client_type super-property + Desktop $pageview (MUL-1253)
Register a `client_type` super-property ("desktop" | "web") plus optional
`app_version` inside `initAnalytics`, so every PostHog event from the
renderer can be split by client without relying on `$lib` (both Electron
and Next.js report "web"). `appVersion` flows in from `ClientIdentity`
via `CoreProvider` → `AuthInitializer`.
Add a Desktop `PageviewTracker` mounted in `DesktopShell` that fires
`$pageview` whenever the active tab's path changes, mirroring the Web
tracker. Restores the `/ → signup → workspace_created` funnel for the
desktop client and enables web-vs-desktop breakdowns.
* fix(analytics): preserve super-props on reset + cover overlay/login pageviews
Two blockers from PR review:
1. `posthog.reset()` wipes persisted super-properties, so after logout or
account switch the next session's events silently dropped `client_type`
and `app_version` until a full reload. Cache the set at init time and
re-register it inside `resetAnalytics()` so the breakdown survives the
auth transition. Added unit tests to pin the invariant.
2. Desktop `PageviewTracker` only watched the active tab path, which
missed pre-workspace overlays (`/onboarding`, `/workspaces/new`,
`/invite/<id>`) — those aren't tab routes on desktop — and also missed
the logged-out `/login` state. Move the tracker to the app root and
derive the visible path from `(user, overlay, activeTabPath)` with
overlay > tab precedence so the `$pageview` stream matches the
surface the user actually sees.
Replace separate CreateAutopilotDialog / EditAutopilotDialog with a single
shared <AutopilotDialog mode="create"|"edit"> that mirrors the issue create
modal — dynamic sizing, expand/collapse, richtext Prompt, pill toolbar.
- Tiptap ContentEditor replaces plain textarea for Prompt; detail page
renders description via ReadonlyContent for visual parity.
- Pills: Agent, Priority, Execution Mode, Schedule (Popover hosting
TriggerConfigSection). 0/1/N trigger strategy: add on 0, edit inline on
1, disabled with tooltip on 2+ (power users edit in detail page).
- Exposes priority + execution_mode at creation time (backend always
supported them; old UI only offered them in Edit).
- parseCronExpression reverse-parses stored cron back to TriggerConfig for
Edit-time prefill (with round-trip tests).
- PillButton extracted to packages/views/common for reuse across modals.
- DialogContent uses showCloseButton={false} so the shared header renders
the Maximize + Close buttons next to the Rocket-prefixed breadcrumb.
- Conditional mount at call sites (`{open && <AutopilotDialog/>}`) keeps
state fresh on each open.
- Schedule dirty detection compares cron+timezone payloads vs mount
snapshot, and edit-mode submits against a snapshotted trigger id so
concurrent WS refetches can't mis-target the update.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Publish stable GHCR self-host images, switch self-host deploys to official image pulls with a source-build fallback, and move self-host signup / Google OAuth config onto runtime /api/config.
* feat(analytics): capture onboarding funnel events + person-property $set
Closes the visibility gap introduced by the Onboarding relaunch: the
five new steps between signup and workspace_created were invisible to
PostHog, and we couldn't see Step 3 web-fork drop-off, cloud waitlist
intent, or starter-content acceptance at all.
Server-side events (see docs/analytics.md for full contracts):
- onboarding_questionnaire_submitted — fires once when all three
answers first land; also $set's role/use_case/team_size on the
person so every subsequent event is cohortable
- agent_created — not onboarding-specific; is_first_agent_in_workspace
isolates the Step 4 signal
- onboarding_completed — fires on the actual NULL → timestamp flip
with completion_path (full / runtime_skipped / cloud_waitlist /
skip_existing / unknown) + joined_cloud_waitlist
- cloud_waitlist_joined — sizes hosted-runtime interest
- starter_content_decided — imported vs dismissed, split by
agent_guided / self_serve branch on both sides
Also adds Event.Set (→ PostHog $set) alongside the existing SetOnce so
the same events can carry mutable cohort signals without a separate
identify round-trip.
* feat(analytics): wire frontend onboarding events + completion_path
- captureEvent / setPersonProperties helpers in @multica/core/analytics,
with the same pre-init buffering as identify/pageview so config races
don't drop step transitions
- onboarding_runtime_path_selected fires from step-platform-fork for
the three web-fork choices (download desktop / CLI / cloud waitlist),
plus platform_preference on person properties for downstream splits
- completeOnboarding now takes an OnboardingCompletionPath; the
onboarding shell derives full / runtime_skipped / cloud_waitlist
from runtime + waitlist state (lifted to the shell so StepFirstIssue
can see both), and handleWelcomeSkip passes skip_existing
- saveQuestionnaire mirrors team_size/role/use_case into person
properties via $set so every event on this user becomes cohortable
- StepAgent sends the template slug, StarterContentPrompt passes
workspace_id on dismiss so the server can mirror the branch label
* docs(analytics): document onboarding funnel events + $set person properties
Two entry points to multica.ai/changelog so users actually find out
what shipped:
- Sidebar user menu (both expanded popover + collapsed dropdown
variants) gains a "What's new" item with a Sparkles icon, sitting
above Log out. Plain `<a target="_blank">` works on both surfaces:
web opens a new tab, desktop's main-process
setWindowOpenHandler intercepts and routes through
openExternalSafely. The shared view doesn't need to branch.
- Desktop's UpdateNotification "ready to restart" card grows a
secondary "See changes" button next to "Restart now", giving the
user a reason to actually restart instead of dismissing. Mirrors
Conductor's update prompt pattern. The "available" / "downloading"
states stay action-only — the changelog isn't useful before the
download finishes.
No version-detection / unread-tracking yet. Web users still need to
click into the menu to see the changelog; that's a follow-up if the
team wants Linear-style "new" dot.
`ListPins` used to join `issues` / `projects` so each pin row carried a
`title`, `status`, `identifier`, and `icon`. Convenient for the sidebar
but architecturally wrong: those fields live on a different cache key
than the pin query, so an `issue:updated` WS event invalidates
`issueKeys` and never touches `pinKeys`. The sidebar therefore showed
stale issue status / titles on pinned rows until a hard refresh —
and the same shape would silently re-emerge for any new enriched
field added later.
This refactor moves the join to the client so display data flows from
its real source of truth:
Server (`server/internal/handler/pin.go`):
- `PinnedItemResponse` keeps only pin-owned columns (id, workspace_id,
user_id, item_type, item_id, position, created_at).
- `ListPins` no longer fetches issues / projects in the loop and no
longer hides orphaned pins; the client decides how to render a pin
whose target was deleted.
- `formatIdentifier` helper deleted (was only used by the enrichment
branch); `strconv` import dropped along with it.
Types (`packages/core/types/pin.ts`):
- `PinnedItem` interface now mirrors the bare server shape. The four
enriched fields are removed.
Sidebar (`packages/views/layout/app-sidebar.tsx`):
- New smart wrapper `PinRow` resolves each pin's display data via
`useQuery(issueDetailOptions(...))` or `useQuery(projectDetailOptions(...))`
with `enabled` gates on `pin.item_type` so the hook order stays
stable. Loading renders a flat skeleton; error / 404 renders null
(orphan pins hide themselves).
- `SortablePinItem` becomes purely presentational: it now takes
`label` and `iconNode` as props instead of reading them off the pin
object. dnd-kit / navigation wiring untouched.
- Same pattern as `packages/views/search/search-command.tsx:151`,
which already uses per-row detail queries for Recent issues.
WS sync layer is unchanged: `onIssueUpdated` already patches
`issueKeys.detail`, so changing an issue's status now flows directly
into the sidebar without any cross-entity invalidate. The `pin:*`
prefix handler still invalidates `pinKeys` for create / delete /
reorder — that's still the correct signal for the pin LIST itself.
Verified: views typecheck + core typecheck + web typecheck +
desktop typecheck + go test ./internal/handler/... + vitest
(views: 165 tests, core: 83 tests) all pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(slugs): reserve homepage + expand reserved slug list (MUL-961)
- Fix: `homepage` was a live `/homepage` landing route in apps/web but not
in the reserved list, so a user could register a workspace slug that
shadowed the landing page. Now reserved on both backend and frontend.
- Add likely-future global routes (home, dashboard, profile, account,
billing, notifications, search, members) so we don't have to do another
audit/rename pass when these get wired up.
- Add API/ops prefixes (v1, v2, graphql, webhooks, sdk, tokens, cli,
health, ws, metrics, ping) as defense-in-depth against collision with
API aliases and ops endpoints.
- Clarify in both source files that the dotted/underscored entries in the
"Next.js / web standards" section are currently unreachable under the
slug regex `^[a-z0-9]+(?:-[a-z0-9]+)*$` and are kept as defense-in-depth
in case the regex is ever relaxed.
- Add audit migration 056 following the 047/049 pattern to fail loud if
any production workspace slug collides with the newly reserved set.
* fix(slugs): rename prod conflicts in migration 056 (home → home-1, dashboard → dashboard-1)
Per db-boy's prod audit in the MUL-961 thread, two §3 slugs had live prod
workspaces at reservation time. Decision on MUL-961: force-rename both in
the audit migration (scheme 1), same playbook as MUL-972 for admin/multica/
new/www.
- `home` → `home-1` (68a982da, zzlye, 2026-04-14)
- `dashboard` → `dashboard-1` (ea5a332f, 王争, 2026-04-22)
Targeted UPDATEs land first, followed by a generic `<slug>-N` fallback that
handles any row that slips in between the audit snapshot and deploy. A
post-condition block re-queries the reserved set and fails loud if anything
slipped through.
Down migration reverts the two targeted renames deterministically (they're
keyed by workspace_id, so rollback is safe).
Owner outreach (email zzlye@ + 王争@ about the URL change) is tracked as a
follow-up outside this PR.
* feat(notifications): only bubble status_changed from sub-issue to parent subscribers (MUL-1189)
Subscribing to a parent issue used to surface every event from every
sub-issue in the inbox — comments, priority/due-date tweaks, assignee
shuffles, the lot — which drowned out the signal that actually matters
to a parent watcher: "did the sub-task move forward?".
notifySubscribers now consults a small allowlist (parentBubbleNotifTypes)
before walking up to the parent's subscriber list. Only status_changed
bubbles today; sub-issue subscribers themselves still get every event.
Direct notifications (issue_assigned, mentioned, task_failed targeted at
specific recipients) are unaffected — they go through notifyDirect, not
the parent-bubble path.
Tests cover the three behaviors that matter:
- status_changed on a sub-issue reaches the parent's subscriber, with
the inbox item still pointing at the sub-issue (so the user lands on
the actual change).
- new_comment on a sub-issue does NOT bubble.
- priority_changed on a sub-issue does NOT bubble.
* fix(test): pick next per-workspace issue number in test helpers
Both createTestIssue and createTestSubIssue inserted with the default
number=0, which collides with the uq_issue_workspace_number unique
constraint as soon as a single test creates two issues in the same
workspace (e.g. parent + sub-issue). The first failure also leaked the
parent row because t.Cleanup hadn't been registered yet, breaking every
subsequent test in the package.
Both helpers now compute number as MAX(number)+1 for the workspace, and
the parent-bubble tests register cleanup right after each insert so a
mid-test failure can't leave orphans.
Follow-ups on the onboarding flow shipped in #1411.
Pin state synchronization:
- ImportStarterContent now publishes pin:created after commit so the
sidebar refreshes without a hard reload (previously the pins landed
in the DB but no event was fired).
- ReorderPins publishes pin:reordered, keeping order in sync across
web + desktop sessions.
- StarterContentPrompt.onImport invalidates queries locally, mirroring
the useCreatePin / useDeletePin / useReorderPins onSettled pattern,
so the originating session's refresh doesn't depend on the WS
round-trip (WS is the signal for OTHER sessions).
- ImportStarterContent rejects malformed workspace_id up front with
400 instead of falling through to a misleading 403.
Welcome step layout:
- Switch the two-column hero from CSS Grid to a flex row. Both
columns share the container's full height via items-stretch +
justify-center, so the bg-muted/40 backdrop fills edge-to-edge on
tall viewports and left/right content stays vertically centred.
Desktop runtime bootstrap state:
- New DesktopRuntimesPage wrapper subscribes to window.daemonAPI and
forwards a `bootstrapping` prop to RuntimeList. While the bundled
daemon is booting, the empty state renders "Starting local
runtime…" instead of the misleading "Run multica daemon start"
hint. Web leaves the prop undefined — behaviour unchanged.
Small polish:
- CLI install dialog caps at 85vh with an internal scroll so the
Connect button stays reachable when multiple runtimes are
registered.
- Drop the env-aware CLI setup command; onboarding always targets
cloud, so `multica setup` is enough — no need to thread apiUrl /
appUrl through the dialog.
Developer tooling:
- pnpm dev:desktop:staging — parallel dev command that loads
.env.staging (copilothub backend) via `electron-vite --mode
staging`, so switching between local and staging no longer
requires hand-editing env files.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(transcript): add multi-select tool filter to agent execution dialog
Adds a Filter dropdown to AgentTranscriptDialog that lets users
multi-select tool types (e.g. tool:Bash, tool:Edit) to narrow down
the event list, timeline bar, and copy output. Non-tool items (text,
thinking, error) are also filterable. Clear filters is placed at the
bottom of the dropdown with a separator.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): address review comments on tool filter
- Replace index-based selection with seq-based (selectedSeq) to fix
highlight/scroll jumping when toggling filters
- Use full tool count for the "X tool calls" chip (task-level stat)
instead of filtered count
- Title-case filter labels: Thinking, Error (was lowercase)
- "Copy all" → "Copy filtered" when filter is active
- Replace raw <button> with DropdownMenuItem for Clear filters
so it participates in keyboard navigation
- Drop redundant idx from row key (seq is already unique/stable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): use proportional width in timeline bar
After removing segment grouping, the timeline bar lost proportional
widths. Restore proportional width per item (1/items.length * 100%)
so each event's width reflects its share of the timeline.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): show individual event dividers in timeline bar
When filtering by a single type (e.g. Agent), all events share the
same color and blend into one solid bar. Split each event into its own
clickable button so users can see and click individual events, while
keeping proportional widths based on item count.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): simplify timeline bar to segment-level buttons
Remove per-item nested buttons in timeline segments; each segment is now
a single clickable area. Reduces DOM nodes and aligns with the original
design where segments are coarse color blocks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(transcript): reuse getEventLabel for filter option display
Replace the manual displayMap with getEventLabel() so filter option
labels stay in sync with row labels automatically.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): address round-2 review comments
- Remove dead `onClick` prop from TranscriptEventRow (caused
TS6133 under noUnusedParameters; row never wired a click handler)
- Align `itemFilterKey` guard with `filterOptions` derivation
(tool_use/tool_result type check)
- Fix TimelineBar `isSelected` from seq range to actual membership
via `.some()` — avoids false highlight when a filtered-out seq
falls within a segment's range
Note: `DropdownMenuItem` uses `onClick` not `onSelect` because this
codebase uses Base UI, not Radix. Base UI's Item.Props has no
`onSelect`; the inbox/members-tab code uses `onClick` as the pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Ark <lifangzhou@shizhuang-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(skills): unify Add Skill UX + surface every local skill with real file count
Iterating on the local-skill import flow that just landed. Three fixes
shipped together because they all surfaced while testing the same code
path on the Skills page.
UX — fold runtime import into the existing "+ Add Skill" dialog
- Drop the standalone HardDrive icon button + the empty-state
"Import From Runtime" buttons. Adding a skill is now a single entry
point: the "+" header button (or empty-state button) opens one dialog
with three tabs: Create / Import URL / From Runtime.
- Extract the runtime-import body into RuntimeLocalSkillImportPanel so
it can mount inline as a tab. The standalone Dialog wrapper stays for
the per-runtime "Import this skill" flow on the agent skills tab,
which preselects runtime + skill and benefits from its own modal.
- Cap the dialog at max-h-[85vh] with a scrollable tabs body so the
From-Runtime tab (runtime selector + skill list + name/description
form) no longer overflows the screen on shorter displays.
- Filter the runtime selector to runtimes the caller owns. Other users'
runtimes were listed but the import endpoint rejects them anyway,
matching the Runtimes page's "Mine" default.
- The selected-runtime label in the trigger now shows the runtime name
(`Claude (MacBook-Air.local) (claude)`) instead of the raw UUID — the
shadcn SelectValue needs explicit children when items don't render
the bare value as their label.
- Drop the placeholder Sparkles icon to the left of the skill name /
description inputs in the detail header — it was decorative noise.
Daemon — surface every installed local skill and report the right count
- listRuntimeLocalSkills used filepath.WalkDir, which silently dropped
every symlinked skill via the os.ModeSymlink early return. Skill
installers like lark-cli ship every skill at ~/.agents/skills/<name>
and symlink each one into ~/.claude/skills/, so users with dozens of
skills only saw the few they had cloned in place. Switch to ReadDir
+ os.Stat (which follows symlinks) on the runtime root.
- collectLocalSkillFiles also failed for symlinked skill dirs because
filepath.WalkDir does not descend into a symlinked root, so every
such skill reported 0 files. Resolve the skill dir via EvalSymlinks
before walking.
- Bundle file count purposely excludes SKILL.md (it travels in the
bundle's `Content` field to avoid duplication on import). The summary
now adds 1 back so the user-facing count matches the real file total
— every skill has SKILL.md, we just required it to be parseable.
Tests
- New TestListRuntimeLocalSkills_FollowsSymlinkedSkillDirs seeds a
shared installer dir, symlinks one skill into the runtime root, and
asserts both regular and symlinked skills come back with the right
source path (~/.claude/...) and metadata.
- TestListRuntimeLocalSkills_Claude updated to expect file_count = 2
(one supporting file + SKILL.md) and a comment explains the +1 split.
* test(skills): drive new Add Skill dialog flow in skills-page test
Old test asserted the standalone "Import From Runtime" button. The PR
folded that into the unified "+ Add skill" dialog as the third tab, so
the test now opens the dialog, switches to the "From Runtime" tab, and
asserts the same end state.
Also stub useAuthStore so the runtime panel's "Mine"-only filter sees
the seeded runtime owner (user-1).
* fix(daemon): list nested skills, not just depth-1 entries
Per #1480 review (MUL-1246): switching listRuntimeLocalSkills from
filepath.WalkDir to flat ReadDir lost coverage for nested skill
layouts. opencode stores skills as e.g. `release/reporter/SKILL.md`,
and loadRuntimeLocalSkillBundle accepts that slash-delimited key, so
the import dialog could no longer surface skills the load endpoint
was perfectly happy to fetch.
Replace the flat ReadDir with a recursive enumerator that:
- Follows symlinks at every level (so installer-style symlinked skill
trees still work — that was the original reason for moving off
WalkDir).
- Short-circuits at every SKILL.md: a directory that qualifies as a
skill is registered, and its children are NOT scanned for further
skills. Stale nested SKILL.md files inside a parent skill's bundle
stay part of that bundle.
- Caps recursion at maxLocalSkillDirDepth=4 (covers opencode's depth=2
with headroom) and tracks visited resolved paths so a cyclic symlink
can't loop forever.
New regression test seeds both a top-level skill (with a decoy
SKILL.md inside its templates dir) and a depth-2 nested skill, and
asserts the walker registers exactly two keys — "top" and
"release/reporter" — with the inner templates SKILL.md correctly
ignored.
Follow-up to #1434. The merge-in of db-reset from main happened during
#1434's conflict resolution and didn't get a `##` description, so it
doesn't appear in `make help`. Add the one-line description so the
target surfaces under the Database grouping alongside the other `db-*`
commands.
* feat: add awk style make help
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
* Address review nits for make help
- Add ##@ Help section so help / makehelp targets are no longer orphaned
between the intro blurb and ##@ Self-hosting.
- Explicitly set .DEFAULT_GOAL := help and document that bare `make` now
prints help instead of launching selfhost. This is a safer default for
onboarding, but it is a behavior change from the previous first-target
default (selfhost).
---------
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
* feat: identify clients via X-Client-Platform/Version/OS
Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.
- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}
Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).
Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.
CORS allowlist extended for the new headers.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: address client-identity PR nits
- Memoize the CoreProvider identity object on Web and Desktop, and key
WSProvider's effect on identity primitives instead of the object
reference, so unrelated parent re-renders no longer tear down and
reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
and asserts the "websocket connected" log entry surfaces them as
structured attributes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128)
When the daemon process crashed mid-task the issue was stuck at
in_progress for up to 2.5h: the in-flight task timeout was the only
mechanism that ever moved the row, and the runtime heartbeat sweeper
only fires after the runtime stays offline for 45s — a quick restart
beats both windows.
This change implements the A+B plan from the issue thread:
A. lifecycle hygiene
- migration 055 adds attempt / max_attempts / parent_task_id /
failure_reason / last_heartbeat_at to agent_task_queue
- new daemon-auth endpoint POST /runtimes/{id}/recover-orphans:
daemon calls it on every register so the server fails any
dispatched/running tasks the previous process left behind
- new daemon-auth endpoint POST /tasks/{id}/session: persists the
agent's session_id + work_dir mid-flight so a crash doesn't
lose the resume pointer (claude+codex emit MessageStatus with
SessionID; daemon forwards on the first one it sees)
- FailAgentTask / FailStaleTasks / FailTasksForOfflineRuntimes
now set failure_reason ('agent_error' / 'timeout' /
'runtime_offline')
B. auto-retry with resume context
- TaskService.MaybeRetryFailedTask spawns a fresh queued attempt
carrying parent's session_id/work_dir when the failure reason
is infrastructure-shaped (timeout, runtime_offline,
runtime_recovery) and attempt < max_attempts; skips autopilot
- wired into the runtime sweeper paths and TaskService.FailTask
so the user transparently sees a new in_progress run instead of
a stuck row
- new user-auth POST /api/issues/{id}/rerun + multica issue rerun
CLI for the manual escape hatch
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(server): address PR review for orphan-task recovery (MUL-1128)
Three review-must-fix items on top of the A+B implementation:
1. recover-orphans now funnels through TaskService.HandleFailedTasks,
the same shared post-failure pipeline used by the runtime sweeper.
This guarantees task:failed events are emitted, agent status is
reconciled, and issues stuck in_progress with no remaining active
task are reset to todo even when no auto-retry is created
(max_attempts exhausted, autopilot, non-retryable reason).
2. RerunIssue now uses CancelAgentTasksByIssueAndAgent, scoped to the
issue's current assignee. The previous implementation called
CancelAgentTasksByIssue, which would collateral-cancel parallel
@-mention agents on the same issue.
3. GetLastTaskSession now considers both completed and failed tasks
(mirroring GetLastChatTaskSession), ordering by the most recent
timestamp. With UpdateAgentTaskSession pinning session_id/work_dir
mid-flight, an auto-retry or manual rerun of a daemon-crash failure
now actually resumes the prior conversation context instead of
starting fresh — matching the stated B-branch behaviour.
go build / go vet pass; the existing service and agent test suites pass.
runtime_sweeper / handler integration tests require a local DB with the
055 migration (and the pre-existing 050 first_executed_at column).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agents): tasks tab crashes when agent has autopilot run_only tasks
Autopilot `run_only` tasks have no linked issue; the server serializes
that as `issue_id: ""` (not null) via `uuidToString` on an invalid
pgtype.UUID. The agent detail Tasks tab assumed every task had a real
issue id and fed `""` into `api.getIssue(id)` → `/api/issues/` and into
`paths.issueDetail("")`, crashing the whole tab as soon as one such
task existed on the agent.
Handle the empty-issue case explicitly:
- Filter empty ids out of `issueIds` so `useQueries` doesn't fire
`/api/issues/` for a nonexistent issue.
- Render run_only rows as non-link `<div>`s labeled "Autopilot run"
instead of clickable issue links.
No server-side change — the `""` serialization stays as-is; callers
just need to treat it as "no issue".
* fix(agents): neutral label for issue-less tasks + regression test
Review feedback on #1453: not every task without a linked issue is an
autopilot run. `ListAgentTasks` returns the agent's full queue; both
autopilot `run_only` runs and chat-spawned tasks persist with NULL
issue_id, which arrives here as "". Labeling both as "Autopilot run"
mislabels chat tasks.
Swap the label to the neutral "Task without linked issue" and update
surrounding comments. A follow-up will surface the real task source
once the server populates it on AgentTaskResponse.
Adds a regression test that empty issue_id rows render the neutral
label, aren't wrapped in an anchor, and don't trigger a detail fetch.
* docs(onboarding): add redesign proposal
Captures motivation (two activation funnels), research-backed principles,
final 5-step flow (welcome+questionnaire → workspace → runtime → agent →
first-issue), Q1/Q2/Q3 personalization matrix, backend user_onboarding
schema, API design, resume policy, and development ordering
(frontend-first with Zustand stub, backend-last, server swap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): scaffold redesigned flow and state foundation
Work-in-progress scaffold toward the redesign documented in
docs/onboarding-redesign-proposal.md. This commit is intentionally
broad — subsequent commits will replace step content and wire real
personalization. Not ready for merge.
Included:
- packages/views/onboarding/: flow orchestrator + 5 step components
(welcome/workspace/runtime/agent/complete) and the CLI install card.
Step content is the placeholder version; Step 1 (questionnaire) and
Step 5 (first issue) are the next changes.
- packages/core/onboarding/: dev-phase Zustand store + types. Not
persisted — every page refresh starts at Step 1 so each step can be
iterated in isolation. Will swap to TanStack Query + PATCH
/api/me/onboarding once the backend user_onboarding table ships
(keeps the exported hook surface stable).
- packages/core/paths/resolve.ts + .test.ts: centralized
resolvePostAuthDestination. Priority is flipped so !hasOnboarded
wins over workspace presence — during frontend development every
login re-enters /onboarding. useHasOnboarded() reads from the store
so the real onboarded_at semantic lands automatically once the
backend ships.
- Post-auth wiring: callback page, login page, landing redirect,
dashboard guard, realtime workspace-loss handler, settings leave/
delete, invite acceptance, and desktop app shell all delegate to
the shared resolver instead of inline logic.
- Desktop overlay: 'onboarding' added as a WindowOverlay type
alongside new-workspace / invite, with a navigation-adapter
interception so push('/onboarding') opens the overlay.
- packages/core/package.json / packages/views/package.json: add new
subpath exports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(onboarding): revise questionnaire to role-driven 3-question form
Aligns the proposal with the corrected product positioning: Multica is an
AI agent orchestration platform for diverse users (developers, product
leads, writers, founders), not a coding-focused tool.
Key changes:
- Drop Q1 "which agents do you already use?" — daemon auto-detects
installed CLIs on PATH; asking is both redundant and less accurate
- Add Q2 "what best describes you?" (role) to drive Step 4 template
default and Onboarding Project sub-issue filtering
- Keep Q1 team_size, refine Q3 use_case (recover writing/research
option); all three now have "Other" with an 80-char text field
- Q3 use_case_other is embedded into Step 5 first issue prompt so
Other users get maximally personalized aha moments, not generic ones
- Agent templates: 3 → 4 (Coding / Planning / Writing / Assistant),
matrix driven by Q2 × Q3
- Onboarding Project sub-issues: surface Autopilot and Workspace
Context (product differentiators), replace "orchestration" wording
- Schema JSONB example and §5/§9 execution plan updated to match
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): align questionnaire shape with role-driven redesign
Prepares the core state layer for the Step 1 questionnaire rewrite.
Type-only and initial-value changes; no behavior changes (nothing was
reading the removed `existing_agents` field, since no questionnaire UI
exists yet).
- Add `Role` type (Q2: developer / product_lead / writer / founder / other)
- Add `*_other` sibling fields for team_size / role / use_case so each
question's "Other" selection can carry 80-char free text
- Drop `existing_agents` — daemon auto-detects CLIs on PATH at Step 3,
so the signal no longer belongs in the questionnaire
- Extend `TeamSize` / `UseCase` unions with `"other"` member
- Refine `UseCase` option label (`writing` → `writing_research`) so
it matches the widened Q3 scope in the proposal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): implement Step 1 questionnaire
Replaces the placeholder welcome step with the 3-question questionnaire
defined in docs/onboarding-redesign-proposal.md §3.4. Answers land in
the core onboarding store for later use by Steps 4 and 5.
Added:
- packages/views/onboarding/components/option-card.tsx — OptionCard +
OtherOptionCard. Radio-group ARIA semantics; Enter/Space select;
Other variant reveals an 80-char input that auto-focuses on mount.
- packages/views/onboarding/steps/step-questionnaire.tsx — merges
welcome + Q1/Q2/Q3 into one screen. Local draft state for
responsiveness; writes to the core store only on submit. Skip/
Continue CTA swap driven by "any answered?"; the only disabled
case is "picked Other but the text box is blank".
- Test coverage for the CTA rules, Other-clear-on-switch behavior,
initial-answers pre-fill, and full payload shape.
Modified:
- packages/views/onboarding/onboarding-flow.tsx — render
questionnaire as the first step; persist answers and advance the
stored current_step on submit. Other steps still run off local
useState for now; full store-driven orchestration follows when
Step 5 lands.
Removed:
- packages/views/onboarding/steps/step-welcome.tsx — superseded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): split welcome + questionnaire, unblock scroll, drop Q1 evaluating
Three fixes prompted by first real browser testing of the Step 1
questionnaire. All three are about making the flow usable before
pursuing visual polish.
1. Split Welcome and Questionnaire into two screens
The previous merge-welcome-into-questionnaire decision dropped
Multica's product introduction entirely. For a product with no
established mental model (AI agents as first-class teammates in a
task platform), first-time users need 5 seconds of framing before
the questionnaire makes sense. StepWelcome carries that framing;
it's UI-only (not a persisted step), shown only on first entry
(pristine store), and skipped automatically on resume.
2. Remove `my-auto` vertical centering from both platform shells
Long questionnaire content pushed the centered block's top above
the scroll origin, making Continue/Skip unreachable. Top-alignment
+ natural body/overlay scroll is the boring-but-correct baseline
for content of variable height.
3. Drop Q1 "Just exploring for now" option
Q1 asks about team structure, not attitude. "Evaluating" was a
category error. Low-commitment users already have a zero-friction
path (skip all questions). Removing the option simplifies the
question and the downstream mapping table.
Types, store initial value, proposal doc (§3.1 flow diagram, §3.4
options, §3.5 sub-issue sorting, §3.6 conditionals, §4.1 JSONB
schema, §5.2 file list, §7 decisions row, §9.2 execution order)
all synced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): center short steps, scroll long ones — correctly this time
Previous attempt removed `my-auto` thinking it was responsible for
blocked scrolling. That diagnosis was wrong: the real blocker was
the root layout's \`body { overflow: hidden }\` (an app-shell
convention so sidebar/topbar stay put while the inner content
region scrolls). Removing `my-auto` broke vertical centering of
short steps (Welcome) without fixing the scroll issue.
Correct fix:
- Web: page now owns its own scroll container — `h-full
overflow-y-auto` on the outermost div decouples from the body's
overflow-hidden.
- Desktop: the overlay's existing `flex-1 overflow-auto` container
already provided scroll; just restoring `my-auto` was sufficient.
- Both platforms: inner `flex min-h-full flex-col items-center` +
content `my-auto` gives the "short centers, long top-aligns and
overflows down" behavior. Per the flex spec, auto margins are
ignored on overflowing boxes (they overflow in the end direction),
so Continue/Skip remain reachable via scroll even on long steps
like the questionnaire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): add progress indicator + stable header anchor
Adds a consistent visual anchor at the top of every step (except
Welcome), so transitioning between steps of different content heights
no longer shifts the vertical baseline.
- packages/core/onboarding/step-order.ts — single source of truth for
step order; indicator math reads from here so adding/reordering a
step touches only one line
- packages/views/onboarding/components/step-header.tsx — dot row +
"Step N of M" counter; three dot states (done/current/pending);
accessible progressbar semantics
- onboarding-flow.tsx — non-welcome steps now render under a shared
`<div flex flex-col gap-8>` wrapper with StepHeader on top. Maps
the local `complete` render step to the store's `first_issue`
until Step 5 lands (one-line function, self-deleting).
- step-welcome.tsx — keeps its own min-h-[60vh] + justify-center so
the short intro still feels centered once the shell drops my-auto
- apps/web + apps/desktop shells — removed `my-auto`. Every
non-welcome step now anchors to the same top position, so only the
content below the header changes during transitions. Welcome's own
internal centering handles its "short content, no header" case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): add web Step 3 platform fork (Desktop / CLI / waitlist)
Web users now see a three-way choice at the runtime step instead of
being dropped directly into CLI install instructions:
- Primary CTA: Download Multica Desktop (bundled runtime)
- Alternate: install the CLI (reveals existing StepRuntimeConnect)
- Alternate: join the cloud waitlist (captures email, completes
onboarding early with cloud_waitlist_email set)
Desktop unchanged — its platform shell doesn't pass cliInstructions,
so OnboardingFlow routes it straight to StepRuntimeConnect for the
bundled-daemon auto-connect path.
Rename step-runtime.tsx → step-runtime-connect.tsx to reflect its
new single responsibility (connect UI only; platform choice lives
in StepPlatformFork).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): capture optional use-case on cloud waitlist
Adds a textarea to the waitlist form asking what the user wants to
use Multica for. Optional (submit still works with email alone) but
surfaces a clear prompt + placeholder example so most users will fill
it in. Stored as cloud_waitlist_description alongside the email.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): make !hasOnboarded a first-class gate on both platforms
Triggering condition was wrong on both sides. Web's dashboard-guard
only checked hasOnboarded when the URL slug failed to resolve; desktop's
App.tsx effect returned early when wsCount > 0 before even looking at
hasOnboarded. Users with existing workspaces never got routed into
onboarding regardless of their flag state.
Also wire store.complete() into the happy-path finish — previously only
the waitlist branch wrote onboarded_at, so every normal completion
left the flag false and (now that triggers work) would loop users back
into onboarding on refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): Step 5 auto-bootstrap — welcome issue + Getting Started project
After agent creation, the flow transitions to a loader screen that
runs the bootstrap in the background:
- Creates a welcome issue with a Q3-driven prompt, assigned to the
new agent (so it starts working immediately)
- Creates a "Getting Started" project with tutorial sub-issues
filtered by Q1/Q2/Q3
- Stores first_issue_id + onboarding_project_id via store.complete()
- Navigates the user straight into the welcome issue detail page,
where they see the agent already responding
Degraded path: if welcome issue fails, shows error with Retry /
Continue anyway. If project or sub-issues fail, logs and proceeds
with just the welcome issue — the aha moment still happens.
No-agent paths (runtime skip, agent skip) short-circuit to onComplete
without bootstrap.
Local flow step union now aligns with the store enum; removed the
mapLocalToStoreStep bridge and deleted the old step-complete.tsx
placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): converge all no-agent paths to a single bootstrap step
Before: skip-runtime, skip-agent, and waitlist each finished onboarding
independently, bypassing Step 5 entirely. Users without an agent landed
in an empty workspace with no tutorial project — the "self-serve" case
had no bootstrap at all.
Now: all three paths converge on the first_issue step with agent=null.
Bootstrap branches on agent presence:
- agent ✓ → welcome issue (assigned to agent) + project + agent-guided
sub-issues ("watch your agent do X"). Lands on the welcome issue.
- agent ✗ → project only + self-serve sub-issues ("try X yourself" —
configure runtime, create agent, write first issue, etc.). Lands on
the workspace issues list with the Getting Started project in the
sidebar.
Both web and desktop shells already handle firstIssueId=undefined →
fall back to /<slug>/issues, so no shell-side change was needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): pin starter project + assign sub-issues to the user
Bootstrap now also:
- Pins the Getting Started project so users see it in the sidebar
immediately (both paths)
- Pins the welcome issue too (path A only) so the first conversation
with the agent stays one click away
- Assigns every sub-issue to the current user (via their workspace
member record). Only the welcome issue stays assigned to the agent —
that's the aha-moment hand-off; everything else is for the user to
work through
Pin calls are fire-and-forget (failure logged but non-blocking).
Member lookup is defensive — if listMembers fails or the user isn't
found, sub-issues gracefully fall back to unassigned rather than
breaking the bootstrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): remove cloud waitlist option
Cloud runtime is not on the immediate roadmap and there's no backend
table to persist emails. Keeping the UI around would silently drop
user submissions — small trust leak. Revisit once cloud product lands
alongside a proper waitlist table + notification pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): persist onboarded_at end-to-end
Phase 1 of bringing onboarding from dev stub to production. A single
persisted column drives every trigger — no separate user_onboarding
table yet (that's a later phase for questionnaire persistence, cloud
waitlist, analytics).
Backend
- Migration 050: ALTER TABLE "user" ADD COLUMN onboarded_at TIMESTAMPTZ
(no backfill — existing users see onboarding next login, Skip
affordance lands later)
- sqlc: MarkUserOnboarded with COALESCE for idempotency
- UserResponse DTO + userToResponse now emit onboarded_at via
existing util.TimestampToPtr helper — single edit covers GetMe,
VerifyCode, GoogleLogin, LoginWithToken
- New handler POST /api/me/onboarding/complete
- Route registered in the authenticated user-scoped group
Frontend
- User type gets onboarded_at: string | null
- api.markOnboardingComplete()
- Auth store adds refreshMe() — lightweight getMe + setUser,
complements existing initialize()
- useHasOnboarded switches source from onboarding-store (dev stub)
to auth-store (user.onboarded_at). Every call site — dashboard
guard, desktop App.tsx, invite page fallback, realtime
workspace-loss handler, settings leave/delete — picks up the
real signal without any direct change
- onboarding-store.complete() now hits the server: POST + refreshMe
before local state update, so the next router effect sees the
non-null timestamp and won't bounce the user back
Triggers + route guards
- StepWorkspace drops the Skip button — every onboarding user
must create their own workspace even if invited into one
- /onboarding page redirects already-onboarded users away (guards
against manual URL access)
- login page + auth callback: onboarding wins over ?next= for
unonboarded users; invite links are revisitable after onboarding
Tests
- apps/web callback tests updated: mocks now return User objects
so onboarded_at is readable; new "onboarded user honors next"
scenario added, "unonboarded ignores next" scenario kept
- test/helpers mockUser gets onboarded_at field
- questionnaire already-existing strict-required tests bundled in
from a prior uncommitted change
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): review findings — dead state, error recovery, cache races
From independent review of the prior onboarded_at commit.
- Remove the dead OnboardingState.onboarded_at field, its INITIAL_STATE
entry, and its write in store.complete(). useHasOnboarded now reads
auth-store exclusively; leaving a parallel field here violates the
"don't duplicate server data in Zustand" rule and risks drifting into
a second source of truth.
- Wrap handleBootstrapDone/handleBootstrapSkip in try/catch with toast
recovery. complete() is idempotent server-side (COALESCE), so a
retry after a failed POST/refreshMe is free — letting the error
bubble into the React error boundary trapped the user with no way
forward.
- RedirectIfAuthenticated: swap `!list` for `isFetched`-gated check,
matching the pattern added on the /onboarding page. Same one-tick
race where a stale cache [] could fire a premature replace before
the fresh list settles.
- (Self-review fixups picked up along the way) /onboarding page now
waits for workspacesFetched before redirecting already-onboarded
users, and login handleSuccess reads useAuthStore.getState() so the
hasOnboarded value is fresh after setUser (the closure captured a
stale pre-login value otherwise).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): shrink store surface + firm up flow invariants
Post-review cleanup. End-to-end flow is already complete (user.onboarded_at
is the single source of truth); these are quality-of-life fixes on top.
Store surface
- Drop six dead fields from OnboardingState (workspace_id, runtime_id,
agent_id, first_issue_id, onboarding_project_id, platform_preference)
and the PlatformPreference type. None had readers — they were stub
placeholders for a future user_onboarding table that isn't coming
this phase. CLAUDE.md "don't design for hypothetical future".
- store.complete() signature simplifies to () — no more patch arg,
since the only patch fields were the ones just deleted.
Welcome as a first-class step
- Add "welcome" to OnboardingStep enum and make it INITIAL_STATE's
current_step. Removes the pristine-heuristic "did user see welcome?"
check, which could misfire on remount.
- pickInitialStep() collapses to `state.current_step ?? "welcome"`.
- ONBOARDING_STEP_ORDER stays unchanged (welcome isn't a progress point).
advance() chain
- Every transition handler now persists the new current_step to the
store (handleWorkspaceCreated, handleRuntimeNext, handleAgentCreated,
handleAgentSkip). Refresh lands on the right step instead of
jumping back to Step 2.
Invariants
- OnboardingFlow throws on null user instead of spreading defensive
`?? ""` and `if (userId)` that silently degraded to unassigned
sub-issues. Shell guards already ensure user is present.
- Desktop WindowOverlay's onComplete gains a paths.root() fallback
when workspace is undefined — matches web's symmetry.
docs/product-overview.md: committed from untracked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): persist questionnaire + current_step; resume + Back
End-to-end questionnaire persistence + resume capability. User answers
are now server-side (analytics-ready); refreshing or revisiting lands
on the furthest reached step with previous answers pre-filled; a Back
button on each step lets users edit earlier answers without losing
progress.
Backend
- Migration 051: ALTER TABLE "user" ADD onboarding_current_step TEXT,
onboarding_questionnaire JSONB NOT NULL DEFAULT '{}'::jsonb
- sqlc: new PatchUserOnboarding with sqlc.narg for optional fields
(COALESCE preserves unspecified columns). MarkUserOnboarded also
clears current_step — once complete, the step pointer has no meaning
- Handler PATCH /api/me/onboarding accepting partial {current_step,
questionnaire}. Questionnaire passthrough via json.RawMessage, no
server-side validation of inner shape (keeps schema evolution free)
- UserResponse DTO emits both new fields; userToResponse coalesces
JSONB to '{}' defensively
Frontend
- User type gains onboarding_current_step + onboarding_questionnaire
- api.patchOnboarding(payload)
- Delete Zustand onboarding store — replaced with plain async
advanceOnboarding() / completeOnboarding() that call the API and
sync auth store. Source of truth is the user object, no client-side
shadow state that could drift
- pickInitialStep reads user.onboarding_current_step; StepQuestionnaire
initial pre-fills from user.onboarding_questionnaire
- Monotonic furthestStepRef: Back edits don't regress server-side
progress, and re-submit returns the user to where they were
- Back buttons on Steps 2/3/4. Back is local-only — just changes the
rendered step, no PATCH
- Loading indicator on Welcome + Questionnaire submit buttons while
PATCH is in flight
- CreateWorkspaceForm.onSuccess accepts Promise<void> so the flow can
await advance() from its onCreated handler
Test mocks (helpers + callback test) updated with new User fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): resume to Step 3+ needs workspace/runtime fallback
Self-review caught: resume lands the user on their saved step, but
React state (workspace, runtime, agent) is empty on fresh mount. The
render conditions gate on those — without fallbacks the page stays
blank.
- workspaceListOptions() query fills runtimeWorkspace from cache when
stepping past Step 2. Only one workspace exists during onboarding
(StepWorkspace always creates one), so [0] is unambiguous.
- StepWorkspace accepts an `existing` prop. On resume / Back to Step 2
with a pre-existing workspace, render a "Continue with <name>"
confirmation instead of the create form, which would otherwise hit a
slug conflict the moment the user clicks Create.
- runtimeListOptions(wsId, "me") similarly seeds Step 4's runtime —
prefer first online, fall back to first.
Step 5 resume path unchanged: if `agent` React state is null on
re-entry, bootstrap runs the self-serve branch. Not ideal (user may
have actually created an agent), but bootstrap's list-check approach
(future work) will handle orphan detection symmetrically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): delete all skip/resume jump logic
Flow always starts from Welcome. Questionnaire answers still pre-fill
from user.onboarding_questionnaire. current_step is still PATCHed for
future analytics but no UI code reads it for navigation.
Removed from onboarding-flow.tsx:
- pickInitialStep + isOnboardingStep (no server-driven entry point)
- furthestStepRef + resolveNextStep (no edit-vs-first-pass branching)
- runtimes useQuery + stepRuntime fallback (user walks through Step 3
linearly, so runtime React state is always populated by Step 4)
- workspace resume fallback in runtimeWorkspace (same reasoning)
Kept:
- advanceOnboarding({ current_step, questionnaire? }) — server
persistence, analytics-ready
- StepQuestionnaire's initial prop from stored answers
- workspaces useQuery (gated to step === "workspace" only) for
existing-workspace detection on Step 2 to prevent slug conflicts
when a previous onboarding was abandoned
- Back buttons + handleBack (local-only navigation)
- Error recovery on completeOnboarding via try/catch + toast
Every transition handler is now a straight advance + setStep line.
Users who close mid-flow and return walk the full flow from Welcome
again — slight extra clicks, but each step shows meaningful confirm
UI (existing workspace, connected runtimes, etc.) so it doesn't feel
like repeated work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): grandfather existing users in the onboarded_at migration
Folded the backfill into 050 itself (branch has not shipped to prod,
so editing the migration in place is clean). Without this, once this
branch deploys, every pre-existing user would be walled off into
onboarding on their next login — a real production incident.
Uses created_at rather than NOW() so analytics like "signup →
onboarded interval" read correctly for pre-launch users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): Step 1 questionnaire — two-column editorial layout
Matches the onboarding(3) design spec: full-bleed two-column on lg+
(main + "Why we ask" side rail), collapses to single column below.
- StepQuestionnaire rewritten with:
- Mono 01/02/03 markers per question
- Serif question headings (22px)
- Editorial serif title ("Three answers. We'll handle the rest.")
- Right-side rationale panel explaining what each answer unlocks
- Sticky footer with hint + Continue CTA
- Embeds StepHeader on the left column so it escapes the flow's
narrow max-w-xl wrapper, same pattern Welcome uses
- OptionCard redesigned: radio-dot marker + inset ring on select,
matches design's .opt pattern
- OtherOptionCard: text input appears below the row (not inside the
card) with bottom-border-only styling, aligned under the label
- onboarding-flow: questionnaire now early-returns full-bleed,
joining Welcome as a hero-layout step
Placeholder copy updated to match design examples; tests adjusted.
* fix(onboarding): questionnaire uses 3-region app-shell layout
Previous version had everything in a single scroll container with a
sticky footer. As the user scrolled into the questions, the Back
button and StepHeader progress indicator scrolled out of view, and
sticky-bottom had edge cases with width-constrained flex nesting.
Classic 3-region shell now:
- Fixed header row: Back button (left) + StepHeader progress
indicator — persistently visible regardless of scroll position
- Scrollable middle: eyebrow / serif title / lede / 3 question
blocks. Uses `flex-1 overflow-y-auto min-h-0` — the min-h-0 is
the critical bit that lets a flex-1 child shrink below content
height inside a flex column
- Fixed footer row: hint (hidden < sm) + Continue CTA — always
reachable, never scrolled off
Right "Why we ask" panel is now an independent grid column with its
own overflow, so the two columns scroll independently instead of the
whole page having one shared scrollbar.
Side panel width reduced 520 → 480 to give the question column more
room on 1280/1366 screens where 1fr_520 left ~760px for content;
1fr_480 gives ~800-900px which comfortably fits the 620px max-w
content column plus breathing room.
* fix(onboarding): questionnaire needs DragStrip like every full-window view
Traffic lights were overlapping the StepHeader progress dots because
Step 1 escaped onboarding-flow's non-welcome wrapper (which renders
<DragStrip />) without rendering its own. The codebase convention per
packages/views/platform/drag-strip.tsx is: every full-window view
places a DragStrip as the first flex child of each visible column.
Adds DragStrip at the top of both the left (shell) and right
("Why we ask") columns, matching step-welcome.tsx which already did
this. Traffic lights now land in the 48px transparent strip with no
content collision; dragging from any top edge moves the window on
Electron; border-l between columns runs edge-to-edge.
Also made the right column's scroll container use
`min-h-0 flex-1 overflow-y-auto` so its internal scroll activates
independently of the left column.
(Separately investigated: useImmersiveMode is no longer called
anywhere in production code — the codebase has fully committed to
the DragStrip pattern. No action needed on the hook itself.)
* style(onboarding): drop top/bottom borders on questionnaire shell
* style(onboarding): use chat-style scroll fade mask instead of border
The questionnaire's scroll area now fades softly at top/bottom edges
via `useScrollFade` (already used by chat-message-list.tsx) — the
same mask-image linear-gradient pattern that fades content under the
header/footer based on scroll position:
- At top: only bottom fades (hint: more content below)
- At bottom: only top fades (hint: content above)
- In middle: both fade
- Fits entirely: no mask
This replaces the removed border-b/border-t on the header/footer with
a softer, more editorial visual separation while giving an actual
scroll-position affordance the border can't.
* feat(onboarding): show "n of 3 answered" progress next to Continue
Gives the user a glance-able progress signal as they fill the
questionnaire. Static text, no extra UI primitives, no dynamic
state variants — just `{n} of 3 answered` updating in place,
left of the Continue button.
Replaces the static "Your answers shape the next screens..." hint,
which was always there regardless of progress and added noise.
Same canContinue gate as before (all 3 answered), just derived
from the new per-question check so we don't compute validity twice.
* style(onboarding): drop redundant lede under questionnaire title
The title already conveys the "we'll handle the rest for you"
promise — the lede just rephrased it at length. Removed; bumped the
question-list top margin (mt-8 → mt-10) to keep breathing room.
* feat(onboarding): land redesigned flow + post-landing starter content opt-in
This commit bundles the final onboarding-redesign work that sat in the
working tree with today's architectural reshape of how starter content
is handled. Splitting across sqlc-regenerated files would be fragile,
so it ships as one logical unit — "onboarding is ready for production".
Flow redesign (Steps 1–5)
-------------------------
- Editorial two-column shells on Steps 1/2/3/4 (DragStrip + hero column
+ aside panel) — Welcome, Questionnaire, Workspace, Runtime, Agent
- Web-only Step 3 fork (Download desktop / Install CLI / Cloud waitlist)
lives alongside desktop's direct runtime picker; cloud path is
interest-capture only, doesn't advance the flow
- DragStrip extracted to packages/views/platform as a cross-platform
component — 48px transparent drag row, no-op on web
- recommend-template.ts + test: Q1–Q3 → AgentTemplate mapping
Cloud waitlist
--------------
- Migration 052: cloud_waitlist_email VARCHAR(254) + cloud_waitlist_reason TEXT
- Handler: net/mail.ParseAddress + length bounds + reason trim
- Frontend: CloudWaitlistExpand component + api.joinCloudWaitlist
Drop persisted onboarding_current_step
--------------------------------------
- The interim implementation persisted the user's furthest-reached step;
the final design starts every entry at Welcome, so the column is dead
- Migration 051 no longer adds it; migration 053 drops it IF EXISTS on
any environment that ran the interim 051 — schema converges cleanly
- UserResponse / User type / patchOnboarding signature all drop the field
Post-landing starter content (new architecture)
-----------------------------------------------
Why: the old design ran bootstrap inside Step 5 (welcome issue + Getting
Started project + sub-issues, all in one try block). That had three
defects — (1) non-idempotent: Retry after partial failure created
duplicates; (2) sub-issue assignee raced listMembers → showed as
"Unknown"; (3) skipped users (paths A/C/D) never got any starter
content. All three are structural, not patchable.
New design: onboarding ends at completeOnboarding() as before (gate is
unchanged for useDashboardGuard). The 4 completion paths (Welcome skip
/ full flow / Runtime skip / Error recover) all just call
completeOnboarding() and navigate to workspace. On landing, a
StarterContentPrompt dialog renders exactly once per user
(starter_content_state == null) with Import / No thanks. The dialog is
mandatory — no X, no ESC, no outside-click — so state always ends in a
terminal value.
- Migration 054: starter_content_state TEXT, backfill 'skipped_legacy'
for pre-feature onboarded users so they're never prompted
- Server POST /api/me/starter-content/import: transactional claim
(NULL → 'imported') + bulk create project + optional welcome issue +
sub-issues + pins, all in one tx. 409 Conflict on second call
- Server POST /api/me/starter-content/dismiss: transactional NULL → 'dismissed'
- Import decides agent-guided vs self-serve by inspecting the workspace's
agent list at dialog time — fixes path A (Welcome skip + existing
agent) which was previously excluded from starter content
- starter-content-templates.ts replaces bootstrap.ts: pure template
builders, no API calls. Copy is reviewed as UI; server owns atomicity
- StepFirstIssue is now just completeOnboarding() + navigate; error
surface collapses to a Retry button (no more "Continue anyway" branch)
- OnboardingCelebration + just-completed.ts removed (replaced by
StarterContentPrompt which reads server state, not sessionStorage)
Handler hardening
-----------------
- PatchOnboarding: MaxBytesReader 16KB so the JSONB column can't be
weaponized as bulk storage (every /api/me read returns the payload)
- JoinCloudWaitlist: net/mail format check + explicit 254-char cap
- ImportStarterContent: MaxBytesReader 64KB (templates are markdown-heavy
but still bounded); welcome issue's agent_id verified in-workspace
Tests
-----
- Existing onboarding_test.go (waitlist) passes
- step-platform-fork.test.tsx + recommend-template.test.ts (new)
- apps/web test helpers updated for User.starter_content_state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): resolve Unknown assignee/creator + tighten prompt copy
Two surface issues on the post-landing starter content dialog:
1. Unknown assignee & Created by
-------------------------------
ImportStarterContent stored `member.id` (the membership row UUID) in
`assignee_id` and `creator_id` for sub-issues. That mismatched the rest
of the codebase — AssigneePicker and resolveActor in issue.go both
store `user_id` for type="member", and `useActorName.getMemberName`
looks members up by `user_id`. The mismatch meant the lookup never
matched any member and fell through to the "Unknown" fallback.
Fix: use `parseUUID(userID)` for both fields. The existing membership
check stays for the 403 signal; we just no longer need the returned
`member.ID`.
2. Dialog copy too long, button labels unclear
----------------------------------------------
Old copy was 3–4 paragraphs of instruction; users need to read less
than that to make a binary choice. Buttons "Import starter tasks" and
"No thanks" also didn't make it clear what "No thanks" actually does —
it starts a blank workspace, so say so.
New:
- Title: "Welcome — add starter tasks?"
- Body: one sentence describing the seeded content
- Left button: "Start blank workspace"
- Right button: "Add starter tasks"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): server decides starter content branch
Problem: the old ImportStarterContent gated the agent-guided vs
self-serve branch on a client-supplied `welcome_issue.agent_id` or
null `welcome_issue`. The client made that decision by reading its
React Query cache of the workspace's agent list — any timing quirk
(cache not populated, stale, race with WS event) could lie to the
server, and there was no way for the server to disagree. Users with
an agent in the DB could still end up on the self-serve branch.
Fix: the server is now authoritative. The client always sends both
template arrays (agent_guided_sub_issues, self_serve_sub_issues) and
a welcome_issue_template (title + description + priority, NO agent_id).
Inside the import transaction the server runs ListAgents on the
workspace — if there's at least one agent, it picks agents[0] (same
ordering the client used: created_at ASC), uses agent_guided_sub_issues,
and creates the welcome issue assigned to that agent. Otherwise it
uses self_serve_sub_issues and skips the welcome issue.
Side effect: the Unknown assignee/creator bug is structurally gone —
no client-supplied id flows into assignee_id/creator_id for type=
"member". The server uses actorID = parseUUID(userID) everywhere,
matching resolveActor in issue.go.
Client surface also simplifies: StarterContentPrompt drops
useQuery(agentListOptions), the hasAgent check, the agentsFetched
button gate, and the branch-specific copy. Dialog description is a
single generic line ("If you already have an agent, we'll also seed
a welcome issue it replies to right away"). buildImportPayload no
longer takes an agentId parameter — one unconditional return shape.
Payload grows ~15 KB (both sub-issue arrays always present); still
well under the 64 KB MaxBytesReader cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(onboarding): clarify runtime prerequisite, revert dialog agent list
Step 3 runtime (desktop step-runtime-connect.tsx) — scanning and empty
subtitles now name the local AI coding tools Multica drives (Claude
Code, Codex, Cursor, and others), so users understand a runtime alone
isn't enough: they also need one of those tools installed on the
machine. Uses "and others" rather than a closed list so we don't lock
the copy to exactly three integrations.
StarterContentPrompt dialog — reverted the short-lived "try Coding,
Planning, Writing agents and more" rewrite. That was a misread of
feedback meant for the Step 3 prerequisite, not the dialog. The
dialog's current single-sentence "how agents, issues, and context
work in Multica" is enough.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add v0.2.11 changelog (2026-04-21)
Combines the v0.2.9 / v0.2.10 / v0.2.11 releases (plus post-v0.2.11
main commits) into a single landing-page entry, covering the PostHog
pipeline, desktop cross-platform packaging, board pagination and the
recent inbox / agent-task / markdown fixes.
* docs: trim v0.2.11 changelog to user-visible highlights
Drop minor fixes and CLI/daemon polish items — keep only the headline
features and the visible user-facing fixes.
* docs: reprioritize v0.2.11 changelog for external readers
Drop internal MUL-/#PR references, swap in the higher-impact fixes
(daemon workspace isolation, multica update + Windows daemon, board
card description, PostHog default off) that a self-hosted user
actually notices.
* docs: drop PostHog items from v0.2.11, promote multica update to feature
Analytics plumbing is not user-perceivable; replace the PostHog feature
and the PostHog default-off fix with multica update (CLI self-update)
as a feature and keep the Windows daemon persistence as a fix.
* docs: add OpenClaw model read fix to v0.2.11 changelog
* fix(inbox): don't archive after deleting an issue
Deleting an issue from the Inbox page was calling the archive API on the
inbox item right after deleteIssue succeeded. Because the inbox_item row
has ON DELETE CASCADE on issue_id, it was already gone by then and the
archive call 404'd with "inbox item not found", surfacing a "Failed to
archive" toast.
Drop the redundant archive call and invalidate the inbox cache through
the issue:deleted WS handler so every tab stays in sync without an extra
round trip.
* fix(inbox): keep stale selection on /inbox instead of the deleted issue
When another tab deletes the selected inbox issue, onInboxIssueDeleted
prunes the cache and `selected` becomes null. The existing fallback then
redirected to the issue detail page — which is also gone, so the user
landed on a "This issue does not exist..." screen instead of back in the
inbox list.
Track the last key that actually resolved against the inbox list. If it
used to be in the list and just disappeared, clear the selection and
stay on /inbox. Only shared links that were never in the user's inbox
continue to fall back to the issue detail page.
Also add ws-updaters tests covering onInboxIssueDeleted and
onInboxIssueStatusChanged.
* feat(issues): paginate every status column, not just done
Previously the workspace issues list fetched all non-done/cancelled
issues in a single unbounded `open_only=true` request and only
paginated the done column. In workspaces with many open issues this
ballooned the initial payload and skipped pagination entirely.
Restructure the issue list cache into per-status buckets
(`{ byStatus: { [status]: { issues, total } } }`) fetched in parallel,
generalize `useLoadMoreDoneIssues` into `useLoadMoreByStatus(status,
myIssuesOpts?)`, and render an infinite-scroll sentinel inside every
accordion group and kanban column. Sort and filter stay client-side,
matching the done column's existing behavior.
Backend `ListIssues` already supports per-status pagination, so no
API changes are required.
* fix(issues): handle project / hidden-column / lookup regressions from paginated list cache
After bucketing the issue list cache by status, three consumers that
treated `issueListOptions()` as a complete local index broke:
- `project-detail.tsx` filtered the workspace list by `project_id`
client-side, so projects whose issues sat past the first 50-per-status
page rendered empty. Switch to `myIssueListOptions(wsId,
'project:<id>', { project_id })` so the server returns only this
project's issues; add `project_id` to `ListIssuesParams` /
`MyIssuesFilter` / api client.
- `board-view.tsx` HiddenColumnsPanel read counts from the in-memory
`issues` array — a paginated fragment. Pass `myIssuesOpts` through to
a per-row subcomponent that reads the real per-status total from the
cache.
- `tasks-tab.tsx` and `search-command.tsx` used the list as a global
lookup for task titles / Recent items / current-issue chrome. Switch
both to per-id `issueDetailOptions` via `useQueries` so they're
independent of which page the issue lands on.
Drop the now-redundant `doneTotal` override prop on BoardView/ListView
and the `allIssues` prop on BoardView (only HiddenColumnsPanel consumed
it).
Tests updated: tasks-tab now mocks `api.getIssue`; search-command mocks
`issueDetailOptions` + `useQueries`; project-issue-metrics drops the
`doneColumnCount` assertion.
Two unrelated bugs were preventing the GitHub-hosted runner desktop
release matrix from succeeding:
1. Windows job failed with `spawnSync electron-vite ENOENT`. On
Windows the package-local binaries are `.cmd` shims and Node's
`spawnSync` does not consult PATHEXT unless going through a shell.
Pass `shell: true` for both the electron-vite and electron-builder
spawns; on POSIX hosts these are real executables so the shell hop
is harmless.
2. Linux `.deb`/`.rpm` job failed with electron-builder errors:
`Please specify project homepage` and `Please specify author
'email'`. fpm requires a maintainer when generating .deb, and
electron-builder derives it from the app package.json metadata. Add
`description`, `homepage`, `repository`, `author` (with
email) and `license` to apps/desktop/package.json so the Linux
targets have the metadata they need.
Refs: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
Refs: https://www.electron.build/configuration.html#metadata
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agents can end a comment-triggered run without calling `multica issue comment
add` — the final reply stays in terminal / run-log text and never reaches
the user, even though the run panel shows "Completed". PR #1372 addressed
this via prompt wording, but compliance is inherently best-effort.
The server already had an exact fix for the assignment-triggered branch:
`HasAgentCommentedSince` + fallback synthesis from `payload.Output`. The
comment-triggered branch was explicitly exempted on the theory that the
agent "replies via CLI with --parent, so posting here would create a
duplicate" — but that is precisely the path that's failing.
Remove the `!task.TriggerCommentID.Valid` guard so the invariant "every
completed issue task has at least one agent comment on the issue" holds for
both branches. The existing `HasAgentCommentedSince` check still prevents
duplicates for compliant agents, and `createAgentComment` already threads
the synthesized comment under `task.TriggerCommentID` when present.
Regression tests cover both:
- comment-triggered + silent agent → synthesized comment threaded under trigger
- comment-triggered + agent already posted → no duplicate
The renderer's navigation adapter fell back to https://multica.ai when
VITE_APP_URL was unset (i.e. desktop dev builds), so "Copy link" in a dev
build produced a production URL instead of one pointing at the running
dev web frontend. Match the fallback used by pages/login.tsx
(http://localhost:3000) so dev links stay on the dev host.
The inbox detail panel keyed `<IssueDetail>` by `selected.id` (inbox-item
id). `deduplicateInboxItems` picks the most recent inbox notification per
issue, so every new `comment:created` / `reaction:added` event for the
currently open issue produced a fresh inbox item with a new id — flipping
the React key and forcing a full unmount/remount of `IssueDetail`. That
wiped the comment composer draft, dropped focus, and reset scroll.
Key on `selected.issue_id` instead: stable for the life of an open issue
(so input + scroll survive incoming events) and still changes when the
user picks a different issue (so state resets between issues, as before).
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:
- "> quote" -> "> quote" (blockquote lost, see #1303)
- '"foo"' -> '"foo"' (literal entities visible)
- "\n\n2." -> " 2." (ordered list items merged into prose)
Comment content is stored as Markdown source. XSS is already handled at
two layers:
- Render: rehype-sanitize in packages/ui/markdown and
packages/views/editor/readonly-content (mention:// allowlist,
data-href restricted to http(s), class restricted to
code/div/span/pre).
- Edit: @tiptap/markdown is configured with html:false, so Markdown
source containing raw HTML tags is treated as plain text.
Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.
The PR #1342 workaround in the editor serializer can be dropped once
this lands.
Co-authored-by: devv-eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
posthog-js ships with autocapture, heatmaps, dead-click detection,
session recording, exception capture, and surveys all on by default.
Staging verification showed the Activity view flooded with "clicked
button" / "clicked span with text \"…\"" events — they leak
user-typed content into PostHog, burn the billed event budget, and
dilute the explicit funnel. Our product analytics surface is narrow
and intentional (see docs/analytics.md): only the events we emit
server-side plus one manual $pageview belong. Opt all the auto
surfaces off at init time so the Activity view reflects the funnel.
* feat(analytics): add PostHog client with async batch shipping
Introduces server/internal/analytics, the shipping layer for the product
funnel defined in docs/analytics.md. Capture is non-blocking — events are
enqueued into a bounded channel and a background worker batches them to
PostHog's /batch/ endpoint. A broken backend drops events rather than
blocking request handlers.
Local dev and self-hosted instances run a noop client until the operator
sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created
emission land in the follow-up commit so this change is independently
reviewable.
* feat(server): emit signup and workspace_created analytics events
Wires analytics.Client through handler.New and main, then emits the first
two funnel events:
- signup fires from findOrCreateUser (which now reports isNew), covering
both the verification-code and Google OAuth entry points — a single
emission site guarantees Google signups aren't missed.
- workspace_created fires after the CreateWorkspace transaction commits,
with is_first_workspace computed from a post-commit ListWorkspaces count
so we can distinguish fresh-user activation from returning-user
expansion.
Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of
MUL-1122; runtime_registered and issue_executed follow in later PRs per
the plan.
* refactor(analytics): drop is_first_workspace from workspace_created
Stamping "is this the user's first workspace?" at emit time races under
concurrent CreateWorkspace requests: two transactions committing close
together can both read a post-commit count greater than one and both emit
false. Fixing it at the SQL layer requires a schema change we don't want in
PR 1.
PostHog answers the same question exactly from the event stream (funnel on
"first time user does X" / cohort on $initial_event), so removing the
property loses no information and makes the emit side race-free.
* docs(analytics): document self-host safety defaults
Spell out why self-hosted instances never ship events upstream by default
(empty POSTHOG_API_KEY → noop client) and explain how operators can point
at their own PostHog project without any code change.
* feat(analytics): emit runtime_registered, issue_executed, team_invite_*
Three server-side funnel events, all gated on first-time state transitions
so retries and re-runs don't inflate the WAW buckets:
- runtime_registered fires from DaemonRegister when UpsertAgentRuntime
reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats
and re-registrations stay silent.
- issue_executed fires from CompleteTask after an atomic
UPDATE issue SET first_executed_at = now() WHERE id = $1 AND
first_executed_at IS NULL flips the column for the first time. Retries,
re-assignments, and comment-triggered follow-up tasks hit the WHERE
clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10
buckets filter without extra queries.
- team_invite_sent fires from CreateInvitation and team_invite_accepted
from AcceptInvitation, closing the expansion funnel.
Adds a 050 migration for issue.first_executed_at plus a partial index so
the workspace-scoped executed-count query doesn't scan the never-executed
tail.
* feat(config): surface PostHog key via /api/config
Extends AppConfig with posthog_key / posthog_host sourced from env on
every request (so operators can rotate the key via secret refresh without
a restart). Reading the key off the server — rather than baking it into
the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances
inherit the blank key automatically and never ship events upstream.
* feat(analytics): wire posthog-js identify + UTM capture on the client
Adds @multica/core/analytics — a thin wrapper around posthog-js that owns
attribution capture and identity merge. Posthog-js config comes from
/api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server
returns an empty key automatically run the SDK inert.
captureSignupSource stamps a multica_signup_source cookie with UTM params
and the referrer's origin (never the full referrer — that can leak OAuth
code/state in the callback URL). The backend signup event reads this
cookie on new-user creation.
Identity flows:
- auth-initializer fires identify() right after getMe() resolves, on both
cookie and token paths. A getConfig/getMe race is handled by buffering
a pending identify inside the analytics module and flushing it once
initAnalytics finishes.
- auth store calls identify() on verifyCode / loginWithGoogle /
loginWithToken and resetAnalytics() on logout so the next login merges
cleanly without bleeding events.
* docs(analytics): describe runtime_registered, issue_executed, invite events
Fills in the schema for the remaining funnel events. Captures the
design commentary that belongs next to the contract rather than in a PR
description — in particular why issue_executed uses the atomic
first_executed_at flip instead of counting task-terminal events, and why
runtime_registered relies on xmax = 0 rather than a query-then-write.
* fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed
Computing the workspace's Nth-issue ordinal at emit time is not atomic
under concurrent first-completions — two transactions can both run
MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and
both observe count=1 before either has committed, so both events go out
stamped as n=1. Serialising it would mean a per-workspace advisory lock
or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly
at query time via row_number() partitioned by workspace_id, so the
emit-time property adds risk without adding information.
Removes the property from analytics.IssueExecuted, deletes the unused
CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial
index stays — any future workspace-scoped executed-issue query will want
it.
* fix(analytics): wire $pageview and harden signup_source cookie payload
Two frontend fixes from the PR review:
- PageviewTracker, mounted under WebProviders, fires capturePageview on
every Next.js App Router path / query-string change. Without this the
capturePageview helper in @multica/core/analytics was never called and
the acquisition funnel's / → signup step was empty.
- captureSignupSource now caps each UTM / referrer value at 96 chars
*before* JSON.stringify, and drops the whole cookie when the serialised
payload still exceeds 512 chars. Previously the overall slice(0, 256)
could leave a half-JSON string on the wire that neither the backend nor
PostHog could parse.
Both capturePageview and identify now buffer a single pending call when
fired before initAnalytics resolves — otherwise the initial "/" pageview
and same-turn login identify race the /api/config fetch and get dropped.
resetAnalytics clears both buffers so a logout→login cycle stays clean.
* fix(analytics): URL-decode signup_source cookie on read
Go does not URL-decode Cookie.Value automatically, so the frontend's
JSON-then-encodeURIComponent payload was landing in PostHog as
percent-encoded garbage (%7B%22utm_source...). Unescape on read so the
backend receives the original JSON string the frontend intended, and
drop values that fail to decode or exceed the server-side cap — sending
truncated garbage is worse than sending nothing. Oversized-cookie guard
matches the frontend's SIGNUP_SOURCE_MAX_LEN.
* docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding
Pulls the schema doc back in line with the code: issue_executed no longer
advertises nth_issue_for_workspace (with a note about why PostHog derives
it at query time instead), the frontend $pageview section names the
actual PageviewTracker component that fires it, and the signup_source
section documents the per-value cap / overall drop rule and the
encode-on-write / decode-on-read contract.
---------
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
* feat(desktop): support macOS cross-platform packaging
* fix(desktop): use releaseType instead of publishingType in electron-builder publish config
publishingType is not a valid electron-builder key; the correct GitHub
provider option is releaseType. The previous value was silently ignored,
causing uploads to be skipped and breaking auto-update.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(release): standardize artifact naming across desktop and CLI
Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a
filename alone reveals kind, version, platform, and CPU arch.
Desktop (apps/desktop/electron-builder.yml):
mac → multica-desktop-<v>-mac-<arch>.{dmg,zip}
linux → multica-desktop-<v>-linux-<arch>.{deb,AppImage}
(fixes `\${name}` expanding the scoped `@multica/desktop` into a
broken `@multica/desktop-*` filename path)
windows → multica-desktop-<v>-windows-<arch>.exe
CLI (.goreleaser.yml):
multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz
(adds `-cli` marker + version; switches `_` to `-` for consistency)
Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`:
- drop mac x64 (Intel not a target yet)
- add linux arm64
Final: mac arm64, win x64/arm64, linux x64/arm64.
Downstream updates so install paths match the new CLI names:
- scripts/install.sh
- scripts/install.ps1 (URL + checksum regex)
- CLI_INSTALL.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(release): use multica_{os}_{arch} CLI archive naming
Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}'
asset names. Install scripts and the desktop CLI bootstrap now resolve
assets via checksums.txt so they work without hardcoding versions.
The Go self-update path queries the GitHub release API and accepts
either the new or legacy 'multica-cli-<version>-...' names so existing
releases keep updating cleanly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(release): ship both legacy and versioned CLI archive names
GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and
'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every
release. The legacy name keeps already-released CLIs self-updating; the
versioned name is what new clients should use going forward.
Self-update / install paths flipped to prefer the versioned name and
fall back to legacy:
- server/internal/cli/update.go (multica update)
- apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap)
- scripts/install.sh, scripts/install.ps1 (fresh install)
Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(desktop): also build Linux .rpm packages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1
Address review feedback on PR #1262:
- .github/workflows/release.yml: add a 'desktop' job that runs after the
CLI 'release' job and packages the Desktop installers for Linux
(AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes
them to the same GitHub Release via electron-builder. macOS Desktop
continues to ship through the manual release-desktop skill so it can
be signed and notarized with Apple Developer credentials.
- scripts/install.ps1: detect Windows ARM64 hosts via
RuntimeInformation::OSArchitecture so the new windows-arm64 CLI
archive is downloaded on ARM64 machines instead of always falling
back to amd64.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision
electron-builder's update metadata file is hardcoded to `latest.yml` for
Windows regardless of arch (only Linux gets an arch-suffixed name; see
app-builder-lib's getArchPrefixForUpdateFile). With two separate
electron-builder invocations for Windows x64 and arm64, both publish
`latest.yml` to the same GitHub Release and the second upload silently
overwrites the first — leaving one of the two architectures with auto-
update metadata pointing at the other arch's installer.
Route Windows arm64 to its own `latest-arm64` channel:
* scripts/package.mjs appends `-c.publish.channel=latest-arm64` only
for the Windows arm64 invocation, so x64 keeps producing `latest.yml`
and arm64 produces `latest-arm64.yml` alongside it.
* updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows
arm64 clients so they fetch the matching metadata file.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
OpenClaw's `--json` result blob carries the actual LLM identifier in
`meta.agentMeta.model` (e.g. `deepseek-chat`, `claude-sonnet-4`),
alongside `provider` and the usage breakdown. The backend was reading
the surrounding `agentMeta.usage` and `agentMeta.sessionId` but skipping
the `model` field entirely, then attributing every run's tokens to
`opts.Model` — which for openclaw is the *agent name* passed via
`--agent`, not a real model identifier — falling all the way through to
"unknown" when no agent.model was configured.
Surface the runtime-reported model:
- `openclawEventResult` gains a `model` string.
- `buildOpenclawEventResult` reads `agentMeta.model` (trimmed; empty
string when absent for forward-compat with older runtimes / partial
outputs).
- `processOutput` propagates it through the result-blob branch.
- `Execute`'s usage map prefers `scanResult.model`, falling back to
`opts.Model` then `"unknown"` — preserving the prior behavior path
for any runtime that doesn't surface its own model yet.
Two unit tests cover both the populated and missing cases.
Refs: #1395
Surface the actual exec path + argv for every agent backend at INFO
so operators can see the exact command without flipping to debug.
Also add the missing log line in pi.go for consistency with the
other nine backends.
* fix(cli): detach daemon from parent console on Windows
CREATE_NEW_PROCESS_GROUP alone leaves the daemon attached to the
parent console, so closing the launching cmd/PowerShell window fires
CTRL_CLOSE_EVENT down the inherited console and takes the daemon
with it. Add DETACHED_PROCESS so the child has no console at all;
stdout/stderr are already redirected to the log file before spawn.
* fix(cli): make `multica update` work while the binary is running on Windows
On Windows, a running .exe is opened without FILE_SHARE_WRITE, so the
previous os.Rename(tmp, exe) always failed with "Access is denied" —
every `multica update` on Windows hit this, because the CLI is
updating its own running binary.
Windows does allow renaming the running .exe (just not overwriting
it), so the new Windows-only replaceBinary moves the running binary
to `.old` first, installs the new one, and restores the original if
installation fails. A best-effort CleanupStaleUpdateArtifacts runs
at CLI/daemon startup to reclaim the leftover `.old` file once the
old process has exited.
Unix keeps the plain rename-over semantics (the old inode stays valid
for the running process).
* fix(cli): stop daemon via HTTP /shutdown instead of console ctrl events
With DETACHED_PROCESS the Windows daemon shares no console with the
stop caller, so `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid)`
silently never reaches it — the old code would report "stop sent"
while the daemon kept running. Replace the platform-specific
stopDaemonProcess with a cross-platform POST to the daemon's HTTP
/shutdown endpoint, which cancels the same top-level context the
self-restart path already uses. Fall back to `process.Kill()` if
the HTTP call fails.
Also drops the now-unused stopDaemonProcess / CTRL_BREAK_EVENT
wiring, adds handler tests, and updates the DETACHED_PROCESS comment.
Extract the URL assembly at the end of S3Storage.Upload into a helper
(uploadedURL) so the four env-var combinations can be covered by a
table-driven test without mocking s3.PutObject. This locks in the fix
from #1300 — cdn > endpoint > bucket — so future refactors can't
silently regress the CDN-wins-over-custom-endpoint case.
No behavior change.
Phase 0 hotfix for the cross-workspace contamination reported in MUL-1027
/ #1235: an agent running for workspace A ended up commenting on (and
renaming) a two-day-old issue in workspace B.
#1249/#1259 fixed resolution for autopilot tasks and consolidated the
task-workspace resolver, and #1294 populated workspace_id in the claim
response for run_only autopilot tasks. Those closed the known fallthroughs
but the failure mode is still broader: whenever the daemon or server fails
to supply a workspace, the CLI silently falls back to
`~/.multica/config.json`, which is user-global, not workspace-scoped. On a
host running daemons for multiple workspaces, a single gap in workspace
propagation is enough to leak writes across workspaces.
This PR adds three coordinated guards so no single layer's bug can cause a
cross-workspace write:
1. `server/cmd/multica/cmd_agent.go` — `resolveWorkspaceID` detects the
agent execution context (`MULTICA_AGENT_ID` / `MULTICA_TASK_ID` env,
both daemon-only markers) and in that context refuses to fall back to
the user-global CLI config. Human / script usage (no agent env) is
unchanged: flag → env → config fallback chain still applies.
2. `server/internal/handler/daemon.go` — `ClaimTaskByRuntime` now
captures the runtime's workspace from `requireDaemonRuntimeAccess` and
enforces `resolved_task_workspace == runtime_workspace` after the
existing issue/chat/autopilot branches. On mismatch or empty, the
handler explicitly cancels the just-dispatched task (via
`TaskService.CancelTask`, which also reconciles agent status) and
returns 500. Without the explicit cancel, `ClaimTaskForRuntime` had
already transitioned the task to 'dispatched' and the agent status to
'working', so a plain 500 would leave both stuck for the ~5 min
stale-task sweep window.
3. `server/internal/daemon/daemon.go` — `runTask` refuses to spawn the
agent when `task.WorkspaceID` is empty (defense-in-depth against
server bugs and reused workdirs).
Tests:
- `cmd/multica/cmd_agent_test.go`:
`TestResolveWorkspaceID_AgentContextSkipsConfig` — five subtests
covering the full fallback matrix (outside agent context still reads
config; agent context uses env; agent context with empty env returns
empty; task-id-only marker also counts; requireWorkspaceID surfaces the
agent-context error message).
- `internal/handler/daemon_test.go`:
`TestClaimTaskByRuntime_TaskWorkspaceMismatch_CancelsAndRejects` —
constructs a data-inconsistent task (runtime_id in workspace A,
issue_id in workspace B) and asserts the handler returns 500 AND
leaves the task in 'cancelled' state (not 'dispatched').
Phase 1/2 follow-ups (prompt injection of workspace slug, session lookup
workspace filter, cross-workspace audit of agent-facing endpoints,
observability) are out of scope for this PR and tracked separately.
When both AWS_ENDPOINT_URL and CLOUDFRONT_DOMAIN are configured, the
uploaded file URL returned by S3Storage.Upload now uses the CDN domain
instead of the raw S3-compatible endpoint.
This enables S3-compatible backends (MinIO, R2, B2, Wasabi, etc.) to be
paired with a separate public-read domain — previously the CDN domain was
silently ignored whenever a custom endpoint was set, forcing clients to
hit the raw S3 API endpoint which typically requires signed requests.
No behavior change for deployments that set only one of the two vars:
pure AWS S3 with CloudFront, AWS S3 without a CDN, and MinIO/R2 without
a CDN all continue to return the same URLs as before.
The ListIssues and ListOpenIssues SQL queries omitted the description
column, so the API response never included description data. Board cards
checked issue.description (always null) and never rendered it, even when
the Description card property was enabled.
Add description to both SQL queries, the generated Go structs/scan calls,
and the response mapping functions.
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:
- "> quote" -> "> quote" (blockquote lost, see #1303)
- '"foo"' -> '"foo"' (literal entities visible)
- "\n\n2." -> " 2." (ordered list items merged into prose)
Comment content is stored as Markdown source. XSS is already handled at
two layers:
- Render: rehype-sanitize in packages/ui/markdown and
packages/views/editor/readonly-content (mention:// allowlist,
data-href restricted to http(s), class restricted to
code/div/span/pre).
- Edit: @tiptap/markdown is configured with html:false, so Markdown
source containing raw HTML tags is treated as plain text.
Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.
The PR #1342 workaround in the editor serializer can be dropped once
this lands.
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(agent): add Kimi CLI as agent runtime
Adds support for Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/kimi-cli)
as a new agent runtime, alongside Claude, Codex, OpenCode, OpenClaw, Hermes,
Gemini, Pi, Cursor and Copilot.
Kimi Code CLI implements the standard Agent Client Protocol (ACP) via the
`kimi acp` subcommand, so the new `kimiBackend` reuses the existing
hermesClient JSON-RPC transport in the agent package — only the binary,
client identity, log prefix, and tool-name extraction differ.
Wiring:
- server/pkg/agent: new kimiBackend + kimi_test.go; registered in New(),
LaunchHeader map, and the supported-types coverage test.
- server/internal/daemon/config.go: probes `kimi` (overridable via
MULTICA_KIMI_PATH / MULTICA_KIMI_MODEL).
- server/internal/daemon/execenv: writes AGENTS.md as the runtime context
file (Kimi reads AGENTS.md natively via /init), and writes skills under
`.kimi/skills/` so they are auto-discovered by the project-level skill
loader.
- packages/views/runtimes: ProviderLogo gains a Kimi mark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(agent/kimi): support per-agent model selection via ACP set_model
Wire Kimi into the model dropdown introduced in #1399:
- ListModels gets a 'kimi' case that drives the same ACP
initialize + session/new handshake as Hermes; both share a new
discoverACPModels helper and parseACPSessionNewModels parser
so future ACP backends only need a small provider entry.
- kimiBackend now issues session/set_model after session/new when
opts.Model is non-empty, mirroring the Hermes flow. Failures
fail the task instead of silently falling back to Kimi's
default model — silent fallback would hide that the dropdown
pick wasn't honoured.
Verified: go build ./..., go test ./pkg/agent/... ./internal/daemon/... ./internal/handler/..., pnpm typecheck and pnpm test (138 passed).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(agent): address code review feedback on Kimi runtime
- Share ACP provider-error sniffer between hermes and kimi. Previously
only hermes promoted stderr-observed 4xx/5xx into a failed task;
kimi would report "completed + empty output" when the Moonshot
upstream rejected a request (expired token, rate limit, …). Rename
hermesProviderErrorSniffer → acpProviderErrorSniffer and parameterise
the provider name; wire it into kimiBackend.Execute the same way.
- Rename extractHermesSessionID → extractACPSessionID (shared by all
ACP backends) so the name matches parseACPSessionNewModels.
- Drop the redundant second argument to kimiToolNameFromTitle; the
Message struct has only one relevant field (Tool), so passing it
twice was a dead fallback. Document that the function normalises
residual capitalised kimi titles not caught by hermesToolNameFromTitle.
- Remove kimi-only cmd.WaitDelay override; the hermes baseline is
fine for both and divergence adds noise.
- Add TestKimiBackendSetModelFailureFailsTask: fake `kimi acp` binary
that returns a JSON-RPC error for session/set_model, asserts that
the task result surfaces status=failed with the model name + upstream
message and preserves the session id.
- Fix stale agent listings in agent.go / daemon/config.go doc comments
(missing cursor, gemini, copilot).
All: `go build ./...`, `go vet ./...`, `go test ./pkg/agent/...
./internal/daemon/... ./internal/handler/...` green.
* fix(agent/kimi): pass --yolo so Shell tools don't hang on approval
Kimi's default config has `default_yolo = false`. Every Shell/file-mutating
tool call causes kimi acp to send a `session/request_permission` request
and block (up to 300s) waiting for a response. The daemon's hermesClient
only handles `session/update` notifications — permission requests go
unanswered, the tool call times out, and the UI loop eventually dies
("UI loop timed out"). Observed with the first real kimi task: agent sat
as Live for ~7 minutes before the daemon killed it.
The fix mirrors hermes' HERMES_YOLO_MODE=1 override: pass `--yolo` to
`kimi` so it auto-approves everything. `--yolo` is a top-level flag on
the `kimi` CLI (not a flag on `kimi acp`), so it must come before the
`acp` subcommand in argv. Added to kimiBlockedArgs so user custom_args
can't strip it.
While here, fix a related bug that made kimi tool names show up empty
in the daemon log ("tool #1: "): hermesToolNameFromTitle's fallback
returned `kind` when neither title-with-colon nor kind matched a known
tool. Kimi's ACP `tool_call` emits bare titles like "Shell" or "Read
file" with no `kind` at all, so we'd drop the title on the floor before
kimiToolNameFromTitle ever got a chance to map it. Now: preserve the
title when kind is unclassified; hermes titles always carry a colon so
this branch never fires for hermes.
Tests:
- TestKimiBackendPassesYoloFlag — fake binary that records its argv,
asserts --yolo comes before acp.
- TestHermesToolNameFromTitle rows for bare kimi-style titles.
- Existing suite green: go build, go vet, full pkg/agent + daemon +
handler test packages.
* fix(agent/acp): auto-approve session/request_permission from agent
The previous attempt (`kimi --yolo acp`) was a no-op. Inspected the
kimi-cli source: the `acp` Typer subcommand takes no parameters, so
flags on the root `kimi` command are dropped before `acp_main()` runs
— it's impossible to opt into YOLO mode through CLI flags for ACP.
The real fix is on our side: respond to session/request_permission.
ACP is bidirectional. When kimi runs a Shell or file-write tool, it
sends `session/request_permission` (agent → client, JSON-RPC request
with id + method) and waits up to 300s for a response. Our existing
hermesClient.handleLine only dispatched: (id + result/error) →
handleResponse, and (no id + method) → handleNotification. A request
with BOTH id and method fell through and got silently dropped — kimi
timed out, UI loop died, task sat stuck for 7 minutes.
Add handleAgentRequest: for session/request_permission, echo the id
and respond with outcome=selected, optionId=approve_for_session. The
daemon is headless; there's no user to prompt. `approve_for_session`
lets the agent remember the action so subsequent identical calls
(every Shell, every file write) skip the round-trip entirely. For any
other agent → client method, reply with standard -32601 method-not-
found so the agent doesn't block.
Also:
- Add writeMu so request() (main goroutine) and handleAgentRequest
(reader goroutine) don't interleave JSON frames on stdin.
- Revert the `--yolo acp` flag — it's a no-op, and carrying it in
kimiBlockedArgs gives the wrong impression that it does something.
Comment in kimi.go now points at handleAgentRequest as the real fix.
Tests:
- TestHermesClientAutoApprovesPermissionRequest: inject a
session/request_permission, assert the reply echoes the id and
carries {outcome: selected, optionId: approve_for_session}.
- TestHermesClientReplesMethodNotFoundForUnknownAgentRequest: confirm
unknown agent → client methods get JSON-RPC -32601 instead of silence.
- TestKimiBackendInvokesACPSubcommand replaces the yolo-flag assertion
with a negative assertion: no dead --yolo / --auto-approve / -y on
argv, since they'd pretend to do something they can't.
All: go build ./..., go vet ./..., go test ./pkg/agent/... green.
* fix(agent/acp): surface kimi tool input/output via content blocks
Kimi-cli emits tool_call and tool_call_update ACP frames with the
input/output inside a `content` array of ContentToolCallContent
blocks (shape: {type:"content", content:{type:"text", text:"..."}}),
not in the hermes-style `rawInput` map / `rawOutput` string. Our
parser only looked at rawInput/rawOutput, so the daemon recorded
empty Input and Output for every kimi tool — the execution-history
UI showed blank terminal panels even for commands that ran fine.
Add extractACPToolCallText() and a fallback in handleToolCallStart /
handleToolCallUpdate: when rawInput is nil / rawOutput is empty, pull
the text out of the content blocks. rawInput / rawOutput still take
precedence so hermes' behaviour is untouched. Terminal /
FileEditToolCallContent blocks are skipped (we have nothing to render
them as — kimi only emits TerminalToolCallContent when the client
advertises terminal capability, which we don't).
Tests:
- TestHermesClientHandleToolCallStartKimiContent — content array →
Input.text populated.
- TestHermesClientHandleToolCallCompleteKimiContent — multi-block
content → Output concatenated with newline separator.
- TestHermesClientHandleToolCallRawOutputTakesPrecedence — hermes
rawOutput still wins when both are present.
- TestExtractACPToolCallText — unit coverage for the helper
(single/multiple text blocks, terminal-block skip, empty input).
* fix(agent/acp): buffer streaming tool args so Input isn't empty in UI
kimi-cli streams tool args token-by-token via tool_call_update frames
— the initial tool_call carries an empty content block and each
subsequent in_progress update carries the cumulative JSON so far
(`{`, `{"comma`, `{"command": "echo`, …). The final completed update
then carries the tool's stdout, not the args. Observed per kimi-cli
acp/session.py::_send_tool_call{,_part,_result} and confirmed by
driving a real Shell call end-to-end: 10 in_progress frames, last
with `{"command": "echo hello world"}`, then completed with `hello
world\n`.
Our previous handleToolCallStart emitted MessageToolUse on the first
tool_call frame, capturing the empty content — so every kimi tool
appeared in the execution-history UI with a blank input. Output was
correct (fix 4335c198) but command was missing.
Changes:
- hermesClient now tracks pending tool calls per toolCallId. Hermes
path is unchanged — rawInput is present at tool_call time, so
emit-immediately-then-flag-emitted still fires on the initial frame.
- kimi path defers MessageToolUse until status=completed / failed.
tool_call_update in_progress frames update the buffered argsText
(cumulative, so overwrite); on completion we parse the accumulated
JSON into Message.Input. Malformed JSON falls back to `{"text": …}`
so non-JSON tool args still render.
- Orphan completion frames (no matching tool_call seen — e.g. daemon
restarted mid-task) synthesise ToolUse from the update's own
title/kind/rawInput so the UI still gets a header.
- extractACPToolCallText now also renders FileEditToolCallContent
blocks as a compact header ("--- path / +++ path / (edited: N → M
bytes)"). kimi emits these for Write / StrReplaceFile / Patch when
the tool's display block is a DiffDisplayBlock.
Tests:
- TestHermesClientKimiStreamingToolCall: empty tool_call + 5 streaming
in_progress + completed. Asserts no emission until complete, then
[ToolUse(Input.command="echo hi"), ToolResult(Output="hi\n")].
- TestHermesClientKimiMalformedArgsFallback: non-JSON argsText → falls
back to Input.text.
- TestHermesClientHandleToolCallCompleteOrphan: completed frame
without a start → ToolUse synthesised from update's rawInput.
- TestExtractACPToolCallText: diff + new-file-diff cases.
All agent / daemon / handler test packages green.
---------
Co-authored-by: Eve <8b0578a3-cf72-4394-9e38-b328eca92463@users.noreply.multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.
Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.
Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.
Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
The session cookie's Secure flag was tied to APP_ENV, and the
docker-compose self-host stack defaults APP_ENV to "production". On
plain-HTTP self-host deployments (LAN IP, private network) the browser
silently drops Secure cookies, leaving every subsequent /api/* call
anonymous and surfacing as 401 "auth: no token found" right after a
successful login.
Derive Secure from the scheme of FRONTEND_ORIGIN so HTTPS origins get
Secure cookies and plain-HTTP origins get non-secure cookies the
browser will actually store. Also harden cookieDomain() against the
other common trap: COOKIE_DOMAIN=<ip>, which RFC 6265 forbids and
browsers reject. Log a one-shot warning and fall back to host-only.
Docs: correct the COOKIE_DOMAIN description (it was labelled as
CloudFront-only but applies to session cookies too) and call out the
IP-literal pitfall in SELF_HOSTING_ADVANCED.md, self-hosting.mdx, and
.env.example.
Refs #1321
AgentLiveCard kept its taskStates map across issueId prop changes, and
its merge logic only added newly-fetched tasks without removing stale
ones. Navigating from Issue A (with a running agent) to Issue B via
cmd+k left A's sticky agent status card pinned on B's page.
Key AgentLiveCard and TaskRunHistory by issue id so React remounts
them when the issue changes, guaranteeing fresh state per issue.
Closes MUL-1147
Agents were silently finishing tasks without ever posting results to the
issue — their final reply stayed in terminal/log output only. See MUL-1124.
Root cause: the injected CLAUDE.md / AGENTS.md put "post a comment with
results" inside the body of step 4 (a nested clause in the default workflow
description), so skill-driven flows jumped straight from "do the work" to
`status in_review`.
- Hoist posting the result comment into its own explicit, numbered step in
both assignment-triggered and comment-triggered workflows, with the exact
`multica issue comment add` invocation inlined.
- Add a hard warning at the top of the Output section that terminal / chat
text is never delivered to the user.
- Add regression test covering both workflow branches.
* feat(server): configurable pgxpool size with sane defaults
pgxpool.New(ctx, url) silently sets MaxConns = max(4, NumCPU). On the
prod pods that resolved to 4, which got fully saturated by daemon
claim/heartbeat traffic (~3800 acquires/s) and showed up as ~900ms
acquire waits on every query — the actual root cause of the 3s+
/tasks/claim tail latency. The db pool stats logging from #1378
confirmed this with empty_acquire_delta == acquire_count_delta.
Switch to pgxpool.ParseConfig + NewWithConfig and apply per-pod
defaults of MaxConns=25 / MinConns=5, both overridable via env vars
(DATABASE_MAX_CONNS / DATABASE_MIN_CONNS) so the size can be tuned
in prod without a redeploy.
The defaults follow the standard 'small pool, lots of waiters' guidance
for Postgres (PG community / HikariCP formula
`(core_count * 2) + effective_spindle_count`); 25 leaves headroom for
bursts and occasional long queries while staying safely under typical
managed-Postgres max_connections ceilings when multiplied across pods.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(server): respect DATABASE_URL pool_* params; add precedence tests
Address review feedback on #1381:
- Configuration precedence is now explicit: DATABASE_MAX_CONNS env >
pool_max_conns query param on DATABASE_URL > built-in default. Same
for min_conns. Previously the env-empty path unconditionally
overwrote whatever ParseConfig had read from the URL — a silent
regression for deployments that already tuned pool size via the
connection string.
- Add unit tests in dbstats_test.go covering each precedence branch
(defaults, URL-only, env-over-URL, partial URL, invalid env,
min>max clamp).
- Move pool tuning vars out of 'Required Variables' into a new
'Database Pool Tuning (Optional)' section in SELF_HOSTING_ADVANCED.md
so self-hosters don't think they need to set them.
- Add commented entries in .env.example.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(server): invalid pool env falls back to URL/code default, never pgx 4
Address second round of review on #1381:
Previous code passed cfg.MaxConns / cfg.MinConns as the envInt32 fallback,
which meant an invalid DATABASE_MAX_CONNS value silently fell back to
ParseConfig's value — i.e. pgx's built-in default of 4/0 when the URL had
no pool_* params. That's exactly the bad value this PR exists to remove,
and the previous test (TestPoolSizing_InvalidEnvFallsBack) accidentally
locked it in.
Compute the non-env fallback first (URL pool_* if present, else code
default 25/5) and pass that to envInt32. Misconfigured env now lands on
the same value as if the env were unset — never on the pgx default.
Replace the loose 'max > 0' assertion with two precise tests:
- invalid env + no URL param → code default (25/5)
- invalid env + URL param → URL value
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After merging the per-phase claim slow-logs (#1376), the prod data showed
the smoking gun: unrelated endpoints (claim, heartbeat, /api/workspaces,
ping) all completed at the *same wall-clock instant* with durations
clustered at ~1.4s and ~2.88s — and within the claim breakdown,
list_pending_ms was 713ms even when list_pending_count=0.
A 0-row indexed scan can't take 713ms, and unrelated endpoints don't
synchronize their completion by accident. The only explanation that fits
is requests blocking on a shared resource and being released together.
The most likely culprit is pgxpool connection-acquire wait: pgxpool.New
is called with no config, so MaxConns defaults to max(4, NumCPU) — under
the daemon poll fan-in this is trivially exhausted.
This change adds the observability needed to confirm/refute that without
changing pool sizing yet (pool sizing is a follow-up once we have data):
- logPoolConfig: prints MaxConns / MinConns / MaxConnLifetime /
MaxConnIdleTime / HealthCheckPeriod once at startup. Surfacing the
effective limit is critical because the default is surprisingly small
and easy to mistake for 'database is slow'.
- runDBStatsLogger: samples pool.Stat() every 15s (matches daemon
heartbeat cadence for easy correlation). Emits INFO with TotalConns /
AcquiredConns / IdleConns and per-window deltas (acquire_count,
empty_acquire, canceled_acquire, avg_acquire_ms). Auto-upgrades to
WARN whenever empty_acquire_delta > 0 or canceled_acquire_delta > 0
— those are the direct symptom of a request having to wait because
no idle connection was available.
If on prod we see 'db pool pressure' WARN lines coincident with the
claim_endpoint slow lines, the hypothesis is confirmed and the fix
becomes a one-liner (pool config tuning + the existing N+1 reduction
ideas to lower demand).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore(server): add slow-path timing logs for /tasks/claim
We're seeing 3s+ tail latency on POST /api/daemon/runtimes/{rid}/tasks/claim
in production. Before changing the code, add structured timing logs along
the entire claim path so we can confirm where the time is actually going.
Three layers, all gated by a slow-only threshold to avoid log spam at the
default 3s daemon poll cadence:
- handler.ClaimTaskByRuntime (>=500ms): splits auth_ms / claim_ms /
build_ms so we can tell whether the slowness is in the actual claim
query or the post-claim response assembly (GetAgent, LoadAgentSkills,
GetIssue, GetWorkspace, GetComment, GetLastTaskSession, or the chat
branch's 4 queries).
- service.ClaimTaskForRuntime (>=300ms): logs list_pending_ms,
list_pending_count, agents_tried, claim_loop_ms — directly validates
the suspected N+1 amplification (one ListPendingTasksByRuntime + one
ClaimTask per unique agent).
- service.ClaimTask (>=300ms): splits get_agent_ms / count_running_ms /
claim_agent_ms so we can isolate the NOT EXISTS + FOR UPDATE SKIP
LOCKED cost from the surrounding metadata reads.
Pure observability change. No behavior change in the request path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore(server): widen claim slow-log to cover post-claim DB work and error paths
Address review feedback on #1376: the previous version emitted
'claim_task slow' before updateAgentStatus and broadcastTaskDispatch,
both of which can hit the DB (broadcastTaskDispatch goes through
ResolveTaskWorkspaceID and may re-query issue/chat_session/autopilot_run).
That meant a claim that was actually slow in the post-claim tail would
either be under-counted or not logged at all, defeating the purpose of
the instrumentation.
Changes:
- ClaimTask: switch to defer-based exit logging. Adds update_status_ms
and dispatch_ms phase fields. Error paths now also emit a slow log
with outcome=error_get_agent / error_count_running / error_claim.
- ClaimTaskForRuntime: same defer pattern; error paths log with
outcome=error_list / error_claim, partial loop time still captured.
- ClaimTaskByRuntime handler: same defer pattern; auth-failure / claim-
error paths now also carry phase timings (outcome=unauth / error_claim).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agent/comments): re-emit trigger comment id every turn + server-side parent_id guard
Resumed Claude sessions keep prior turns' tool calls in context, so a
comment-triggered task could reuse the PREVIOUS turn's --parent UUID
instead of the current trigger's. The reply landed in the wrong thread
(MUL-1125): backend stored exactly what the agent sent, but the agent
pulled a stale UUID from its own conversation memory.
Two layers of defense:
1. Extract BuildCommentReplyInstructions so daemon.buildCommentPrompt
and execenv.InjectRuntimeConfig emit the same "use this exact
--parent, do not reuse values from previous turns" block. The
per-turn prompt now carries the current TriggerCommentID, which it
previously relied on CLAUDE.md for (and CLAUDE.md isn't re-read
mid-session).
2. Handler-side guard in CreateComment: when an agent posts from inside
a comment-triggered task (X-Agent-ID + X-Task-ID, task has
TriggerCommentID), require parent_id == task.TriggerCommentID or
return 409. Assignment-triggered tasks are untouched.
* fix(agent/comments): scope parent_id guard to the task's own issue
Two issues from CI + GPT-Boy's review:
1. Guard was too broad: the CLI stamps X-Task-ID on every request, so an
agent legitimately commenting on a different issue while its current
task was comment-triggered would get 409'd with the wrong issue's
trigger comment id. Narrow the guard to fire only when the request's
issue matches the task's own issue — cross-issue agent activity
stays unblocked.
2. The integration test tried to insert a second queued task for the
same (agent, issue), which hits the idx_one_pending_task_per_issue_agent
unique index. Replace the assignment-triggered-task sub-case with a
cross-issue regression test (the scenario we now need to cover anyway):
post on issue B while X-Task-ID points at a comment-triggered task on
issue A, expect 201.
Replace the single day picker in the "Weekly" frequency with a multi-select
so users can schedule on any combination of weekdays (e.g. Mon/Wed/Fri)
in addition to the existing "Weekdays" Mon-Fri preset.
The backend already accepts any day-of-week list in the cron expression,
so this is a frontend-only change. Relabels the tab to "Days" to reflect
the new behavior.
* fix(agent/codex): surface stderr tail in initialize / turn startup errors
When codex app-server exits before the JSON-RPC handshake completes —
e.g. because the user put a flag in custom_args that the subcommand
rejects — the Result.Error users see is `codex initialize failed:
codex process exited`, while codex's actual complaint (typically
something like `error: unexpected argument '-m' found`) only lives in
daemon logs.
Wrap the stderr writer with a bounded stderrTail that still forwards
to the slog logWriter but also retains the last 2 KiB of bytes
written. Include that tail on the three startup failure paths
(initialize, startOrResumeThread, turn/start). Runtime cancellation
paths are left untouched — they're our own abort and the stderr
context isn't a clear signal there.
Refs #1308. Complement to #1310 / #1312 — lets "bad custom_args fail
loudly" actually be workable by giving the failure a real message.
* fix(agent/codex): join cmd.Wait() before sampling stderr tail
Addressing review of #1314: reading stderrBuf.Tail() right after
c.request returns "codex process exited" was racy. Nothing in that
path synchronizes with os/exec's internal stderr copy goroutine —
cmd.Wait() is the only documented join point. The original defer ran
cmd.Wait() later, but by then we had already built Result.Error from
a potentially-empty Tail().
Replace the ad-hoc deferred stdin.Close()/cmd.Wait() with a
sync.Once-wrapped drainAndWait closure. Call it explicitly on the
three startup failure paths before sampling the tail; keep it as the
cleanup defer so the success path behaves identically.
Also add TestCodexExecuteSurfacesStderrWhenChildExitsEarly: spawns a
real subprocess that prints to stderr and exits before responding to
initialize, runs it through Execute, and asserts Result.Error
contains the stderr hint. This covers the full timing path the
reviewer flagged, which the helper-level tests in this PR did not.
* feat(desktop): hourly update poll + manual check button in settings
The previous updater only ran one check 5s after launch, so a missed
or failed initial check meant the user had to fully restart the app to
see a new release. Add a 1h background poll for long sessions and a
"Check now" button under a new Updates tab in Settings so the user can
trigger a check on demand without waiting.
The button reuses the existing autoUpdater pipeline — when an update is
available the existing corner notification still drives the download
flow; the settings tab only surfaces the immediate check result
(up-to-date / available / error).
* fix(desktop): trust electron-updater's isUpdateAvailable for the manual check
Per review: deriving `available` from a version-string compare is wrong —
`updateInfo.version` can differ from `app.getVersion()` while
electron-updater still suppresses `update-available` (pre-release channels,
staged rollouts, downgrade scenarios, min-system-version gates). In those
cases the settings tab would say "vX is available" but no corner download
prompt would ever appear. Use `result?.isUpdateAvailable` instead, which is
electron-updater's own answer.
Follow-up to PR #1188 / migration 047, which intentionally omitted the
five historical conflict slugs (admin / multica / new / setup / www) from
the reserved-slug audit because each had one production workspace using
it at the time and we did not want to block deploy on owner outreach.
MUL-972 closed that loop on prd for four of the five:
* admin (99cd10e4-…) → renamed to legacy-admin-99cd10e4
* multica (dcd796aa-…) → renamed to legacy-multica-dcd796aa
* new (e391e3ed-…) → renamed to legacy-new-e391e3ed
* www (5e8d38b2-…) → workspace deleted (was empty: 0 issues /
projects / agents, owner-only member; 18
workspace-FK relations all CASCADE)
This PR:
1. Adds migration 049_audit_legacy_reserved_slugs which audits those
four slugs against workspace.slug at startup. If any future workspace
slips in with one of them, startup fails loudly via RAISE EXCEPTION
instead of being silently shadowed by a global route. Mirrors the
structure of 047.
2. Adds 'multica' / 'www' / 'new' to the reserved-slug allow-deny list
in both the Go handler and the shared TS list (admin was already in
both). Keeps the two lists in lockstep per the convention enforced
in workspace_reserved_slugs.go header.
setup is STILL exempt from the audit and is intentionally NOT added to
the reserved list. The setup workspace (b43f0bc2-…) is a real production
user (owner: Roberto Betancourth, building a chants/Alabanzas app) and
is being handled out-of-band via owner outreach. A separate follow-up
migration will fold setup into the audit once that workspace's slug has
been migrated.
Migration is intentionally shipped AFTER the prd data fix (not before):
049 will RAISE EXCEPTION on any remaining conflict, so we want the data
state clean first. Rollout order:
prd data fix (done by db-boy on 2026-04-20) → this PR.
Tested:
- go test ./server/internal/handler/ -run TestReserved → pass
- pnpm --filter @multica/core test consistency → pass (4/4 in
consistency.test.ts; global-prefix↔reserved invariant holds)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When Desktop opens /login?platform=desktop in the browser and the user
already has a valid web session, the page previously bounced them to
their workspace and Desktop never received a token. Now we mint a bearer
token via issueCliToken and redirect through the multica:// deep link so
Desktop completes sign-in without a second Google round-trip.
Refs: MUL-1080
Fixes#1332.
Two regressions introduced in #910 (2026-04-14, "OpenClaw backend P0+P1
improvements") that together block all openclaw users:
1. `openclaw agent` does not accept `--model` or `--system-prompt`, so
any agent configured with a Model field crashed in ~700ms with
`exit status 1`. Remove both forwards, and add them to
openclawBlockedArgs so custom_args can't reintroduce the crash.
Model is bound at registration time via `openclaw agents
add/update --model`.
2. AgentInstructions were written to `{workDir}/AGENTS.md` by
execenv.InjectRuntimeConfig, but openclaw loads bootstrap files
from its own workspace dir — the file was never read, so every
agent's Instructions field was silently discarded. Populate
opts.SystemPrompt for the openclaw provider in runTask and
prepend it to the `--message` payload in the backend so the
model actually receives the instructions.
Other providers surface instructions through their native runtime
config file (CLAUDE.md / AGENTS.md / GEMINI.md) and are intentionally
left unchanged to avoid double injection.
Extract buildOpenclawArgs so arg construction is directly testable;
add unit tests covering the removed flags, the SystemPrompt prepend,
and custom_args filtering.
* fix(chat): preserve chat session resume pointer across failures
The chat 'forgets earlier messages' bug came from PriorSessionID being
silently lost in several edge cases:
- UpdateChatSessionSession unconditionally overwrote chat_session.session_id,
so any task that completed without a session_id (early agent crash,
missing result) wiped the resume pointer to NULL.
- CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A
follow-up chat message claimed in between resumed against a stale (or
NULL) session and started over.
- FailAgentTask never wrote session_id back, so a task that established
a real session before failing lost its resume pointer.
- ClaimTaskByRuntime only trusted chat_session.session_id and never
fell back to the existing GetLastChatTaskSession query, so a single
bad turn could permanently drop the conversation memory.
This change:
- Use COALESCE in UpdateChatSessionSession so empty inputs preserve the
existing pointer; surface DB errors instead of swallowing them.
- Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside
the same transaction (TaskService now takes a TxStarter).
- Extend FailAgentTask + the daemon FailTask path (client, handler,
service) to forward session_id/work_dir, so failed/blocked tasks that
built a real session still record it.
- Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the
chat_session pointer is missing, and include failed tasks in that
lookup so a single failure can't lose the conversation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(daemon): forward session_id/work_dir on blocked + timeout paths
runTask previously dropped result.SessionID and env.WorkDir on the
non-completed return paths:
- timeout returned a naked error, so handleTask called FailTask with
empty session info and the chat resume pointer was either left stale
or eventually overwritten with NULL.
- blocked / failed (default branch) returned a TaskResult without
SessionID / WorkDir, so even though FailTask now COALESCEs into
chat_session, there was no value to write through.
- the empty-output completion path was the same: it raised an error
even when a real session_id had been built.
All three paths now return a TaskResult that carries the SessionID /
WorkDir the backend produced. Combined with the COALESCE-based update
in UpdateChatSessionSession and the FailTask plumbing introduced in
PR #1360, the next chat turn can always resume from the latest agent
session — even when the previous turn timed out, was rate-limited, or
returned an empty completion — instead of starting over with no memory
of the conversation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(copilot): capture session id from session.start as fallback
The Copilot backend only read sessionId from the synthetic 'result'
event, ignoring the one already present on session.start. When the CLI
was killed before result arrived (timeout, cancel, crash, or a
session.error mid-turn), the daemon reported SessionID="" and the
chat-session resume pointer could not advance — causing the chat to
silently drop conversation memory on the next turn.
Capture session.start.sessionId into state up front, and only let
'result' overwrite it when it actually carries one. result still wins
when present (it is the authoritative end-of-turn record).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(copilot): parse premiumRequests as float to preserve session id
Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5),
not an integer. Our copilotResultUsage struct typed it as int, which
made the entire 'result' line fail json.Unmarshal — silently dropping
sessionId on every turn.
This was the real cause of chat memory loss: the daemon reported
SessionID="" to the server, chat_session.session_id stayed NULL, and
the next chat turn never received --resume <id>, so each turn started
a fresh Copilot session with no prior context.
Add a regression test using the real JSON line from CLI v1.0.32 that
asserts sessionId is preserved when premiumRequests is fractional.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: yushen <ldnvnbl@gmail.com>
The v0.2.6 self-host security fix (#1307) defaults APP_ENV to "production"
in docker-compose.selfhost.yml, which disables the 888888 master verification
code. The follow-up docs PR (#1313) covered SELF_HOSTING.md and the
installers, but `.env.example` — the file users actually copy and edit —
still makes no mention of APP_ENV, so operators who don't read the prose
docs hit the exact same "888888 stopped working after upgrade" confusion
reported in #1331.
- Add APP_ENV= to .env.example with a comment block that spells out the three
cases (Docker default, local dev, evaluation) and warns against enabling
the bypass on public instances. Keeping the value empty preserves the
current `make dev` UX (Go server reads empty → treats as non-production →
888888 works locally) while `${APP_ENV:-production}` in the compose file
still ensures public Docker deployments are safe by default.
- Update the existing 888888 mention under # Email so it no longer
contradicts the new gating rule.
- Update the `make selfhost` post-start banner, which still told operators
to "Log in with any email + verification code: 888888" even after #1307
disabled that path by default.
Adds a "Create sub-issue from selection" button to the editor bubble
menu. When an issue context is present (description editor, comment
input, reply input, comment edit), selecting text and clicking the
button creates a new issue parented under the current issue and
replaces the selection with a mention link to the new issue.
Closes#930
- Added environment variables to control signups
- Updated frontend to hide signup text when disabled
- Added backend check to block new user creation via magic link
- Updated .env.example
ESC did nothing inside the issue description editor because browsers
don't blur contenteditable elements by default, leaving users stuck in
the editor with no keyboard escape hatch.
Add a blur-shortcut extension mirroring TitleEditor's behavior and wire
it into ContentEditor's edit-mode extension set.
Previously the issue detail top bar only showed 'workspace name > identifier'.
Add the issue title next to the identifier so users can see what issue they're
viewing without scrolling.
* fix(sidebar): stabilize useQuery default arrays to prevent render loop
Inline `= []` defaults on `useQuery` return a new array reference on
every render when `data` is undefined (query disabled or mid-load).
Downstream effects/memos that depend on the value then fire every
render; the pinned-items `useEffect` compounds this by calling
`setLocalPinned` each time, so under sustained `data === undefined`
(e.g. backend unreachable, WebSocket in reconnect loop) React trips
its "Maximum update depth exceeded" guard and the sidebar becomes
unusable.
Use module-level empty-array constants so the default identity stays
stable across renders.
* fix(chat): short-circuit ResizeObserver update when bounds unchanged
The resize observer always called `setRevision(r => r + 1)` from its
callback, even when `clientWidth`/`clientHeight` were identical to the
previous reading. Any spurious notification — sub-pixel layout jitter
during mount, or an ancestor reflow triggered by an unrelated state
update — then fed back into the same render cycle and could exceed
React's update-depth limit.
Guard the state bump by comparing against the previous bounds, and
leave `setBoundsReady(true)` outside the guard since it's idempotent.
Following #1307, the Docker self-host stack defaults to APP_ENV=production,
which disables the 888888 master verification code on auth.go:169. The
installer banners and self-hosting docs still told operators to log in with
888888, leaving them stuck.
Update install.sh, install.ps1, SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md,
and self-hosting.mdx to document the three login paths: configure
RESEND_API_KEY (recommended), set APP_ENV=development to enable 888888 for
private evaluation, or read the dev verification code from backend container
logs. Also warn against enabling APP_ENV=development on public instances.
* fix(agent/codex): route custom_args -m/--model to thread/start payload
Codex agents spawn via `codex app-server --listen stdio://`, which does
not accept `-m` / `--model` (those belong to the normal Codex CLI). When
a user's custom_args still carried those tokens the process exited
before the JSON-RPC initialize handshake with `codex process exited`,
with no actionable error.
Extract `-m <v>`, `--model <v>`, and `--model=<v>` from opts.CustomArgs
before invoking app-server and promote the value into opts.Model, so
that startOrResumeThread can pass it through the `thread/start` payload
where Codex actually reads the field.
Fixes#1308.
* fix(ui/agents): drop Codex-incompatible --model example from custom args tab
The helper text and placeholder suggested `--model claude-sonnet-4-…` as
a custom CLI argument, which is valid for Claude but crashes Codex
agents (its `app-server` subcommand does not accept model flags). Swap
in provider-agnostic copy so the UI no longer steers users into an
invalid configuration for non-Claude runtimes.
Refs #1308.
* revert "fix(agent/codex): route custom_args -m/--model to thread/start payload"
This reverts f18355b2. After review, extracting `-m`/`--model` out of
opts.CustomArgs and promoting them into the thread/start payload is the
wrong shape of fix: agent CLIs have many flags their non-interactive
modes don't accept, and hand-translating a subset case-by-case doesn't
scale — it pushes us toward an ever-growing list of per-backend arg
rewriters.
The preferred direction is to teach users via the UI what command their
custom_args extend (see the launch_header preview in #1312) and let
bad configurations fail loudly. If the resulting error is hard to read
that's a separate improvement we should make on the failure path, not
by silently rewriting user input.
Refs #1308.
* feat(agent): add LaunchHeader per agent type
Each backend in server/pkg/agent/ hardcodes a stable command skeleton
(e.g. `codex app-server --listen stdio://`, `hermes acp`) before
appending opts.CustomArgs. Surfacing that skeleton lets the UI tell
users which command their custom_args are being appended to, so a
Codex user doesn't mistakenly add `-m gpt-5.4-mini` expecting it to
reach the CLI when the subcommand is actually `app-server`.
Expose only the minimum that aids judgment — binary + subcommand, or a
short mode label when there is no subcommand — and deliberately omit
transport values, internal flags, and env to keep the surface small
and renaming-safe.
Refs #1308.
* feat(handler/runtime): surface launch_header on runtime response
runtimeToResponse now derives launch_header from agent.LaunchHeader,
piggybacking on the runtime's existing provider field so the
frontend's RuntimeDevice gains the skeleton without a new endpoint or
DB query. Client gets the header for free whenever it lists agents'
runtimes — which the custom-args tab already does.
Refs #1308.
* feat(ui/agents): show launch mode preview in custom args tab
Thread the resolved RuntimeDevice from AgentDetail into CustomArgsTab
and render its launch_header as a one-line preview above the args
list, so users see `codex app-server <your args>` (or equivalent per
provider) and can tell whether a CLI-style flag like `--model` will
actually reach the invoked subcommand. Source of truth stays in the
Go backend; the TS type just carries the string.
Refs #1308.
* refactor(auth): add sanitizeNextUrl helper in @multica/core/auth
Extracts a reusable helper that returns a post-login redirect URL only
when it's a safe single-slash relative path, and null otherwise. Rejects
absolute URLs, protocol-relative URLs, backslashes, and control
characters so call sites can safely pass the result to router.push().
Keeping the rule in a single helper (with direct unit tests) avoids
each consumer re-implementing the validation and drifting.
* fix(auth): validate next= redirect target to prevent open redirect
Closes#1116
Next.js router.push accepts absolute URLs, so a crafted
`/login?next=https://evil.example` would send the user off-origin
after a successful login. The Google OAuth callback has the same
vector via the `state=next:<url>` payload.
Sanitize both entry points through `sanitizeNextUrl` from
`@multica/core/auth` so only safe single-slash relative paths survive;
null results fall through to the existing workspace-list-based default
without any hard-coded path.
---------
Co-authored-by: JunghwanNA <70629228+shaun0927@users.noreply.github.com>
* fix(comment): assignee on_comment path should use reply id, not thread root
Symmetric fix to #871 — that PR fixed the @mention path but missed the
assignee on_comment path in the same file. Replies on agent-assigned
issues were still getting trigger_comment_id = parent_id, so the daemon
fed the parent comment's content to the resumed claude session, which
then either exited with 'Already replied to comment <parent>' or silently
misrouted its answer depending on model / session state.
Reply placement (flat-thread grouping) is already decoupled from
trigger_comment_id by TaskService.createAgentComment's parent
normalization (added alongside #871), so passing comment.ID directly is
safe and matches the mention path's post-#871 behavior.
Fixes#1301
Made-with: Cursor
* test(comment): assert assignee on_comment records reply id as trigger_comment_id
Integration regression guard for #1301. Asserts that after a member posts
a reply under an agent-authored thread, the enqueued agent task's
trigger_comment_id matches the new reply, not the thread root. Without
the companion fix in comment.go the old parent-override would store the
root id and the daemon would feed stale content (via prompt.go
BuildPrompt) to the agent.
Made-with: Cursor
---------
Co-authored-by: fuxiao <fuxiao@zyql.com>
Agent mentions enqueue a new task; member mentions send a notification.
Without this warning, agents have used `[@Name](mention://agent/<id>)` in
prose (e.g. "GPT-Boy is correct") and accidentally re-triggered the agent.
Adds a caveat under `## Mentions` in the prompt injected into agent
runtimes, plus tightens the Agent bullet to make the side-effect explicit.
The autopilot detail page mapped `status: "running"` to a `Loader2` icon
but rendered it without `animate-spin`, so a manually-triggered run sat
on a static circle until the row flipped to completed/failed and the
user got no visual feedback that anything was happening.
Add an optional `spin: true` flag to the run-status config and apply
`animate-spin` when set. Only the running entry is marked.
When --resume targets a dead session, claude prints
"No conversation found with session ID: ..." to stderr, emits a stream-json
system init with a fresh session_id, then exits with code 1. The backend
was treating that fresh id as the authoritative session, so
daemon.go's retry-with-fresh-session fallback (SessionID == "" guard)
never triggered. Every subsequent task for the same (issue, agent) pair
stayed permanently broken until the server-side session_id was cleared by
hand.
Fix: when --resume was requested but the emitted session_id differs AND
the run failed, drop the fresh id from Result so the daemon's existing
fallback can do its job. Factored into a pure helper and unit-tested.
Fixes#1284
Co-authored-by: fuxiao <fuxiao@zyql.com>
* fix(agent): add per-agent mcp_config field to restore MCP access
Closes#1111
The --strict-mcp-config flag was added defensively in #592 to prevent
Claude agents from inheriting MCP state from the outer Claude Code session.
It was meant to be paired with --mcp-config <path> to inject a controlled
set of MCPs, but that path was never implemented, which silently stripped
all user-scope MCPs from spawned agents.
This PR completes the original design by:
- Adding a nullable mcp_config jsonb column to the agents table
- Wiring mcp_config through AgentResponse, Create/Update requests
- Piping it into ExecOptions.McpConfig in the daemon
- Serializing to a temp file and passing --mcp-config <path> in buildClaudeArgs
- Blocklisting --mcp-config in claudeBlockedArgs to prevent override
via custom_args
Does not touch Codex provider (tracked separately in #674).
Does not implement Multica MCP auto-injection (out of scope).
* fix: disambiguate JSON null vs absent for mcp_config
The release workflow previously triggered on 'v*', which matched a
stray 'v0.2.5-dirty' tag pushed to the repository. GoReleaser ran
again and overwrote the Homebrew formula with a 0.2.5-dirty version
whose tarball URLs 404.
Tighten the trigger to semver-shaped tags and add an explicit guard
that fails the job if the tag name contains '-dirty' (which can come
from 'git describe --tags --dirty').
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Docs site is no longer auto-deployed via Vercel (disabled in
dashboard), so building it on every PR adds friction without
catching anything actionable. Use turbo's negative filter to
skip @multica/docs across all three tasks.
Self-hosted services (postgres, backend, frontend) should restart
automatically on failure or host reboot. This is standard practice
for production docker-compose deployments.
Co-authored-by: Zhazha <zhazha@openclaw.internal>
- Mark AppLink draggable={false} and add pointer-events-none while
dragging, so the browser's native <a> drag (which otherwise navigates
to the pin's href on mouse release) is suppressed.
- Introduce a component-local pinnedItems snapshot gated by an
isDraggingRef, so a mid-drag TQ cache write (optimistic or WS
refetch) cannot reorder the DOM under dnd-kit's drop animation.
Mirrors the pattern already used by board-view.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub Copilot CLI scans project-level skills from .github/skills/<name>/SKILL.md
(per the official cli-config-dir-reference docs), not from .agent_context/skills/.
Previously, skills injected for the copilot provider were placed under
.agent_context/skills/ and only referenced by name in AGENTS.md, meaning
Copilot would not actually pick them up.
- resolveSkillsDir: add a dedicated copilot case writing to .github/skills/
- Update doc comments in context.go and runtime_config.go
- Add TestWriteContextFilesCopilotNativeSkills covering the new path and
ensuring .agent_context/skills/ is not created for copilot
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
electron-builder 26.8.1 rejects publishingType under the GitHub publisher;
the correct option for selecting draft/prerelease/release is releaseType.
Using publishingType caused schema validation to fail during packaging.
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agents): make issue tasks easier to open from agent details
Make task rows in the Tasks tab navigate directly to the related issue
detail page when issue data is available, using AppLink for cross-platform
compatibility. Rows without resolved issue data remain non-clickable.
Adds a subtle hover shadow to make the interactive area more discoverable.
Closes#1129
* fix(agents): use workspace issue paths in tasks tab
* test(agents): cover tasks tab issue links
* feat(docs): mount docs site at /docs subpath via basePath + multi-zone
Configure the Fumadocs site so it can be served at multica.ai/docs:
- Add basePath: '/docs' to apps/docs/next.config.mjs
- Flatten routes: drop standalone home, render content/docs/index.mdx at
the root, move catch-all from app/docs/[[...slug]] to app/[...slug]
- Wrap children with DocsLayout in the root layout (was a separate
segment-level layout under app/docs/)
- Set source loader baseUrl to '/' so URL slugs no longer carry the
basePath (Next.js prepends it automatically)
- Strip the now-redundant '/docs/' prefix from internal MDX links and
drop the duplicate "Documentation" nav entry
- Add app/not-found.tsx for App Router 404 handling
Wire up multi-zone routing so apps/web proxies /docs/* to the docs app:
- Add DOCS_URL env (default http://localhost:4000) and rewrites for
/docs and /docs/:path* in apps/web/next.config.ts
- Whitelist DOCS_URL in turbo.json globalEnv
* fix(web): move /docs rewrite to beforeFiles so [workspaceSlug] doesn't shadow it
The /docs rewrite was running in the default afterFiles slot, which is
evaluated *after* file-system routing. apps/web/app/[workspaceSlug]/
matched /docs first as a workspace named "docs" (which doesn't exist) and
returned 404 before the rewrite to the docs Vercel project ever fired.
Splitting rewrites into beforeFiles/afterFiles puts /docs and
/docs/:path* ahead of route resolution so they always proxy to the docs
zone.
* feat(cli): add `issue subscriber` commands
Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.
* fix(issues): default subscribe target to resolveActor, not X-User-ID
When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.
Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
Configure the Fumadocs site so it can be served at multica.ai/docs:
- Add basePath: '/docs' to apps/docs/next.config.mjs
- Flatten routes: drop standalone home, render content/docs/index.mdx at
the root, move catch-all from app/docs/[[...slug]] to app/[...slug]
- Wrap children with DocsLayout in the root layout (was a separate
segment-level layout under app/docs/)
- Set source loader baseUrl to '/' so URL slugs no longer carry the
basePath (Next.js prepends it automatically)
- Strip the now-redundant '/docs/' prefix from internal MDX links and
drop the duplicate "Documentation" nav entry
- Add app/not-found.tsx for App Router 404 handling
Wire up multi-zone routing so apps/web proxies /docs/* to the docs app:
- Add DOCS_URL env (default http://localhost:4000) and rewrites for
/docs and /docs/:path* in apps/web/next.config.ts
- Whitelist DOCS_URL in turbo.json globalEnv
Before this PR, `EnsureDaemonID(profile)` wrote to ~/.multica/profiles/
<profile>/daemon.id — meaning the same physical machine minted a different
UUID per profile. On any host running both the CLI-spawned daemon (default
profile) and the desktop-spawned daemon (profile derived from API host),
that produced two runtime rows per provider per workspace. The server-side
`legacy_daemon_ids` merge only covers hostname variants, not UUIDs, so the
rows just piled up.
Profile boundaries are about which backend/account the daemon is talking
to, not about the physical machine. Identity should be per-machine, token
should be per-profile.
Changes:
- `EnsureDaemonID` now always reads/writes ~/.multica/daemon.id regardless
of the `profile` argument. The argument is retained for migration-only
use (see promotion below).
- Migration path: when the canonical file is missing and the requested
profile has a pre-change per-profile daemon.id, promote that UUID in
place so a user who only ever ran under a named profile keeps the same
identity instead of minting a fresh UUID and round-tripping a merge.
- New `LegacyDaemonUUIDs()` scans ~/.multica/profiles/*/daemon.id and
returns every UUID that survives parsing. `config.go` now appends those
to the daemon's `legacy_daemon_ids` payload, so any runtime rows
previously registered under a per-profile UUID (on any backend) get
merged into the canonical machine UUID at register time.
Tests replace the `ProfileIsolated` assertion with `SharedAcrossProfiles`
and add coverage for promotion, UUID scanning (including skipping corrupt
files), and the empty-profiles-dir fast path.
Adds two new toggleable card properties that surface issue context at a glance:
- Project: shows the parent project icon + title when the issue belongs to one.
- Sub-issue progress: gates the existing progress ring behind a card property
so users can hide it when not useful.
Both default to on; toggled via the existing "Display" popover.
* feat(daemon): persistent UUID identity + legacy-id merge at register-time
daemon_id is now a stable UUID persisted to `<profile-dir>/daemon.id` on
first start, replacing the hostname-derived id that drifted whenever
`.local` appeared/disappeared, a system was renamed, or a profile
switched — each of which used to mint a fresh `agent_runtime` row and
strand agents on the old one.
To migrate existing installs without operator intervention, the daemon
reports every legacy id it may have registered under previously
(`host`, `host` with `.local` stripped, and `host[-profile]` variants
for both). At register-time the server looks up each candidate row
scoped to (workspace, provider), re-points its agents and tasks onto
the new UUID-keyed row, records which legacy id was subsumed in the
new `legacy_daemon_id` column for audit, and deletes the stale row.
Result: users running `xxx.local`-keyed runtimes today transparently
land on the new UUID row on next daemon restart.
The hostname-prefix `MigrateAgentsToRuntime` / `daemon_id LIKE '...-%'`
compatibility shim is no longer needed and has been removed along with
the handler call that invoked it.
* fix(daemon): handle bidirectional .local drift and case drift in legacy merge
Review on #1220 flagged two gaps in the legacy-id migration candidate set:
1. Reverse .local: LegacyDaemonIDs only added the stripped variant when the
current hostname ended in `.local`. The opposite direction — DB has
`foo.local`, current host is `foo` — was missed, so runtimes registered
under the `.local` variant stayed orphaned after upgrade. Now both
variants (`foo` and `foo.local`) are always emitted, regardless of what
`os.Hostname()` currently returns, plus their `-<profile>` suffix forms.
2. Case drift: os.Hostname() has been observed returning different casings
on the same machine across mDNS/reboot state. A case-sensitive `=`
comparison stranded rows like `Jiayuans-MacBook-Pro.local` when the
daemon later reported `jiayuans-macbook-pro.local`. FindLegacyRuntimeByDaemonID
now uses `LOWER(daemon_id) = LOWER(@daemon_id)` on both sides, so casing
differences merge rather than orphan. The (workspace_id, provider) prefix
still bounds the scan to a tiny set of rows so the non-indexed LOWER()
comparison has negligible cost.
Tests: TestLegacyDaemonIDs gets the mixed-case + reverse-direction cases;
daemon_test.go adds TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal
and TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift.
* fix(daemon): consolidate every case-duplicate legacy runtime, not just the first
Follow-up review on #1220: after switching to `LOWER(daemon_id) =
LOWER(@daemon_id)`, the single-row lookup still only merged one legacy
row per candidate. If a machine already had two rows in the DB that
differed only in casing (e.g. `Jiayuans-MacBook-Pro.local` AND
`jiayuans-macbook-pro.local` coexisting because earlier hostname drift
already minted a duplicate), only one of them got consolidated and the
other stayed orphaned — violating the "no duplicate runtime per machine
after backfill" acceptance.
- FindLegacyRuntimeByDaemonID → FindLegacyRuntimesByDaemonID (:many)
- mergeLegacyRuntimes iterates every returned row and dedupes across
overlapping legacy candidates so `foo` and `foo.local` both resolving
to the same stored row don't double-process
Test: TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes seeds two
case-duplicate rows with one agent each and confirms both rows are
deleted and both agents end up on the new UUID-keyed row.
These trigger kinds exist in the DB schema but nothing on the server
fires them:
- autopilot_scheduler.ClaimDueScheduleTriggers filters kind='schedule'
(pkg/db/queries/autopilot.sql:150)
- DispatchAutopilot is reached only from the scheduler (source:schedule)
or POST /api/autopilots/{id}/trigger (source:manual); no inbound
webhook or api endpoint exists
- The UI only surfaces schedule creation
Exposing them in the CLI lets users create triggers that sit in the DB
doing nothing. Drop --kind from trigger-add, require --cron, always
send kind=schedule. Re-add the flag when the server grows a dispatch
path for the other kinds.
Follow-up to #1249. Two small follow-ups requested in review:
1. `resolveTaskWorkspaceID` was duplicated between `handler/daemon.go` and
`service/task.go`. #1249 fixed the handler copy but left both in place,
meaning any future branch (e.g. a fourth task link type) still needs
to be added in two files. Promote the service method to the exported
`TaskService.ResolveTaskWorkspaceID` and delete the handler copy.
Handler's `requireDaemonTaskAccess` and `ListTaskMessagesByUser` now
call through `h.TaskService`.
2. Add a regression test `TestStartTask_AutopilotRunOnlyTask_ResolvesWorkspace`
covering the exact scenario from #1224: a task linked only via
`AutopilotRunID` must resolve to the autopilot's workspace. The test
asserts 404 for a cross-workspace daemon token and 200 (with status
transitioning to `running`) for the correct-workspace token.
Follow-up to #1192. Document the v2 protocol contract that the
dispatch-level threadId guard relies on, and lock down the two leakage
paths the guard closes:
- turn/completed from a subagent thread must not call onTurnDone
- item/completed (agentMessage, final_answer) from a subagent thread
must neither leak text into the output builder nor terminate the turn
Without these tests a future refactor that drops or relocates the guard
would not be caught by CI, since existing notification tests omit the
top-level threadId field and pass through unfiltered.
* feat(cli): add autopilot commands
Expose the existing autopilot REST API through the multica CLI so
users and agents can list, get, create, update, delete, trigger, and
inspect autopilots, plus manage their triggers (schedule/webhook/api).
Also surface the read + core write commands in the agent meta skill
prompt so agents discover them without needing --help.
- new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints
- add APIClient.PatchJSON (autopilot update uses PATCH)
- expose autopilot in CORE COMMANDS group
- extend runtime_config.go meta skill with autopilot entries
- document autopilot command group in CLI_AND_DAEMON.md
* fix(autopilot): address code review — restrict run_only, validate workspace on update
Code review caught two issues with the initial CLI PR:
1. run_only mode is broken end-to-end. The daemon-side
resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves
workspace from issue/chat, so run_only tasks (which have neither)
return 404 from /start. BuildPrompt() would also emit an empty issue
ID. The service-level resolver in internal/service/task.go already
handles AutopilotRunID, but the daemon endpoint uses the handler
copy. Fixing that path is out of scope for the CLI PR; drop
run_only from the CLI and docs so we don't recommend a mode that
cannot complete. Server continues to accept it for the existing UI.
2. UpdateAutopilot did not verify that a new assignee_id belongs to
the workspace, unlike CreateAutopilot. This let a PATCH swap in an
agent from a different workspace. Mirror the same
GetAgentInWorkspace check.
The route-level loading.tsx creates a Suspense boundary that shows a
generic skeleton on every page navigation within the dashboard. Since
every page already handles its own data-loading skeleton via TanStack
Query isLoading, this causes two sequential skeleton flashes:
loading.tsx skeleton → page skeleton → content.
Removing it makes the old page stay visible during route transitions
(typically <100ms), then the new page renders directly with its own
skeleton — a single, smooth transition.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(daemon): filter thread/status/changed by threadId to prevent subagent interference
When Codex CLI has memories enabled, the app-server spawns a memory
consolidation subagent as a separate thread within the same stdio
connection. When that subagent thread finishes and transitions to idle,
the daemon's codex backend mistakenly interprets the idle signal as the
main turn completing, causing it to close stdin and cancel the context
before the real turn produces any output.
Add a threadId check to the thread/status/changed handler so only
status changes from the tracked thread trigger turn completion. Signals
from subagent threads (threadId != c.threadID) are now ignored.
Fixes#1181
* fix(codex): dispatch-level threadId filter for subagent notifications
Codex multiplexes subagent threads (e.g. memory consolidation) on
the same stdio pipe. Previously only thread/status/changed had a
threadId guard, but item/completed (agentMessage + final_answer),
turn/completed, and turn/started from subagent threads could still
trigger onTurnDone or contaminate output.
Move the threadId check to the top of handleRawNotification so all
notification handlers are protected. Remove the now-redundant
per-handler check on thread/status/changed.
Fixesmultica-ai/multica#1181
---------
Co-authored-by: fuxiao <fuxiao@zyql.com>
resolveTaskWorkspaceID only handled tasks linked via IssueID or
ChatSessionID. Tasks created by run_only autopilots (introduced in
#1028) have only AutopilotRunID set, so the resolver returned an empty
workspace ID, causing requireDaemonTaskAccess to respond with 404.
Add an AutopilotRunID branch that looks up the autopilot run, then
its parent autopilot, to obtain the workspace ID.
The project CRUD commands (list, get, create, update, delete, status)
and the `--project` flag on issue commands have been implemented in
the CLI but were not yet documented. Add them to both the docs site
reference and the repo-level CLI_AND_DAEMON.md so the feature is
discoverable.
Closes MUL-867
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make the http/https scheme allowlist structurally enforced instead of a
convention. Move the allowlist check + shell.openExternal call into a
single openExternalSafely wrapper in external-url.ts, have both main-process
call sites (the IPC handler and setWindowOpenHandler) go through it, and
add an ESLint no-restricted-syntax rule that bans direct shell.openExternal
usage anywhere under apps/desktop/src/main/ except external-url.ts itself.
This is the follow-up to #1124: same safety guarantee, but a reviewer can
no longer accidentally reintroduce a bare shell.openExternal somewhere that
bypasses the check — the lint rule catches it at CI time. Also restores
the scheme info in the warn log (lost when the helper was extracted).
Test coverage extended to the cases the original PR review flagged but
didn't ship: casing (FILE:// / HTTPS://), javascript: / data:, ftp / smb,
vscode:// / ms-msdt:, mailto / tel, credentials-in-URL, empty / malformed.
Added two openExternalSafely tests (electron mocked) confirming allowed
URLs forward and rejected URLs do not.
Closes a follow-up bullet from the internal #1115 / #1124 review.
* fix(desktop): restrict shell.openExternal to http/https schemes
The Electron main-process IPC handler for shell:openExternal called
shell.openExternal with whatever string the renderer passed, with no
scheme validation. Under this app's intentional webSecurity: false and
sandbox: false configuration (#648), any unsafe content path in the
renderer reaching this IPC becomes a way to dispatch arbitrary OS
protocol handlers — file://, smb://, vscode://, Windows ms-msdt:,
and so on.
Parse the URL and reject anything outside http/https (the only schemes
any legitimate call site uses today). Matches the Electron security
checklist guidance for openExternal on non-isolated renderers.
Closes#1115
* Close the desktop external-open gap on target=_blank links
The original fix validated only the IPC path, but the renderer could still trigger shell.openExternal through setWindowOpenHandler for target="_blank" links and window.open(). This change reuses one allowlist helper for both sinks and adds a focused unit test for the helper contract.
Constraint: Desktop shell.openExternal must stay limited to http/https despite webSecurity=false and sandbox=false
Rejected: Duplicate URL validation logic in each sink | easy to drift and harder to test
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep all desktop external-open paths on the same validator so new sinks do not bypass the allowlist
Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop test -- src/main/external-url.test.ts
Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop typecheck
Not-tested: Full desktop app manual smoke run
Related: #1115
---------
Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
* fix(daemon): platform-aware Codex sandbox config to unbreak macOS network
On macOS, Codex's Seatbelt sandbox in workspace-write mode silently
ignores '[sandbox_workspace_write] network_access = true' (see
openai/codex#10390). That blocks DNS inside the sandbox, so 'multica
issue get' and other CLI calls fail with 'dial tcp: lookup ...: no such
host' — this is what caused MUL-963.
Changes:
- New server/internal/daemon/execenv/codex_sandbox.go: picks a sandbox
policy based on runtime.GOOS and the detected Codex CLI version.
Non-darwin or darwin with a known-fixed version keeps workspace-write
+ network_access=true; older darwin falls back to danger-full-access
and logs a warn with upgrade hint. The fix-version threshold is a
single constant (CodexDarwinNetworkAccessFixedVersion) so it's easy
to bump once upstream ships.
- Per-task config.toml now gets a 'multica-managed' marker block
(BEGIN/END comments) rewritten idempotently; user-owned keys outside
the markers are preserved. Legacy inline sandbox directives from
earlier daemon versions are stripped on migration.
- execenv.PrepareParams gains CodexVersion; execenv.Reuse takes a
codexVersion arg; daemon.go caches detected versions at registration
and threads them through to Prepare/Reuse.
- Replaces the old ensureCodexNetworkAccess tests with
platform-parameterised coverage (linux vs darwin, idempotency,
legacy-migration, policy matrix).
- docs/codex-sandbox-troubleshooting.md: symptom fingerprint table,
decision matrix, self-check commands, trade-offs.
Refs: MUL-963
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(daemon): hoist managed sandbox block above user tables (MUL-963)
Review on #1246 flagged that upsertMulticaManagedBlock appended the
managed block to EOF. If the user's config.toml ends inside a TOML table
(e.g. [permissions.multica] or [profiles.foo]), a trailing bare
sandbox_mode = "..." is parsed as a key of that preceding table, so
Codex silently ignores the policy the daemon meant to apply.
Two changes make the block position-independent:
- renderMulticaManagedBlock now emits only top-level key=value lines and
uses TOML dotted-key form (sandbox_workspace_write.network_access =
true) instead of opening a [sandbox_workspace_write] header. The block
therefore neither inherits from nor leaks into any surrounding table.
- upsertMulticaManagedBlock always hoists the block to the top of the
file (stripping any previously written managed block first), so the
sandbox_mode line is always at the TOML root regardless of what the
user put below it. This also migrates configs written by the original
PR #1246 logic where the block was trapped behind a user table.
Added tests for the regression scenario (pre-existing [permissions.*]
table) and the legacy-trailing-block migration; updated the existing
Linux default test and the troubleshooting runbook to reflect the
dotted-key form.
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>
Autopilot was formatting the triggered-at timestamp with time.RFC3339
(e.g. "2026-04-16T14:54:32Z"), which is hard to read and confusing for
users in non-UTC timezones because the "Z" suffix looks like an error
instead of a timezone indicator.
Switch to a human-readable format ("2026-04-16 14:54 UTC") so only the
hour differs from local time; minutes match across timezones, making
the value easy to reconcile at a glance.
Fixesmultica-ai/multica#1197.
Shared inbox links (?issue=<id>) pointed to notifications that may no
longer exist in the current user's inbox (archived, or received by
someone else). The detail pane would fall back to an empty state and
leave the user stuck.
After inbox loads, if the selected key has no matching item, replace
the URL with /issues/<id> so the link still resolves to something
meaningful.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
crypto.randomUUID() is only defined in secure contexts, so self-hosted
HTTP deployments were throwing TypeError on mount and when clicking Add.
Route the id generation through the existing createSafeId() helper so
the tab works in non-secure contexts too.
Fixes#1214
- Migration 046 adds UNIQUE(workspace_id, name) with dedup (keep most recently updated)
- CreateAgent handler returns 409 Conflict scoped to constraint name agent_workspace_name_unique
- Dedup verified as (0 rows) against worktree DB; rerun against staging/production before applying
- Down migration drops the constraint only; deleted rows and cascaded data are not restored
Co-authored-by: Anup Joy <joyanup@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Desktop-launched daemons have their CLI binary overwritten by the Desktop
app on every launch, so any in-app update is reset. The detail panel already
renders 'Managed by Desktop' and hides the Update button when
metadata.launched_by === 'desktop', but the sidebar red dot
(useMyRuntimesNeedUpdate) and the list arrow (useUpdatableRuntimeIds) still
flagged them because runtimeNeedsUpdate() only considered mode/owner/version.
Short-circuit runtimeNeedsUpdate() on launched_by === 'desktop' so all three
surfaces (sidebar dot, list arrow, detail panel) agree and defer CLI
upgrades to the Desktop auto-updater.
Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Our CLI release flow pre-creates a *published* GitHub Release via
`gh release create`. electron-builder's default `publishingType: draft`
conflicts with `existingType=release` and causes the DMG/ZIP/blockmaps/
latest-mac.yml uploads to be silently skipped, which breaks
electron-updater auto-update on installed clients (observed on v0.2.4,
had to fall back to `gh release upload` manually).
Explicitly setting `publishingType: release` aligns electron-builder
with our release flow so desktop artifacts are uploaded to the existing
published release automatically.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(desktop): tabs are per-workspace, not cross-workspace
Tabs are now grouped by workspace in the store; the TabBar shows only the
active workspace's tabs, and switching workspace swaps the visible group.
Before this change tabs were a flat list that spanned workspaces, which
produced a confusing experience: working in acme with three tabs, then
switching to butter and back, still showed whatever tabs you happened to
open while you were in butter alongside your acme work.
The bug had the same shape as the pre-workspace-overlay bug we fixed in
#1237 — a concept ("workspace") was encoded in data (tab paths) but
ignored by the UI that displayed it (TabBar). The fix is structural:
make the data model match the concept.
Key changes:
- **Schema**: `{ activeWorkspaceSlug, byWorkspace: {slug: {tabs, activeTabId}} }`.
The invariant "every tab belongs to a workspace group" is enforced at
sanitize time and at migration time; there is no longer a root `/`
sentinel.
- **NavigationAdapter** detects cross-workspace pushes and delegates to
`switchWorkspace(slug, path)` instead of navigating the active tab's
router. All existing call sites in shared code (sidebar dropdown,
settings post-delete redirect, invite-accept, cmd+k) keep calling
`push(paths.workspace(x).issues())` unchanged.
- **TabContent** renders only the active workspace's tabs under Activity.
Cross-workspace state preservation is an explicit non-goal — switching
workspaces should feel like switching.
- **WorkspaceRouteLayout** auto-heal no longer navigates the tab router
to `/`. Stale-slug cleanup is a store-level op (`validateWorkspaceSlugs`)
that drops the whole stale group in one go.
- **App.tsx** bootstrap seeds `activeWorkspaceSlug` when null and the
user has workspaces; the new-workspace overlay opens/closes based on
workspace count independently of any route.
- **Persistence migration** (v1 → v2) groups old flat tabs by extracted
slug, drops root / transition / reserved-slug tabs, and picks an
active workspace from the old active tab's owning group. No data
loss for existing users with workspace-scoped tabs.
Web is unchanged — tabs are a desktop-only concept. `packages/views`,
`packages/core`, `apps/web` are all untouched. `setCurrentWorkspace`
in core remains the single source of truth for the API client's
workspace header, driven by `WorkspaceRouteLayout` as before.
Tests: 19 tab-store tests (sanitize, migration, switchWorkspace,
validate, close-last-reseeds, reset). 38 desktop tests total pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: stable selectors + defensive guards on tab-store
Addresses self-review findings on #1239.
**C1 — perf cliff from unstable selector returns.** The previous
`useActiveTab()` selector used `.find()` inside, so every router tick
on the active tab (which replaces the Tab object via immutable spread
in updateTab / updateTabHistory) forced every subscriber to re-render.
Replaced with finer-grained selectors:
- `useActiveTabIdentity()` — { slug, tabId } primitives (stable across
unrelated updates).
- `useActiveTabRouter()` — stable object reference for a tab's lifetime.
- `useActiveTabHistory()` — { historyIndex, historyLength } numbers.
`useTabHistory` and `DesktopNavigationProvider` now consume the
primitive selectors, so back/forward buttons don't churn on every
path change. A non-hook `getActiveTab(state)` helper covers the
event-handler case.
**I1 — `switchWorkspace` no-ops on empty slug.** Defensive guard in
case a malformed path ever reaches the adapter's detector.
**I2 — merge warns on path/slug mismatch.** Previously silent drop;
now `console.warn` makes the condition visible during debugging.
**Misc — TabRouterInner takes `tab` prop directly.** Passing the Tab
object eliminates a redundant store read per rendered tab.
Known follow-up (not this PR): `packages/core/realtime/use-realtime-sync.ts`
still uses `window.location.assign` for workspace-deleted eviction —
that's a full renderer reload on desktop, which post-refactor wastes
the careful in-memory tab state we just set up. Fixing cleanly requires
a navigation-callback injection pattern through CoreProvider, which is
cross-cutting and deserves its own PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workspace): navigate away BEFORE leave/delete mutation to avoid CancelledError
Symptom: deleting the current workspace logged "current workspace
deleted, switching" from the realtime handler and surfaced an
"Uncaught (in promise) CancelledError" from TanStack Query's
refetchQueries batch.
Root cause: a three-way race between the mutation's own
invalidateQueries(workspaceKeys.list()), the settings page's
navigateAwayFromCurrentWorkspace() fetchQuery, and the realtime
workspace:deleted handler's relocateAfterWorkspaceLoss fetchQuery.
All three refetched the same query concurrently; TanStack Query
cancelled the in-flight loser(s), and the rejection bubbled out of
invalidateQueries as an unhandled promise rejection.
Fix: invert the order. Compute the destination from the current
cached workspace list, navigate immediately, *then* fire the
mutation. By the time the backend fires workspace:deleted, the
active workspace is already something else — the realtime handler's
"current === deleted" check fails and its relocate branch no-ops.
Only one refetch happens (the mutation's onSettled), no race, no
cancellation.
navigateAwayFromCurrentWorkspace no longer needs async/fetchQuery
since it reads from cache and returns before the mutation fires.
Applies to both Leave and Delete flows. Both web and desktop benefit
since the code is in packages/views.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(desktop): clear workspace singleton + flex drag strip + defer seeding
Three issues that the last round of delete-workspace fixes missed.
**1. `setCurrentWorkspace` singleton leaks after delete.** Navigating
before the mutation (prior fix) changed the URL but nothing cleared
the core platform's currentSlug/currentWsId singleton. Three
downstream consumers still believed the deleted workspace was active:
- `useRealtimeSync`'s `workspace:deleted` handler: its
`getCurrentWsId() === deleted` check fired, triggering a parallel
relocate that raced the mutation's invalidate and the settings
page's navigate — CancelledError + `window.location.assign`
(white screen reload).
- Chrome gating: `{slug && <AppSidebar />}` stayed truthy, the
sidebar mounted, and `useWorkspaceId` inside it threw because the
workspace was gone from the list cache.
- API client's `X-Workspace-Slug` header: stale on the next call.
Fix: `navigateAwayFromCurrentWorkspace` now calls
`setCurrentWorkspace(null, null)` before pushing. The next
workspace's `WorkspaceRouteLayout` re-sets the singleton when it
mounts; for the last-workspace case, null is the correct state
(overlay has no workspace context).
Same family as the previous logout bug: persist only writes to
storage, reset on logout must also wipe in-memory state. Here the
singleton is another in-memory bit that survives a URL change if
we don't explicitly clear it.
**2. "Cannot update a component while rendering" warning.** The
per-workspace-tabs refactor kept the validate+seed call in render
phase (matching the pre-refactor pattern). It worked before because
`validateWorkspaceSlugs` is idempotent; the new `switchWorkspace`
seed is not, and triggers a TabBar re-render during AppContent's
render. Moved to `useLayoutEffect` — synchronously after render,
before paint, no flicker.
**3. Welcome-screen drag region didn't work on desktop.** The
absolute-positioned `h-10 z-10` drag strip relied on z-index stacking
to beat the content wrapper's no-drag for hit-testing, which wasn't
reliable for `-webkit-app-region` on the overlay. Replaced with a
flex child (`h-12 shrink-0` at top of the overlay's flex-col), so
the drag region owns its own layout space — any pixel in the top 48
is unambiguously drag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(CLAUDE): desktop-specific rules — routing, singleton, drag, UX split
Codifies the lessons from the recent desktop refactor series
(#1237, #1238, #1239) so future work doesn't re-derive them from
bugs. Covers:
- **Route categories** (session / transition / error) — explains why
`/workspaces/new` and `/invite/:id` are overlay state, not routes,
on desktop; stale slugs auto-heal instead of rendering error pages.
- **`setCurrentWorkspace` singleton hygiene** — unmount doesn't
clear it; any code leaving workspace context must call
`setCurrentWorkspace(null, null)` explicitly.
- **Workspace destructive operations ordering** — navigate first,
mutate after, to avoid the three-way refetch race that surfaces
as CancelledError + full-page reload.
- **Tab isolation** — tabs are grouped per workspace; cross-workspace
push is intercepted by the navigation adapter and translated into
switchWorkspace.
- **Drag region pattern** — flex child at top, not absolute overlay;
`-webkit-app-region` hit-testing is unreliable with z-index stacking.
- **UX vs platform chrome split** — UX affordances (Back, Log out,
welcome copy) in packages/views/; platform chrome (drag, immersive
mode, tab system) in desktop-only code.
Also patches the Cross-Platform Development Rules' rule #2 which
previously said "add a route in both apps" unconditionally — added
the exception for pre-workspace transition flows pointing at the
new Desktop-specific Rules section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): prevent duplicate image attachments showing as file cards
Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.
Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
where a sibling is already referenced inline — handles old data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(editor): rewrite bubble menu with @floating-ui/dom for reliable scroll hiding
Replace Tiptap's native <BubbleMenu> with custom @floating-ui/dom positioning.
The native plugin's virtual element lacked contextElement, so the hide middleware
could only detect viewport clipping — not nested scroll container clipping
(e.g. comment/reply inputs inside a scrollable page).
Key changes:
- contextElement on virtual reference enables hide middleware to detect ALL
overflow ancestor clipping, not just viewport bounds
- visibility:hidden (not display:none) keeps element measurable for
computePosition, fixing the comma-selection positioning bug
- autoUpdate monitors all scroll ancestors automatically via contextElement
- Remove: getScrollParent, scrollHiddenRef, manual scroll listener, scrollTarget
- Remove @tiptap/extension-bubble-menu dependency (no longer used)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(workspace): typed delete confirm + sole-owner leave preflight
Harden the Danger Zone on the Workspace settings tab.
**Delete workspace** now requires typing the workspace name exactly
(case-sensitive, no trimming) before the destructive button enables —
GitHub's repo-delete pattern. Deleting cascades into every issue,
agent, skill, and run under the workspace with no soft-delete, so the
friction is deliberate. Enter submits only when matched; the input
clears on close so reopening for a different workspace doesn't leak
the prior attempt.
**Leave workspace** now preflights the sole-owner case the backend
already blocks (server/internal/handler/workspace.go:569 — "workspace
must have at least one owner"). Previously the user clicked Confirm
and got an opaque 400 toast; now the Leave button is disabled upfront
with inline guidance that distinguishes:
- sole member: "Delete the workspace to leave."
- sole owner with other members: "Promote another member to owner
first, or delete the workspace."
Both changes live in packages/views/, so web and desktop get the same
Danger Zone treatment automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: gate Danger Zone on members fetched + reset typed input on rename
Addresses self-review findings on #1238:
- Previously the Danger Zone rendered immediately with `members = []`, so
the Delete workspace block (gated on `isOwner`, which is derived from
an empty members list) would flash in once the query settled. Gate the
whole section on `membersFetched` so it appears once with correct
controls.
- Reset `typed` on `workspaceName` change too — if another owner renames
the workspace while the dialog is open, the already-typed string stops
matching silently; resetting surfaces the mismatch.
- Added two tests: unicode/special-char names match literally; rename
mid-dialog clears the input.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously /workspaces/new and /invite/:id were tab routes on desktop.
That meant the TabBar rendered on top of flows that conceptually aren't
"places" the user sits at — creating a workspace or accepting an invite
is a one-shot transition, not a session. The mismatch also produced
several downstream bugs: tab state persisted these paths, the invite
deep link had no clean dispatch target, and NoAccessPage leaked TabBar
chrome when a workspace slug went stale.
Fix by recognising the underlying category mistake: on desktop, these
flows are application state, not routes. Move them to a window-level
overlay driven by a small Zustand store; the navigation adapter
intercepts pushes to the corresponding paths and routes them to the
overlay instead. Web keeps the routes (users need shareable URLs and
back-button semantics), so shared view components are reused as-is.
UX affordances (Back button when dismissable, Log out escape) live in
the shared NewWorkspacePage/InvitePage so both platforms render
identical content; the desktop overlay is now a thin platform shell
(drag strip + useImmersiveMode) that wraps the shared UX. Web wires
onBack based on whether the user has any workspaces.
Also addresses several related issues uncovered along the way:
- Logout now resets the in-memory tab + overlay stores (previously only
localStorage was cleared, so the next login inherited the prior
user's tabs).
- WorkspaceRouteLayout auto-heals a stale workspace slug by navigating
to "/" instead of rendering NoAccessPage — on desktop without a URL
bar, "no access" is always stale state, not a legitimate destination.
- IndexRedirect overlay lifecycle is bidirectional: opens when wsList
is empty, closes when it becomes non-empty (realtime workspace:added
would otherwise leave the overlay stuck open).
- tryRouteToOverlay resets the current tab to "/" when opening the
new-workspace overlay; otherwise workspace-scoped components under
the overlay continue to render and throw when the workspace they
reference disappears from the cache (reproducible by deleting the
last workspace from Settings).
- handleDeepLink now accepts multica://invite/<id>, IPC'd through to
the renderer and opened as an invite overlay. Email template still
links to https:// (unchanged), but the desktop dispatch path is now
wired for a future "open in desktop app" bridge.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.
Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
where a sibling is already referenced inline — handles old data
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The issue:created subscriber listener type-asserted payload["issue"] to
handler.IssueResponse, but autopilot publishes the issue as
map[string]any (via service.issueToMap). The assertion failed silently,
so no subscribers (including the creator) were ever added to autopilot
issues — meaning creators received no notifications when their
autopilot run produced comments or status changes.
Add an extractIssueFields helper that accepts either format and use it
in both the issue:created and issue:updated listeners. Mirrors the
dual-format pattern already used by the comment:created listener.
Replace the Pause/Activate button on the detail page with a Switch next
to the title, showing a colored status label. Flipping it toggles
between active and paused via the existing updateAutopilot mutation.
* refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority
- Drop GitHub button from hero CTAs (already in header) so the primary
Start / Download Desktop pair is the clear path.
- Split InstallCommand: outer is no longer a <button>, so text selection
no longer fights with copy. Mobile gets full-width with break-all;
desktop keeps the compact pill. Copy button has aria-label.
- Fix invalid `hover:bg-white/8` opacity to `hover:bg-white/[0.08]` so
the install pill's hover background actually renders.
- Add `flex-wrap` and gap-y to the "Works with" row so the label + 5
logos can stack on small screens instead of overflowing horizontally.
- Move `priority` from the decorative backdrop image onto the product
hero image (the actual LCP candidate) to stop background bytes from
starving the foreground.
* refactor(landing): remove install command from hero
Per design feedback, the install command pill is removed from the hero.
The download path now flows through the Download Desktop CTA only;
install instructions remain available in the docs and README.
Previously every registered Command (New Issue, New Project, three theme
switches, plus contextual Copy actions on issue pages) surfaced on empty
query, leaving only 3–5 rows for Recent in a 400px panel. Low-frequency
commands (theme, copy, New Project) are now revealed by typing, matching
the progressive-disclosure pattern already used for Pages and Switch
Workspace. Refs MUL-991.
* feat(search): add light/dark/system theme toggle actions to cmd+k
The command palette now surfaces an "Actions" section with theme toggle
items (Light / Dark / System), searchable via keywords like "theme",
"light", "dark", "appearance", or "mode". The active theme is marked
with a check icon.
* feat(search): add quick-win commands to cmd+k palette
Extends the command palette with a "Commands" group that consolidates
theme toggles plus four new actions:
- New Issue / New Project — trigger the global create modals
- Copy Issue Link / Copy Identifier (MUL-xxx) — only when the current
route is an issue detail page; mirrors the copy-link dropdown logic
from issue-detail
Adds a "Switch Workspace" group that lists the user's other workspaces
(filtered by name/slug, or by typing "workspace"/"switch") and
navigates to the selected workspace's issues page.
To make "New Project" work from anywhere, the inline CreateProjectDialog
on ProjectsPage is extracted into a global CreateProjectModal mounted
via the existing ModalRegistry + modal store (same pattern as
create-issue / create-workspace). The modal store type gains a
"create-project" variant.
* feat(search): show Commands by default so they're discoverable
Before, cmd+k actions (New Issue / New Project / Copy link / Copy ID /
theme toggles) only appeared when the user typed a matching keyword,
leaving them invisible unless the user already knew they existed.
Now the Commands group renders as soon as the palette opens (no query),
with the whole command list shown; typing narrows it down as before.
Also trims the redundant "⌘K to open this anytime" hint from the empty
state — the palette is already open.
Dev Electron uses a single userData path ("Multica Canary") derived from
the app name, which also locates the single-instance lock. Two worktrees
running dev simultaneously fight for that lock — the second `app.quit()`s
silently before opening a window.
DESKTOP_APP_SUFFIX appends to the app name + userData path so each
worktree can claim its own lock:
DESKTOP_APP_SUFFIX=foo → "Multica Canary foo"
Default (no env var) keeps behavior unchanged.
Complements the existing DESKTOP_RENDERER_PORT env from #1210 so a full
"run a second dev Electron" setup looks like:
DESKTOP_RENDERER_PORT=15173 DESKTOP_APP_SUFFIX=foo pnpm dev:desktop
Hooks recordVisit into useCreateIssue onSuccess so issues the user just
created appear in cmd+k's Recent section without requiring them to open
the issue first.
* feat(desktop): brand dev build as Multica Canary with bundled icon
pnpm dev:desktop ran under the stock Electron name and default icon,
making it indistinguishable from any other Electron dev app in the dock.
Set a Canary app name + userData path and point the macOS dock icon and
BrowserWindow icon at the bundled resources/icon.png so the dev build is
visually branded.
* feat(desktop): allow overriding renderer port via DESKTOP_RENDERER_PORT
Lets a second worktree run `pnpm dev:desktop` while a primary checkout
already holds the default Vite dev port 5173 — required to actually
exercise the "Multica Canary" branding in isolation.
* feat(desktop): rebrand Electron.app Info.plist so dev shows Multica Canary
app.setName() can't override the macOS menu bar title or Cmd+Tab label
— those come from CFBundleName baked into the running bundle's
Info.plist. Patch the bundled Electron.app's plist during `pnpm
dev:desktop` so dev launches read "Multica Canary" everywhere, not
"Electron". Idempotent; unlinks before rewriting so we don't mutate a
pnpm-store inode shared with other projects.
Previously shouldEnqueueOnComment suppressed agent triggers on done/
cancelled issues, requiring an explicit @mention to resume the
conversation. The gate was non-obvious and confused users who expected
a regular reply to wake the agent up.
Drop the status check — comments are conversational and should wake
the agent up at any status. @mention already bypasses all gates, so
behavior for mentions is unchanged.
Refs multica-ai/multica#1205
* feat(issues): persist comment collapse state across page reloads
Store collapsed comment IDs in a workspace-scoped Zustand store backed
by localStorage, replacing the transient useState(true) default.
Comments now remember their collapsed/expanded state per issue.
* test(issues): add useCommentCollapseStore mock to issue-detail tests
The existing vi.mock for @multica/core/issues/stores didn't include the
newly exported useCommentCollapseStore, causing CommentCard to throw at
render time.
* fix(daemon): normalize hostname by stripping .local mDNS suffix
Daemons started via different methods (standalone CLI vs desktop app
bundled binary) resolve the hostname differently on macOS — one gets
'computer' and the other 'computer.local'. This caused duplicate runtime
registrations for the same machine.
Stripping the .local suffix at the point of hostname resolution ensures
both always register under the same identifier.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(daemon): move empty-host fallback to after .local trim; fix Makefile @ prefix
- Reorder: TrimSuffix runs first, then empty-check, so a hostname of
just ".local" doesn't propagate as an empty daemon_id/device_name
- Add missing @ prefix on migrate command in Makefile so it isn't
echoed twice at startup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
When invoked as `pnpm package -- --mac --arm64 --publish always`,
the bare `--` separator that pnpm inserts was forwarded into
electron-builder's argv. This terminated option parsing, causing
`--publish always` to be treated as positional arguments instead of
a named flag. As a result electron-builder built locally but never
uploaded artifacts to the GitHub Release (isPublish: false).
Add `stripLeadingSeparator()` to remove the leading `--` before
passing args through. Includes unit tests.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(desktop): new tab inherits current workspace + guard against malformed tab paths
Three layered fixes for the same root cause: tab URLs were being
constructed without a workspace slug in some code paths, triggering
NoAccessPage whenever the router interpreted the first segment as a
(non-existent) workspace slug.
## Layer 1 — tab-bar "+" button now inherits current workspace
The handler had a hardcoded `path = "/issues"` left over from before
the slug URL refactor. Without a workspace prefix, the router saw
`workspaceSlug = "issues"` and rendered NoAccessPage. Read
`getCurrentSlug()` and build `/{slug}/issues` instead. Falls back to
"/" (→ IndexRedirect) when there is no current workspace.
This matches terminal/IDE new-tab semantics: new tab opens in the
same workspace as the active tab, not in `wsList[0]`.
## Layer 2 — validateWorkspaceSlugs runs synchronously
PR #1178 added startup validation of persisted tab slugs against the
current workspace list, but ran it in a useEffect. useEffect fires
AFTER commit, so the initial render would briefly show NoAccessPage
on a stale slug before the effect reset the tab path. Moving the call
into render phase eliminates that flash; zustand supports setState in
render, and the validator is idempotent (early-returns if nothing
changed) so this doesn't loop.
## Layer 3 — tab store rejects malformed paths at construction
Any path whose first segment is a reserved slug (e.g. "/issues",
"/login") clearly lacks a workspace prefix and is a caller bug.
sanitizeTabPath catches these at makeTab time, rewrites to "/", and
logs a console.warn naming the offending path so the bug can be fixed
at source. Any future new-tab entry point that forgets the slug will
not reach NoAccessPage.
Net effect: NoAccessPage is reserved for its legitimate purpose —
users navigating to URLs they genuinely don't have access to — and
can no longer be triggered by system bugs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* review: read new-tab workspace from active tab + unify sanitize + add tests
Three follow-ups from self-review of PR #1198:
1. Resolve the current workspace from the active tab's path instead of
from getCurrentSlug(). With N tabs mounted under <Activity>, every
WorkspaceRouteLayout calls setCurrentWorkspace() in render — the
singleton ends up holding "whichever tab rendered last", which is
non-deterministic. activeTabId is the unambiguous source of truth
for "which workspace is the user actually looking at right now".
2. Unify the persist merge's stale-path detection with sanitizeTabPath.
The merge previously checked ROUTE_ICONS (dashboard segments only);
sanitizeTabPath uses isReservedSlug (dashboard + auth + platform +
RFC 2142 + hostname confusables). Same code path now, wider
coverage, and one source of truth.
3. Add unit tests for sanitizeTabPath: root pass-through, global paths,
valid workspace-scoped paths, malformed paths (reserved first
segment) rejected with console.warn, and user slugs that happen to
look path-like but aren't reserved.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(routing): rename /new-workspace to /workspaces/new + extend reserved slug list
Two related changes:
1. Rename the global workspace-creation route from /new-workspace to
/workspaces/new. The hyphenated word-group `new-workspace` is a
common user workspace name (last deploy was blocked by a real user
with exactly this slug). Industry consensus from auditing Linear,
Vercel, Notion, Slack, GitHub: zero major SaaS uses hyphenated
word-group root routes — they all use single words or `/{noun}/{verb}`
pairs. Reserving the noun `workspaces` automatically protects the
entire `/workspaces/*` subtree, so future workspace-related routes
(`/workspaces/{id}/edit`, `/workspaces/{id}/billing`, etc.) need no
additional reserved slugs or audit migrations.
2. Extend the reserved slug list to cover the minimal set recommended by
the URL-design audit: full auth flow vocab, RFC 2142 mailbox names
(postmaster, abuse, noreply...), hostname confusables (mail, ftp,
static, cdn...), and likely-future platform routes (docs, support,
status, legal, privacy, terms, security, etc.). Production data
audit confirmed zero conflicts for every newly added slug, so
migration 047 (the safety net) passes cleanly.
Slugs intentionally NOT added despite being in scope of the audit:
admin, multica, new, setup, www. Each has one production workspace
already using it; adding them now would block deploy. They will be
handled in a follow-up PR via owner outreach + targeted rename.
Also adds a CLAUDE.md convention rule: new global routes MUST use a
single word or `/{noun}/{verb}` pair, never hyphenated word groups.
This prevents the pattern from regenerating itself.
This PR does NOT resolve the currently-blocked prd deploy — that requires
the existing `slug='new-workspace'` workspace (owner: Dhruv Raina) to be
renamed by ops. After that workspace is renamed and migration 046 passes,
this PR's migration 047 will also pass on its first run.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* review: drop migration 046, sweep stale comments, drive reserved test from map
Address code review on PR #1188:
1. Delete migration 046 (audit_new_workspace_slug). It audits "new-workspace"
which is no longer a reserved slug after this PR's rename. Removing 046
has an unexpected upside: it directly unblocks the currently-stuck prd
deploy. Migration 046 had never successfully applied (it was the source
of the deploy block); the audit-only nature means down-rollback is a
no-op. The user workspace previously caught by 046 (slug='new-workspace',
owner: Dhruv Raina) is now safe — `new-workspace` is no longer reserved,
so the slug correctly resolves to that workspace and the global route
`/workspaces/new` doesn't shadow it.
2. Refactor workspace_test.go to drive its reserved-slug list from the
reservedSlugs map directly via `for slug := range reservedSlugs`. The
previous hand-copied list was already drifting (40-ish entries vs 58 in
the map). Now drift is impossible.
3. Sweep ~10 stale `/new-workspace` references in code comments to
`/workspaces/new`. Comments only — runtime unchanged. The references
in reserved-slugs.ts/workspace_reserved_slugs.go and CLAUDE.md are
intentionally kept as anti-pattern examples ("don't add hyphenated
word-group root routes like /new-workspace").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The NoAccessPage button previously only called nav.push('/login'),
leaving the session cookie, React Query cache, and local auth state
intact. AuthInitializer then silently re-authenticates and bounces the
user right back to the workspace URL — the button appeared broken.
Extract the logout flow (clear per-workspace storage, clear cookies,
clear multica_tabs, queryClient.clear(), authStore.logout(), navigate
to /login) into a shared useLogout() hook in packages/views/auth/.
AppSidebar and NoAccessPage both use it now; any future logout entry
point can too.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Desktop tabs persist their full path to localStorage (multica_tabs), so
a tab path like /naiyuan/issues survives app restarts, account switches,
and workspace deletions. Any stale slug caused WorkspaceRouteLayout to
render NoAccessPage immediately on login — the user saw "Workspace not
available" every time they opened the app, with no way to recover
except manually opening a new tab or clearing localStorage.
Root cause: persisted URL strings outlive the server-state they
reference. The auth initializer fetches a fresh workspace list on every
startup, but nothing validated the tab paths against it.
Fix: add tab-store.validateWorkspaceSlugs(validSlugs). Runs on every
change to the workspace list query data (login, background refetch,
realtime workspace:deleted). Any tab whose first path segment isn't in
the valid slug set is reset to `/`, where IndexRedirect picks a live
workspace (or /new-workspace if the user has none). Idempotent, so
over-triggering is safe. Tabs on global paths (/login, /new-workspace,
/invite/...) are left alone.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(daemon): allow startup with zero workspaces
The daemon used to fail fast with "no runtimes registered" when the
initial workspace sync returned zero workspaces. This masked a latent
bug: a newly-signed-up user has no workspaces yet, so the daemon would
crash immediately after login instead of waiting for the first
workspace to be created.
workspaceSyncLoop already polls every 30s (daemon.go:107, 365) to
discover new workspaces — the fail-fast check at startup was bypassing
this dynamic discovery. Remove the check so the daemon stays resident
and picks up the first workspace whenever it appears.
PR #1001 partially addressed this for the "server has workspaces but
local CLI config is empty" case. This finishes the job for the true
zero-workspace state, which until now was masked by the onboarding
wizard always creating a workspace before the daemon started.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(views): extract CreateWorkspaceForm for reuse
Modal and the upcoming /new-workspace page share the same form +
mutation + slug validation. Extract to a shared component so they
can't drift.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(views): add NoAccessPage for unknown or inaccessible workspace slugs
Rendered when the URL slug doesn't resolve to a workspace the user has
access to. Deliberately doesn't distinguish 404 vs 403 to avoid letting
attackers enumerate workspace slugs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(paths): add /new-workspace route and reserve slug on both sides
Adds paths.newWorkspace() builder, registers /new-workspace as a global
(pre-workspace) prefix, and reserves the "new-workspace" slug on both
frontend and backend (kept in sync per convention). Existing
"onboarding" reservation retained — removing it would desync FE/BE
and leaves no future fallback if an onboarding route is revived.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore(migrations): audit no existing workspace uses 'new-workspace' slug
Migration 046 blocks deploy if any workspace in the DB has slug =
'new-workspace', which would shadow the new global workspace creation
route at /new-workspace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add /new-workspace route on web and desktop
Renders the CreateWorkspaceForm as a full-page workspace creation flow,
used as the destination for first-time users with zero workspaces.
Replaces the 4-step onboarding wizard with a single form.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: show NoAccessPage on unknown workspace slug, hold null during active removal
Layouts render NoAccessPage when the URL slug doesn't resolve to an
accessible workspace — except when the slug previously resolved during
this layout instance's lifetime.
URL and cache are two asynchronous signals: there will always be a
short window where the URL still points at the old workspace but the
cache has already been invalidated (e.g. just after a delete/leave
mutation, or a realtime workspace:deleted event). Rendering
NoAccessPage during that window would flash "Workspace not available"
with recovery buttons in front of a user who just deleted the
workspace themselves — jarring and wrong.
useWorkspaceSeen classifies the two cases:
- slug was seen before, now gone → user's intent is changing (caller
is navigating away); render null, no flash
- slug never seen → user is genuinely looking at an inaccessible
workspace (stale bookmark, revoked access, link from a former
teammate); render NoAccessPage with recovery options
NoAccessPage deliberately does not distinguish 404 vs 403 to avoid
letting attackers enumerate workspace slugs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: redirect zero-workspace users to /new-workspace instead of /onboarding
Switches 8 call sites and the CLI:
- Web: login, auth callback, landing redirect-if-authenticated
- Desktop: routes.tsx IndexRedirect
- Shared: dashboard guard, invite page fallback, workspace-tab on delete,
realtime sync on workspace loss
- CLI: cmd_login.go waitForOnboarding now opens /new-workspace
Also adds /new-workspace to navigation store's lastPath exclusion list
so it doesn't get persisted as a 'last visited' page.
Adds a desktop App.tsx effect that restarts the daemon when workspace
count transitions 0 → ≥1, so first-workspace creation triggers
immediate daemon pickup rather than waiting up to 30s for the daemon's
workspaceSyncLoop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: remove onboarding flow
The 4-step onboarding wizard (workspace → runtime → agent → demo issues)
is replaced by:
- /new-workspace: a single-page workspace creation form (Phase 3)
- NoAccessPage: explicit feedback when a slug doesn't resolve (Phase 4)
- daemon zero-workspace bootstrap (Phase 1) so the daemon doesn't
crash before the user creates their first workspace
- desktop daemon restart on first workspace creation (Phase 5) for
instant pickup instead of the 30s workspaceSyncLoop tick
Deletions:
- packages/views/onboarding/ (OnboardingWizard + 4 step components + tests)
- apps/web/app/(auth)/onboarding/page.tsx
- apps/desktop/src/renderer/src/components/onboarding-gate.tsx (+test)
- OnboardingGate wrapper in desktop-layout.tsx
- OnboardingRoute + /onboarding route in desktop routes.tsx
- paths.onboarding() builder + /onboarding from GLOBAL_PREFIXES
- packages/views/package.json onboarding export
- /onboarding from navigation store's EXCLUDED_PREFIXES
Retained (intentional):
- 'onboarding' in RESERVED_SLUGS (both FE + BE) — kept for FE/BE sync
and future-proofing if /onboarding is ever revived
Also drops 4 demo issues that onboarding used to create on the new
workspace ('Say hello', 'Set up repo', etc.). New workspaces are now
fully empty; all list views already render empty-state UI correctly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: clean stale 'onboarding' references in comments and CLI helpers
Batch cleanup of references to the removed onboarding flow:
- 13 comment sites mentioning 'onboarding' updated to reflect the
new /new-workspace flow or removed where no longer accurate
- CLI waitForOnboarding renamed to waitForWorkspaceCreation (function
name + docstring); behavior unchanged
The 'onboarding' reserved slug entries (frontend + backend) are
intentionally retained — see prior commit rationale.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(views): extract shared NewWorkspacePage shell
The web (/new-workspace) and desktop (NewWorkspaceRoute) pages had
identical outer layout — same container, heading, and copy — with only
the onSuccess navigation primitive differing. That's exactly the
No-Duplication Rule pattern: extract the shared UI, inject the
platform-specific behavior.
The apps now only own the thin auth guard (web needs it, desktop
routes below WorkspaceRouteLayout already handle it) and the
onSuccess → navigate call.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: remove rollback compat layer and tighten daemon restart trigger
Two cleanup items:
1. Drop localStorage['multica_workspace_id'] double-write in both
workspace layouts. That write was added as a rollback safety net
for the workspace-slug URL refactor (PR #1138) — the refactor has
since landed and stabilized, so the compat shim is no longer
needed. Per CLAUDE.md: don't keep compat layers beyond their
purpose.
2. Tighten the desktop daemon-restart trigger. The previous ref-based
logic fired a restart on any 0→1 workspace-count transition,
including account switches (user A logout → user B login). Scope
it precisely to 'this session started with zero workspaces and
just gained one' using a three-state ref (null=undecided,
true=empty-start, false=already-restarted-or-started-nonempty).
Account switches are already handled by daemon-manager.ts on
token change, so this avoids a redundant restart there.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auth): redirect to /login on logout and unauthenticated workspace visits
Two gaps previously left users stuck on blank workspace pages:
1. app-sidebar logout() cleared all state but never moved the URL. The
current path is /{workspaceSlug}/... which has no meaning without
auth; the workspace layout would then see user=null, render null
(via the hasBeenSeen short-circuit), and the user saw a blank page
thinking logout didn't work.
2. The workspace layouts (web + desktop) had no !user handling at all.
Any path that leaves user=null — token expiration, cross-tab logout,
or fresh visit to a workspace URL without a session — resulted in
the same blank screen.
Fix:
- app-sidebar.logout() explicitly push(paths.login()) after authLogout()
to cover the primary (user-initiated) logout path.
- Both workspace layouts get a defensive useEffect that redirects to
/login whenever auth has settled and user is null. Covers token
expiration, realtime logout, and any other silent session loss.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(editor): include done issues in @ mention search
The mention picker filtered against the cached issue list, which only
holds the first page of done issues. Older done issues were unfindable
via @, so users had to hand-write `[MUL-xxx](mention://issue/...)` to
reference them.
Switch the issue portion of the picker to the server-side search
endpoint with `include_closed=true` (matching the global Cmd+K search),
debounced and abortable. Done/cancelled rows render dimmed with a
strikethrough title so they remain visually distinct but selectable.
* fix(editor): unblock member/agent results in @ mention picker
The previous patch made items() async and awaited the server-side issue
search before returning anything, which forced even local member/agent
matches to wait for the 150ms debounce + roundtrip.
Return sync items (members, agents, cached issues) immediately and let
the renderer be updated in-place when extra server results arrive. Also
move the search seq/abort state into the createMentionSuggestion closure
so concurrent ContentEditor instances no longer abort each other's
fetches, and aborts on cleanup so a late response can't write to a
destroyed renderer.
Adds a focused test that locks in the sync member/agent path and the
include_closed=true flag.
GetWorkspaceUsageByDay and GetWorkspaceUsageSummary had the same date
attribution bug as the runtime endpoint fixed in #1167: they bucketed
and filtered on agent_task_queue.created_at (enqueue time), so a task
that queued at 23:58 and reported usage at 00:05 was attributed to the
prior day, and ?days=N became a rolling now()-N window that clipped the
morning of the earliest day returned.
Switch both queries to task_usage.created_at (~= task completion time)
and snap the since cutoff to start-of-day via DATE_TRUNC, mirroring
ListRuntimeUsage.
These endpoints have no frontend caller today, but per offline
discussion they will back the upcoming workspace-level usage dashboard.
Fix preemptively so the dashboard inherits correct numbers.
Add a regression test covering both endpoints with the same
cross-midnight + earliest-day-cutoff scenarios used for runtime usage.
* refactor(runtime): derive runtime usage from task_usage only
The daemon used to scan each runtime's local CLI log directory every 5
minutes (Claude Code, Codex, OpenCode, OpenClaw, Hermes) and post daily
aggregates to /api/daemon/runtimes/{id}/usage. Those directories are
shared with the user's own local CLI sessions, so the user's personal
usage was being counted as Daemon-executed usage. Cursor and Gemini had
no scanner at all, so their runtime-level aggregates were always zero.
Switch GetRuntimeUsage to aggregate task_usage (already scoped to
Daemon-executed tasks) via agent_task_queue.runtime_id. Single source of
truth; Cursor/Gemini/Copilot get runtime usage for free; no reliance on
external CLI log formats.
Removes:
- server/internal/daemon/usage/ (all scanners)
- Daemon.usageScanLoop + providerToRuntimeMap
- Client.ReportUsage
- ReportRuntimeUsage handler + POST /api/daemon/runtimes/{id}/usage
- UpsertRuntimeUsage / GetRuntimeUsageSummary queries
- runtime_usage table (migration 046)
Refs: MUL-786
* fix(runtime): bucket daily usage by task_usage.created_at, not enqueue time
ListRuntimeUsage was aggregating by DATE(atq.created_at) and filtering
on atq.created_at. agent_task_queue.created_at is the enqueue timestamp,
which drifts from actual token-production time: a task queued at 23:58
and executed at 00:05 was attributed to yesterday; a task sitting in
the queue overnight was counted on the queue day.
The ?days=N cutoff also became a rolling window (now() - N) instead of
a calendar-day boundary, silently clipping the morning of the earliest
day returned.
Switch bucket + filter to task_usage.created_at (~= task completion /
usage-report time) and snap the since cutoff to start-of-day via
DATE_TRUNC.
Add a regression test covering both scenarios: cross-midnight task
attributes to the day tokens were reported, and the earliest day's
pre-cutoff rows are still included.
The Run History list only had the 'Issue linked' text as a click target.
Wrap the entire row in AppLink when an issue is linked so the whole row
navigates to the issue.
Every other backend (Claude, Gemini, OpenCode, OpenClaw, Hermes) honors
ExecOptions.ResumeSessionID — only Codex didn't. That's why users on
the Codex runtime saw each new comment on an issue start a fresh Codex
conversation: the daemon persists Result.SessionID per (agent, issue)
and passes it back as PriorSessionID, but codex.go always called
thread/start and never populated SessionID, so the value round-tripped
as empty.
Wire the missing half:
- Extract startOrResumeThread on codexClient. When ResumeSessionID is
set, call thread/resume (per the Codex app-server protocol), passing
only cwd / model / developerInstructions overrides so the thread
keeps its persisted model and reasoning effort. If resume fails
(unknown thread, schema drift, transport error) fall back to
thread/start so the task still runs on a fresh thread.
- Surface the live threadID as Result.SessionID on the final emit so
the daemon stores it and feeds it back into ResumeSessionID on the
next claim.
Tests drive the new helper through the fake stdin harness, covering:
fresh start, successful resume, fallback on resume error, fallback
when resume returns no thread ID, and surfacing of thread/start
failures.
AuthStore.initialize() cleared the stored token on any error from
`api.getMe()`, which meant a transient failure — backend rolling
restart, network blip, HMR-aborted fetch in local dev — would force
a re-login. On 401 the token is already cleared upstream via
ApiClient.onUnauthorized, so the store's catch block only needs to
reset the in-memory user state.
Check `err instanceof ApiError && err.status === 401` before clearing
workspace context; leave the token in storage for every other error
so the next initialize() can retry.
Adds regression tests covering the 401 / 500 / network-failure / happy
paths.
Problem
-------
The v2 workspace URL refactor (#1141) switched the frontend from sending
X-Workspace-ID (UUID) to X-Workspace-Slug. The workspace middleware was
updated to accept the slug and translate it via GetWorkspaceBySlug.
But the handler package maintained a PARALLEL resolver
(`resolveWorkspaceID` in handler.go) used by endpoints that sit outside
the workspace middleware — and that resolver was never updated. It only
checked context / ?workspace_id / X-Workspace-ID, never the slug.
/api/upload-file is the one production route that hit the broken path:
it's user-scoped (not behind workspace middleware) because it also
serves avatar uploads (no workspace). Post-refactor requests from the
frontend arrived with only X-Workspace-Slug; the handler resolver
returned "", the code fell into the "no workspace context" branch, and
every file upload since v2 landed in S3 with no corresponding DB
attachment row — files orphaned, invisible to the UI.
Root cause is structural: two resolvers doing the same job, written
independently, diverged silently when one was updated.
Fix
---
Collapse to a single shared helper. middleware.ResolveWorkspaceIDFromRequest
is the new canonical resolver; both the middleware's internal
`resolveWorkspaceUUID` (for middleware gating) and the handler-side
`(h *Handler).resolveWorkspaceID` (promoted from a package function)
now delegate to it. Priority order matches what the middleware has had
since v2: context > X-Workspace-Slug header > ?workspace_slug query >
X-Workspace-ID header > ?workspace_id query.
Impact analysis
---------------
47 call sites of the old `resolveWorkspaceID(r)` are renamed to
`h.resolveWorkspaceID(r)`. 46 of them sit behind workspace middleware,
so they hit the context fast path and see zero behavior change. The
one caller that actually gains capability is UploadFile — which now
correctly recognizes slug requests and creates DB attachment rows.
Tests
-----
- New table-driven unit test for ResolveWorkspaceIDFromRequest covers
all priority levels and the unknown-slug fallback.
- Regression tests for UploadFile: once with X-Workspace-Slug only
(the broken path), once with X-Workspace-ID only (legacy CLI/daemon
compat path). Both assert that a DB attachment row is created.
- Full Go test suite passes; typecheck + pnpm test unaffected.
Plan
----
See docs/plans/2026-04-16-unify-workspace-identity-resolver.md for the
full first-principles writeup.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Problem
-------
On desktop, creating a new tab triggered thousands of chat-store
rehydration logs per second (sustained for seconds). Same session,
same workspace — nothing actually changed. `pnpm test` was clean; the
bug only manifests at runtime with React 19 Activity + multi-tab.
Root cause
----------
Every tab's WorkspaceRouteLayout kept its own `syncedSlugRef` to decide
"did slug change since last sync". That model assumes one layout
instance equals one workspace context — true on web, false on desktop
where N tabs each mount their own layout. Activity remounts +
tab-router-sync stirring the tab store caused per-layout refs to drift
out of agreement with the module-level truth, so each ref independently
called `rehydrateAllWorkspaceStores()`. The existing microtask dedup
only coalesced same-tick calls; successive ticks each scheduled another
iteration through every registered rehydrate fn.
Fix
---
Move the "did slug actually change?" decision to where the truth lives:
inside `setCurrentWorkspace` itself. The singleton now:
- Returns immediately when the slug is already current (idempotent).
- Fires slug subscribers + persist rehydrate as internal side effects
when (and only when) the slug transitions.
Layouts are simplified to "feed the URL slug in"; they no longer
maintain a ref guard or call rehydrate explicitly. N tabs feeding the
same slug is naturally a no-op after the first — the model no longer
depends on "one layout instance" as an implicit invariant.
Also hardens the original render-time race that motivated the v2
refactor: both layouts now gate on `!listFetched || !workspace` so
`useWorkspaceId()` in descendants is guaranteed non-null.
Public API
----------
`rehydrateAllWorkspaceStores` removed from `@multica/core/platform`
exports — it's now purely an internal effect of `setCurrentWorkspace`.
The function itself is deleted; the rehydrate loop lives inline in
`setCurrentWorkspace`.
Tests
-----
Four new tests covering the new semantics: single rehydrate on mount,
same-slug noop across repeat calls, real workspace switch fires again,
logout → re-entry into same workspace fires again.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a "Download Desktop" button in the hero section alongside the
existing CTA and GitHub buttons, linking to the latest GitHub release.
Also add a Desktop link in the footer product group for both EN and ZH.
* feat(agent): add GitHub Copilot CLI backend
Integrate Copilot CLI as a new agent backend using the stable
`-p` JSONL mode (`--output-format json`), following the same
spawn-CLI-scan-JSONL pattern established by claude.go.
Backend (server/pkg/agent/copilot.go):
- Spawn `copilot -p <prompt> --output-format json --allow-all-tools --no-ask-user`
- Parse streaming JSONL events (system/assistant/user/result/log)
- Extract session ID for resume support (`--resume <id>`)
- Accumulate per-model token usage for billing
- Filter blocked args to prevent protocol-critical flag overrides
Daemon config:
- Probe MULTICA_COPILOT_PATH / MULTICA_COPILOT_MODEL env vars
- Copilot uses AGENTS.md (native discovery) and default skills path
Frontend:
- Add Copilot logo SVG and provider switch case
Tests: 14 unit tests covering arg building, event parsing, usage
accumulation, and edge cases. All Go + TS checks pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(daemon): add restart subcommand, make daemon uses it
- `daemon start` keeps original behavior: errors if already running
- `daemon restart` stops existing daemon then starts fresh
- `make daemon` now runs `daemon restart --profile local`
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(copilot): address review nits 1-5
- Nit 1: Add MinVersions["copilot"] = "1.0.0"
- Nit 2: Seed activeModel from session.start.data.selectedModel (falls
back to opts.Model, then "copilot"). First-turn tokens now get correct
model attribution.
- Nit 3: Handle assistant.reasoning/reasoning_delta → MessageThinking,
reasoningText in assistant.message → MessageThinking,
session.warning → MessageLog{warn}
- Nit 4: Extract handleCopilotEvent() method shared by production and
tests — no more duplicated switch body that can drift
- Nit 5: Deltas write to output buffer as defense-in-depth; if process
dies before assistant.message, output is non-empty
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When codex emits `turn/completed` with `status="failed"` or a terminal
top-level `error` notification, the daemon previously treated the turn
as successfully completed, saw no accumulated text, and surfaced the
generic "codex returned empty output" — hiding the real reason (auth,
sandbox, API error, etc.).
Capture `turn.error.message` on failed turns and the `error.message`
from non-retrying top-level error notifications, then propagate them
through `Result.Error` with `finalStatus="failed"` so the daemon's
default branch reports the actual cause.
Dev mode now uses a separate app name ('Multica Dev') and userData path
before acquiring the single-instance lock, so the lock file no longer
collides with the packaged production app. The AppUserModelId is also
differentiated (ai.multica.desktop.dev vs ai.multica.desktop).
This follows the same pattern VS Code uses for Stable / Insiders
coexistence: isolate identity before requestSingleInstanceLock().
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* docs: add Pi and Gemini runtimes to supported-agent references
CLI_AND_DAEMON.md, SELF_HOSTING.md, and SELF_HOSTING_ADVANCED.md listed
claude/codex/opencode/openclaw/hermes as supported runtimes in their agent
tables and env-var overrides but omitted the pi and gemini entries that
the daemon already registers (server/internal/daemon/config.go).
* docs(readme): list all supported runtimes (add Hermes, Gemini, Pi)
* docs: add Cursor runtime, fix Pi URL, clarify daemon ASCII diagram
- Add Cursor Agent (cursor-agent CLI, MULTICA_CURSOR_PATH/MODEL) to the
supported-runtime tables, env-var lists, and prose across README,
CLI_AND_DAEMON, CLI_INSTALL, SELF_HOSTING, and SELF_HOSTING_ADVANCED.
- Fix Pi's canonical URL from github.com/paperclipai/paperclip to
https://pi.dev/.
- Rework the Agent Daemon box in both READMEs so provider names live in
an annotation outside the box instead of being wrapped mid-word
(`OpenClaw/Code`), which read as a phantom "Code" runtime.
* feat(agent): add Cursor Agent CLI runtime support
Add cursor-agent as a new agent backend, following the same pattern as
existing providers. The implementation spawns cursor-agent CLI with
stream-json output, parses JSONL events into the unified Message type,
and supports session resume, usage tracking, and auto-approval (--yolo).
Changes:
- server/pkg/agent/cursor.go: cursorBackend implementation
- server/pkg/agent/cursor_test.go: unit tests for args, parsing, errors
- server/pkg/agent/agent.go: register "cursor" in New() factory
- server/internal/daemon/config.go: probe cursor-agent in PATH
- server/internal/daemon/execenv/context.go: cursor skill discovery path
- server/internal/daemon/execenv/runtime_config.go: AGENTS.md injection
- packages/views/.../provider-logo.tsx: cursor logo in UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(agent): address PR review for cursor backend
1. Fix token usage double-counting: usage is now taken exclusively from
"result" events (session totals). Per-message usage in "assistant"
events is intentionally ignored. "step_finish" usage is only used as
fallback when no "result" usage is available.
2. Remove dead code: isCursorUnknownSessionError() and its regex were
defined but never called. Removed along with corresponding test.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(agent): add missing CustomArgs, SystemPrompt, MaxTurns, and debug logging to cursor backend
- Add cursorBlockedArgs and filterCustomArgs support for safe custom arg passthrough
- Add --system-prompt and --max-turns flag support to buildCursorArgs
- Add debug logging of command args before execution (consistent with all other backends)
- Move stdout-close goroutine inside main goroutine (consistent with claude.go pattern)
- Add tests for SystemPrompt/MaxTurns and CustomArgs filtering
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: make daemon uses local profile & update Cursor logo to official brand
- Makefile: make daemon now runs 'daemon start --profile local' for local dev
- Replace Cursor runtime logo with official brand SVG (removed background rect)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agent): remove unsupported --system-prompt and --max-turns from cursor-agent
cursor-agent CLI does not support these flags. Instructions are already
injected via AGENTS.md and .cursor/skills/ files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agent): prevent step_finish + result usage double-counting in cursor
Split usage accumulation into separate stepUsage and resultUsage maps.
After stream ends, use resultUsage if available (session totals from
result event), otherwise fall back to stepUsage (sum of step_finish).
This prevents 2x counting when result.usage already includes totals.
Added table-driven test covering: result-only, step_finish-only,
step_finish+result (no double count), and multi-model scenarios.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* docs(agent): fix misleading comment on cursor -p flag
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the placeholder Greek-letter π glyph with pi.dev's actual
pixel-art "pi" wordmark on the brand's dark background. Source:
https://pi.dev/logo.svg.
* feat(agent): add Pi agent runtime support
Add Pi as a new agent runtime provider, following the established adapter
pattern. Pi CLI outputs JSONL events which are parsed for messages, tool
calls, and usage tracking.
Backend:
- New piBackend implementing the Backend interface (pi.go)
- Pi CLI discovery via MULTICA_PI_PATH env var or PATH lookup
- JSONL event stream parsing (agent_start, message_update, thinking_update,
tool_execution_start/end, agent_end)
- Usage scanner for ~/.pi/sessions/*.jsonl files
- Runtime config injection via AGENTS.md
- Skill injection to .pi/agent/skills/
Frontend:
- Pi provider logo (teal π icon)
- Pi label in transcript dialog
Docs:
- Updated all provider lists in README, CLI_INSTALL, and docs
* fix(agent): filter Pi usage scanner to agent_end events only
Address review feedback: restrict usage parsing to agent_end events
which contain cumulative totals, preventing potential inaccuracy if
Pi adds usage fields to other event types in the future.
* fix(agent): align Pi runtime with real CLI flags, event schema, and custom_args
- Flags: Pi's CLI uses `--mode json` (not `--output-format jsonl`), has no
`--yolo` (explicit `--tools` allowlist instead), takes the prompt as a
positional argument (not `-p <prompt>`), splits model as
`--provider <name> --model <id>`, and treats `--session` as a file path
that must exist before spawn.
- Event parsing: rewrite the stream event struct to match Pi's actual
JSON event schema (`message_update.assistantMessageEvent.delta`,
`turn_end.message.usage.{input,output,cacheRead,cacheWrite}`, etc.).
- Sessions: generate/persist session files under ~/.multica/pi-sessions/
and use the file path as the opaque SessionID returned to the daemon.
- Usage scanner: read assistant `message` events from the same session
files (Pi's session-file schema, distinct from the stdout stream).
- Custom args: consume `ExecOptions.CustomArgs` via `filterCustomArgs`
with a Pi-specific blocked set (`-p`, `--print`, `--mode`, `--session`)
so Pi matches the pattern shared by every other agent backend.
GetActiveTaskForIssue, CancelTask, ListTasksByIssue, and GetIssueUsage
accepted the issueId URL parameter and queried by it without verifying
that the issue belonged to the caller's X-Workspace-ID workspace. The
RequireWorkspaceMember middleware only proves membership in the header
workspace; it does not bind the path-parameter issue to it. A member of
workspace A could therefore enumerate tasks, cancel tasks, and read
usage metadata for any issue UUID in workspace B.
Route every issueId through loadIssueForUser (matching GetIssue and the
existing comment/subscriber handlers). For CancelTask additionally
verify that the task's IssueID matches the loaded issue — the task must
not only belong to the caller's workspace but also to the specific
issue named in the URL, and the access check must run before any
mutation.
Follow-up to MUL-899 / #1112.
The top bar pads `pl-20` for the macOS traffic lights only when
`state === "collapsed"`, but the shadcn sidebar also hides itself in
mobile mode (<768px) where `state` stays `"expanded"` and only
`isMobile` flips. In that case tabs slid under the traffic lights and
no UI affordance existed to bring the sidebar back (since the in-sidebar
trigger went off-canvas with it).
Treat both as "sidebar not in main flow", apply the padding, and render
a `SidebarTrigger` in the header (with `no-drag` so the window drag
region doesn't swallow the click).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Every other backend (claude, codex, opencode, openclaw, gemini) filters
opts.CustomArgs through a per-backend blocked map so protocol-critical
flags can't be overridden via the Create Agent UI. The hermes backend
appended CustomArgs directly to argv, so any future flag we add to the
map would be silently bypassed here.
Add hermesBlockedArgs (with 'acp' as the pinned subcommand) and route
CustomArgs through filterCustomArgs. Behaviour is identical for today's
use cases; the change prevents accidental protocol-flag overrides and
brings hermes in line with the other five backends.
Closes#1113
Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
Adds TestGetIssueGCCheck_WithDaemonToken_CrossWorkspace alongside the
existing TestGetTaskStatus_WithDaemonToken_CrossWorkspace, covering:
- daemon token scoped to a different workspace → 404 (matches the
"issue not found" status, so no UUID enumeration oracle)
- daemon token scoped to the issue's workspace → 200 with status and
updated_at fields populated
Follow-up to #1121, which fixed the underlying IDOR reported in #1112
but did not ship a regression test. This gates the class of bug at CI
so the next handler to forget requireDaemonWorkspaceAccess will be
caught before merge.
After the slug-first URL refactor, the frontend sends X-Workspace-Slug
and the workspace middleware resolves it into a UUID stored in the
request context. The inbox handlers still read X-Workspace-ID directly
from the request header, which is now absent, so every inbox query ran
with an empty workspace_id and returned zero rows.
Switch all six inbox handlers to ctxWorkspaceID(r.Context()), matching
the pattern already used by chat / issue / project / autopilot. No
frontend changes required — the slug header path was already correct.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The daemon GC check endpoint did not verify the caller's access to the
issue's workspace, letting a daemon token or PAT scoped to workspace A
read issue status/updated_at for any issue UUID across the instance.
Mirror the pattern used by every other handler in daemon.go: look up
the issue's workspace and gate on requireDaemonWorkspaceAccess.
Closes#1112
Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
Follow-up to #1126 (which closed the HTML-injection vector in the Body).
The Subject line is not HTML-rendered, so html.EscapeString would leak
literal entities into recipient inboxes. Instead:
- Strip control characters from workspace/inviter names (defense in depth
even though Resend also filters CR/LF).
- Cap each field at 60 runes so an attacker can't stuff a full phishing
pitch into a workspace name that gets sent from noreply@multica.ai.
Also extracts buildInvitationParams to make the sanitization logic
testable without mocking the Resend SDK, and adds a test covering:
- HTML escape behavior for script/attribute/anchor injection payloads
- Subject stripping of \r\n\t and other unicode controls
- Subject NOT being HTML-escaped (so "Acme & Co." stays literal)
- Subject length bounds
- Benign inputs pass through unchanged
Adds a note on SendVerificationCode that its body uses only
server-generated content, to prevent the same pitfall from creeping in.
Refs #1117
* fix(email): HTML-escape workspace/inviter names in invitation email
SendInvitationEmail interpolated workspaceName and inviterName directly
into the HTML body via fmt.Sprintf with no escaping. A workspace owner
who sets a name like '</h2><a href="https://evil.example">Click</a>'
can break the email structure and inject attacker-controlled links that
appear as part of the official Multica invitation.
Escape both values with html.EscapeString before interpolation. The
Subject line also gets the escaped variants since some transports render
HTML-entity-like sequences.
Closes#1117
* fix(email): use raw names in Subject, keep HTML-escape for body only
Email Subject is a plain-text context — applying html.EscapeString
turns "A&B" into "A&B" and "O'Brien" into "O'Brien" in the
recipient's inbox. Keep the escape for the Html body where it prevents
injection, but use the original values in Subject.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Reapply "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)
This reverts commit 9b94914bc8.
* compat: legacy URL redirect + localStorage double-write for safe rollback
The first attempt at this refactor (#1131) was reverted because existing
users on old URLs (/issues, /projects, etc.) hit 404 immediately after
deploy, and rolling back left them with empty dashboards — the legacy
code reads localStorage["multica_workspace_id"] to attach a workspace
to API requests, but the new code had stopped writing that key.
Two compat layers added on top of the restored refactor:
1. proxy.ts now intercepts legacy route prefixes (/issues/*, /projects/*,
/agents/*, /inbox/*, /my-issues/*, /autopilots/*, /runtimes/*,
/skills/*, /settings/*). Logged-in users with a last_workspace_slug
cookie are 302'd to /{slug}/{rest}, preserving their deep link. Users
without the cookie bounce through / where the landing page picks a
workspace client-side. Unauthenticated users go to /login.
2. Both layouts now double-write the workspace id to the legacy
localStorage key on every workspace entry. New code ignores this key
— it exists solely so that if this PR ever gets reverted again, the
legacy build reading the key would still find the correct workspace
and avoid the empty-dashboard symptom users saw during the rollback.
Net effect: any direction of deploy ↔ rollback is now cache-compatible,
and any direction of old bookmark → new route resolves without 404.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(platform): defer rehydrateAllWorkspaceStores to a microtask
Same React 19 render-phase restriction that forced setCurrentWorkspace
to defer its subscriber notifications. rehydrateAllWorkspaceStores
synchronously calls each persist store's rehydrate, which setState()s
the store, which schedules updates on any subscribed component. When
the workspace layout's render-phase ref guard invoked this, React
complained that SearchCommand (a store subscriber) couldn't be
re-rendered while WorkspaceLayout was still rendering.
Fix: queueMicrotask the rehydrate loop and add a pending-flag guard so
rapid workspace switches coalesce into one rehydrate on the final slug.
Persist stores tolerate one microtask of staleness — they hold UI
preferences, not correctness-critical state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: workspace URL refactor + slug-first API identity
Make the URL the single source of truth for workspace identity.
All workspace-scoped URLs now carry the workspace slug as the first
path segment (/{slug}/issues, /{slug}/projects, etc.), matching the
industry standard (Linear, Notion, Vercel, GitHub).
## Key architectural changes
**URL-driven workspace identity:**
- Web routes moved under app/[workspaceSlug]/(dashboard)/
- Desktop routes nested under /:workspaceSlug
- paths.ts builder centralises all URL construction
- reserved-slugs validation (backend + frontend + DB migration audit)
**Slug-first API contract:**
- Frontend sends X-Workspace-Slug header (from URL) instead of X-Workspace-ID (UUID)
- Backend middleware resolves slug → UUID via GetWorkspaceBySlug, falls back to
X-Workspace-ID for CLI/daemon backwards compatibility
- WebSocket auth accepts ?workspace_slug query param with SlugResolver callback
**State cleanup:**
- Deleted: useWorkspaceStore (Zustand mirror), switchWorkspace/hydrateWorkspace/
clearWorkspace, localStorage["multica_workspace_id"], api._workspaceId
- useCurrentWorkspace() derives from URL slug + React Query workspace list
- useWorkspaceId() is now a bridge hook (no Context, derives from useCurrentWorkspace)
- WorkspaceIdProvider removed from DashboardGuard
- Paired module vars (slug + UUID) in workspace-storage.ts for non-React consumers
**Layout simplified:**
- Render-phase ref guard sets workspace context synchronously (no async gate)
- DashboardGuard handles auth redirect, loading state, and workspace resolution
- Subscriber notifications deferred via queueMicrotask (React 19 compat)
- persist namespace uses slug (immutable) instead of UUID
## Issues resolved
MUL-43 (share links), MUL-509 (mobile workspace switch), MUL-723 (workspace in URL),
MUL-727 (create workspace flash), MUL-728 (delete workspace no-navigate),
MUL-820 (sidebar Join not switching)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve code review C3/C4/C5/C6 — desktop deadlock + hardcoded paths
C3: Desktop OnboardingGate was calling useCurrentWorkspace() outside
WorkspaceSlugProvider → always null → permanent onboarding deadlock.
Rewrite to use useQuery(workspaceListOptions()) which reads React Query
cache directly without slug context. Remove DashboardGuard from
DesktopShell (auth gating handled by AppContent, workspace routing by
WorkspaceRouteLayout per-tab).
C4: Landing page "Dashboard" links hardcoded /issues (no longer valid).
Changed to / — proxy handles redirect to /{lastSlug}/issues.
C5: autopilots-page.tsx had one hardcoded /autopilots/${id} link.
Changed to wsPaths.autopilotDetail(id).
C6: inbox-page.tsx hardcoded /inbox paths. Changed to wsPaths.inbox().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): wrap shell in WorkspaceSlugProvider from module var
AppSidebar calls useWorkspacePaths() → useRequiredWorkspaceSlug() which
throws outside WorkspaceSlugProvider. In the desktop shell, the sidebar
renders at the shell level (outside any tab's WorkspaceRouteLayout).
Fix: DesktopShell reads the current slug via useSyncExternalStore on
the workspace-storage singleton. When slug is available, wraps the
entire shell in WorkspaceSlugProvider. When null (first mount before
any tab's WorkspaceRouteLayout sets it), shows a loading spinner.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): migrate old tab paths + fix shell slug deadlock
Tab store rehydration: old-format paths like "/issues/abc" (missing
workspace slug prefix) are reset to "/" so IndexRedirect picks the
correct workspace. Detection: if the first segment is a known route
name (issues, projects, etc.) rather than a workspace slug, it's an
old-format path.
Desktop shell: TabContent must always render (not gated behind slug
check) so WorkspaceRouteLayout can mount and call setCurrentWorkspace.
Only sidebar and shell-level UI (chat, modals, search) gate on slug
being present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two editor bugs fixed:
1. Descriptions saved unnecessarily on every document change (no dirty
check). Added onCreate baseline capture + string comparison in the
debounced onUpdate handler so mutations only fire when content
actually changes.
2. Clearing a description didn't persist — empty string was converted
to undefined via `md || undefined`, causing the field to be omitted
from the API request. Changed to `md` so empty strings reach the
backend and clear the description via COALESCE.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- TrimSpace incoming repoURL in ensureRepoReady to prevent unnecessary
server refreshes when CLI passes URLs with whitespace
- Add comment on reposVersion field clarifying it is stored for future
version-based skip optimization
- Add concurrency safety comment on syncWorkspacesFromAPI skip logic
- Add test for URL trimming fast-path behavior
* feat(server): trigger agent when issue moves out of backlog
When a member moves an agent-assigned issue from "backlog" to an active
status (e.g. "todo", "in_progress"), enqueue an agent task so the agent
starts working. This lets backlog act as a parking lot where issues can
be assigned to agents without immediately triggering execution.
Applies to both single and batch issue updates.
* fix(server): treat backlog as parking lot — no trigger on create/assign
Address review feedback: creating or assigning an agent to a backlog
issue no longer triggers immediate execution. Only moving out of backlog
to an active status triggers the agent, producing exactly one task.
- shouldEnqueueAgentTask now gates on backlog status
- backlog→active trigger uses isAgentAssigneeReady directly
- Added TestBacklogNoTriggerOnCreate test
- Updated TestBacklogToTodoTriggersAgent to assert exactly 1 task
across the full create→move path (no manual cleanup)
* feat(ui): show toast hint when assigning agent to backlog issue
Users may not know that backlog issues won't trigger agent execution
until moved to an active status. Show an actionable toast with a
"Move to Todo" button when:
- Assigning an agent to a backlog issue in the detail page
- Creating a backlog issue with an agent assignee
* feat(ui): add "Don't show again" option to backlog agent toast
Users who understand the backlog parking lot behavior can dismiss the
hint permanently. Uses localStorage to persist the preference.
* feat(ui): replace backlog agent toast with AlertDialog
Use a modal dialog instead of a toast notification so users must
explicitly acknowledge the hint. The dialog offers three options:
- "Move to Todo" — changes status and triggers the agent
- "Keep in Backlog" — dismisses without action
- "Don't show again" — persists dismissal in localStorage
* fix(ui): improve backlog agent dialog
* fix(ui): close create dialog behind hint, use checkbox for don't-show-again
1. Create Issue dialog now closes when the backlog agent hint appears,
so only the hint dialog is visible (not stacked behind).
2. "Don't show again" is now a checkbox instead of a separate button.
When checked, clicking either "Keep in Backlog" or "Move to Todo"
persists the preference.
* fix(ui): smooth backlog agent hint dialog
* fix(test): add useUpdateIssue mock to create-issue test
The test mock for @multica/core/issues/mutations was missing the
useUpdateIssue export that create-issue.tsx now imports, causing
CI failure.
DESKTOP_SPAWN_ENV was a top-level const in daemon-manager.ts that
snapshotted process.env at module load. Because ESM imports are hoisted
and evaluated before main/index.ts runs fix-path, the snapshot captured
launchd's minimal PATH — missing ~/.local/bin, Homebrew, etc. The main
process then had the corrected PATH, but every spawned daemon inherited
the stale one and failed with "no agent CLI found" on fresh GUI launches.
Convert it to desktopSpawnEnv() so process.env is read at call time,
after fix-path has already updated it.
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The desktop login was reading VITE_WEB_URL, which is defined nowhere
in the committed env files. In production builds the variable was
undefined, so Google login opened http://localhost:3000/login?platform=desktop
instead of https://multica.ai/login?platform=desktop.
Switch to VITE_APP_URL, which is already set in apps/desktop/.env.production
and is the same variable platform/navigation.tsx uses for shareable links.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fresh desktop accounts no longer need to walk through runtime, agent,
and get-started steps before reaching the app. Once the workspace is
created, the onboarding gate hands off directly to the main shell.
Web onboarding is unchanged.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(desktop): add macOS app icon
Replace the default electron-vite scaffold icon with the Multica asterisk
icon. Adds build/icon.icns so electron-builder picks it up automatically
via the `buildResources: build` config — no YAML change needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): run electron-vite build inside package script
The package wrapper only ran bundle-cli.mjs and electron-builder, so
electron-builder silently packaged whatever was already in out/. On a
fresh checkout (or after a partial build) this shipped an app with a
missing renderer bundle, which white-screens on launch.
Add an explicit `electron-vite build` step between bundle-cli and
electron-builder so `pnpm package` is self-contained.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): restore shell PATH in main process for GUI launches
macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
~/.zshrc, Homebrew, nvm, ~/.local/bin, and other shell config. Child
processes spawned from the main process — including the bundled multica
CLI used by daemon-manager — inherit the same stripped PATH, so the CLI
fails to locate agent binaries like claude, codex, opencode, etc. with
"no agent CLI found: … ensure it is on PATH".
Use `fix-path` to recover the real shell PATH at startup, then prepend
common install locations (/opt/homebrew/bin, /usr/local/bin,
~/.local/bin) as a fallback for broken shell rc or non-interactive
$SHELL. Runs before setupDaemonManager so every subsequent spawn sees
the corrected PATH.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): show onboarding wizard when authed user has no workspace
Desktop is a single-shell architecture — every route, including
/onboarding, lives inside DashboardGuard. The guard returns its loading
fallback whenever workspace is null, so a fresh account that logs in
with no workspaces ends up stuck on the spinner forever: the
`replace(onboardingPath)` redirect navigates the tab router, but
DashboardGuard still blocks its children because workspace is still
null.
Handle the empty-workspace case in DesktopShell itself: render
OnboardingWizard as a full-screen takeover, bypassing DashboardGuard.
A ref-based flag freezes the "needs onboarding" decision at first
mount so creating a workspace mid-wizard (step 0) doesn't unmount the
wizard and dump the user into the main shell before steps 1-3
(runtime, agent, get started) finish.
Also add a local `bootstrapping` flag in AppContent so DesktopShell
doesn't mount until the deep-link login chain (loginWithToken →
syncToken → listWorkspaces → hydrateWorkspace) fully resolves. Without
it, the shell would briefly see `!workspace` before hydration lands,
causing users with existing workspaces to flash the wizard (or, with
the ref freeze, get stuck in it permanently).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(desktop): extract OnboardingGate with test coverage
Pull the "render onboarding wizard when authed user has no workspace"
logic out of DesktopShell into a dedicated OnboardingGate component.
Replaces the ref-based freeze with a lazy useState initializer
(`useState(() => !hasWorkspace)`), which is React's idiomatic pattern
for "capture a value once at mount". The freeze semantics are unchanged:
creating a workspace in step 0 of the wizard must not unmount it,
because steps 1-3 still need to run; only `onComplete` flips the gate
back to the main shell.
Also de-duplicates the wrapping DesktopNavigationProvider — both branches
of the shell now share a single provider instead of re-mounting one per
branch.
Wire up jsdom + @testing-library/react in the desktop vitest config
(mirroring packages/views) and add three deterministic tests covering:
1. children render when hasWorkspace is true at mount
2. wizard stays mounted when hasWorkspace flips to true mid-flow
3. onComplete transitions the gate to children
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(desktop): drop redundant syncToken call in deep-link login
daemonAPI.syncToken was called twice on a deep-link login: once inside
the deep-link handler's bootstrapping chain, and again in the
useEffect([user]) that reacts to the user state change. Both calls spawn
a multica CLI subprocess over IPC, wasting ~1-2s of startup time on the
critical login path.
Keep the [user] effect (it covers the session-restore path too) and
drop the explicit call from the deep-link handler. Net effect: login
latency shrinks, behavior is unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(selfhost): persist local uploads and proxy file routes
* fix(selfhost): keep local uploads across container recreation
* docs(selfhost): restore relative local upload dir example
Document the complete workflow for running backend, frontend, and daemon
from source in a fully isolated environment. Covers dynamic profile
naming, automated auth, Desktop app testing, and cleanup — all without
touching the system CLI config or production environment.
GoReleaser produces .zip for Windows and .tar.gz for other platforms,
but the update command hardcoded .tar.gz for all platforms, causing a
404 error on Windows.
- Select .zip extension when runtime.GOOS is "windows"
- Add extractBinaryFromZip() for zip archive extraction
- Use "multica.exe" as the binary name on Windows
Closes#1072
Ensures the database schema is always up to date when starting the app,
preventing silent API failures caused by missing columns after pulling
latest changes.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a debug-level log line in every agent backend (claude, codex,
opencode, openclaw, gemini, hermes) that prints the executable path
and full argument list when spawning the agent process. Helps diagnose
custom args, model overrides, and other CLI flag issues.
Users naturally type `--model claude-sonnet-4-20250514` on one line,
but the backend needs them as separate tokens. Now `entriesToArgs`
splits each entry by whitespace before saving, so the API receives
`["--model", "claude-sonnet-4-20250514"]` instead of a single string.
Also updated placeholder and description to show the natural input
format.
* feat(agent): add custom CLI arguments support
Allow users to configure custom CLI arguments per agent that get
appended to the agent subprocess command at launch time. This enables
use cases like specifying different models (--model o3), max turns,
or other provider-specific flags without needing separate runtimes.
Changes:
- Add custom_args JSONB column to agent table (migration 041)
- Update API handler to accept/return custom_args in create/update
- Pass custom_args through claim endpoint to daemon
- Append custom_args to CLI commands for all agent backends
- Add ExecOptions.CustomArgs field in agent package
- Add Custom Args tab in agent detail UI
- Add --custom-args flag to CLI agent create/update commands
Closes MUL-802
* fix(agent): filter protocol-critical flags from custom_args
Add per-backend filtering of custom_args to prevent users from
accidentally overriding flags that the daemon hardcodes for its
communication protocol (e.g. --output-format, --input-format,
--permission-mode for Claude).
This follows the same pattern as custom_env's isBlockedEnvKey: we
only block the small, stable set of flags that would break the
daemon↔agent protocol — not every possible dangerous flag. Workspace
members are trusted for everything else.
Each backend defines its own blocked set:
- Claude: -p, --output-format, --input-format, --permission-mode
- Gemini: -p, --yolo, -o
- Codex: --listen
- OpenCode: --format
- OpenClaw: --local, --json, --session-id, --message
- Hermes: none (ACP is positional)
Includes unit tests for the filtering logic.
* fix(agent): address code review nits for custom_args
- Replace module-level `nextArgId` counter with `crypto.randomUUID()`
in custom-args-tab.tsx to avoid SSR ID conflicts
- Add unit tests for custom args passthrough and blocked-arg filtering
in both Claude and Gemini arg builders
The .env.example had hardcoded http://localhost:8080 defaults for
NEXT_PUBLIC_API_URL and NEXT_PUBLIC_WS_URL. When users copied .env.example
to .env and customized the backend port, the old defaults would still get
baked into the frontend at docker build time via NEXT_PUBLIC_WS_URL build
arg, causing API/WebSocket connection failures.
With empty defaults:
- Docker selfhost: frontend uses relative paths, Next.js rewrites proxy
to backend internally — works regardless of external port config
- Local dev (make dev): Makefile sets these to localhost:$PORT automatically
- Browser fallback: deriveWsUrl() auto-derives WebSocket URL from page
origin when NEXT_PUBLIC_WS_URL is empty
Closes#1055
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): ship entitlements.mac.plist so electron-builder can codesign
electron-builder.yml already references build/entitlements.mac.plist
via entitlementsInherit, but the file was missing from the tree, so
`pnpm package` failed at the codesign step with:
build/entitlements.mac.plist: cannot read entitlement data
Ship the file. It grants the hardened-runtime capabilities the app
actually needs: JIT + unsigned executable memory for V8, disabled
library validation so the Electron process can spawn the bundled
`multica` Go binary as a child process, and network client/server for
the daemon's API and /health endpoints.
Also tweak the root .gitignore: the top-level `build` rule was
shadowing apps/desktop/build/, hiding this config file from git.
Add a scoped exception so apps/desktop/build/ (which holds
electron-builder source resources, not output) is tracked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(desktop): derive package version from git tag at build time
The Desktop app version was hardcoded to "0.1.0" in package.json and
never bumped, while the bundled CLI reports whatever `git describe`
gives at build time. Result: packaging on main produced
desktop-0.1.0.dmg containing multica v0.1.35-14-gf1415e96 — completely
disconnected. Users see two unrelated version numbers for the same
release.
Sync them by using the same source GoReleaser uses for the CLI: the
nearest git tag. A new scripts/package.mjs wrapper runs bundle-cli.mjs,
derives the version via `git describe --tags --always --dirty` (strips
the `v` prefix, falls back to `0.0.0-<hash>` when no tags are
reachable), and invokes electron-builder with
`-c.extraMetadata.version=<derived>` — which overrides package.json at
build time without mutating the tracked file.
On a clean tag commit → "0.1.36"; between tags → "0.1.35-14-gf1415e96"
(valid semver prerelease); dirty tree → same with "-dirty" suffix.
The `package` script in package.json now points to the wrapper.
Passthrough args (--mac, --arm64, etc.) after `pnpm package --` are
forwarded to electron-builder unchanged. Dev and build scripts are
untouched — they continue to use bundle-cli.mjs directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(desktop): enable macOS notarization and clean artifact names
Two electron-builder.yml tweaks that unblock a proper release:
- `mac.notarize: false` → `true`. Notarization runs in-build via
notarytool, reading APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID
from env. electron-builder then staples the ticket before zipping, so
`latest-mac.yml`'s SHA512s match the published artifacts (critical
for electron-updater — post-hoc re-stapling would invalidate them).
Non-mac/CI contributors are unaffected: `pnpm package` already
requires the Developer ID signing cert, and notarization is a strict
superset of signing.
- `mac.artifactName` and `dmg.artifactName` now hardcode
`multica-desktop-${version}-${arch}.${ext}` instead of using
`${name}`, which expands to `@multica/desktop` for scoped package
names and literally produced files at `dist/@multica/desktop-*.dmg`.
The nested `@multica/` path is useless and makes the GitHub Release
asset URL ugly. New layout is flat: `dist/multica-desktop-<ver>-arm64.dmg`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): keep local package builds working after notarize: true
Three polish items from review of this PR.
- Local dev regression: `mac.notarize: true` in electron-builder.yml
made `pnpm package` hard-fail on macs without APPLE_* env vars, even
for non-publishing local smoke tests. Detect the missing env in
scripts/package.mjs and pass `-c.mac.notarize=false` for that run
only. Real release builds (which source apps/desktop/macOS/.env via
the release-desktop skill) are unaffected. Also logs a clear warning
so the developer knows notarization was skipped.
- spawnSync previously used `shell: true`, which reassembled argv into
a shell command string. Zero real-world injection risk given our
controlled inputs, but dropping it closes the vector at no cost —
pnpm already puts node_modules/.bin on PATH for script runs so the
binary is found without a shell wrapper.
- On spawn failure (e.g. electron-builder not found), result.error was
silently swallowed and the exit was just `1`. Log the underlying
reason before exiting.
Also refactor so normalizeGitVersion is exportable and guard the main
entry behind an import.meta.url check, enabling unit coverage. New
package.test.mjs covers the six branches: null/empty input, clean tag,
between-tags prerelease, dirty suffix, v-prefixed prerelease tags
(vX.Y.Z-alpha and vX.Y.Z-rc.2), and the 0.0.0-<hash> fallback for
hash-only describe output. vitest.config.ts picks up scripts/**/*.test.mjs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(desktop): commit .env.production for release builds
Bake production backend + app URLs into release packages so `pnpm
package` produces a build that points at multica.ai out of the box.
electron-vite (Vite) reads .env.production automatically in production
mode — no script changes needed.
Values:
VITE_API_URL = https://api.multica.ai
VITE_WS_URL = wss://api.multica.ai/ws
VITE_APP_URL = https://multica.ai
Also parameterize the two hardcoded `https://www.multica.ai` strings
in platform/navigation.tsx's `getShareableUrl` on VITE_APP_URL. The
previous hardcoded host pointed to `www.multica.ai`, which disagrees
with the canonical `multica.ai` we're standardizing on. Shareable
links from the desktop ("Copy link to issue") now match.
The env file is public config, not a secret, so add a scoped exception
to the root .gitignore's `.env*` rule.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Issue list JSON now includes total, limit, offset, has_more fields so agents
can detect truncated results and paginate. Also documents --limit/--offset in
the agent prompt and emphasizes mention format in Output section.
Closes MUL-837
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tiptap's React wrapper initialises the menu element with
position:absolute, but computePosition needs position:fixed so
getOffsetParent returns the viewport instead of a positioned ancestor.
On the first show, coordinates were computed relative to the wrong
containing block, causing the menu to fly off-screen (negative coords).
Fix: set position:fixed in the onShow callback, which fires right
before updatePosition(), ensuring computePosition sees the correct
offset parent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the concurrency_policy system (skip/queue/replace) — skip had an
orphan bug that permanently blocked triggers, queue didn't actually queue,
and replace didn't cancel running tasks. Every trigger now simply executes.
Bug fixes:
- Listener now handles in_review status (was silently ignored)
- Issue deletion fails linked autopilot runs before DELETE (prevents orphans)
- ComputeNextRun rejects invalid timezones instead of silent UTC fallback
- dispatchCreateIssue post-commit failures now properly fail the run
Reliability:
- Scheduler recovers lost triggers on startup (crash recovery)
- New index on autopilot_run(issue_id) for deletion lookups
- Migration 043 cleans up historical orphaned/skipped/pending runs
WebSocket event handlers for comment:created and activity:created
appended new entries to the end of the timeline array without sorting.
When events arrived out of order (e.g. agent replying rapidly), comments
displayed out of chronological order.
Sort the timeline by created_at after each append to maintain correct
chronological ordering.
Closes#1032
* fix(agent): restrict custom_env visibility to agent owner and workspace admin
Agent environment variables (custom_env) were visible to all workspace
members, exposing sensitive tokens. Now only the agent owner and
workspace owner/admin can view them — regular members receive the field
omitted (null) from API responses, and the frontend hides the
Environment tab accordingly.
Closes#1018
* fix(agent): show masked env keys to non-authorized users instead of hiding tab
Instead of completely hiding the Environment tab for non-owner/non-admin
users, show the variable keys with masked values (****) in a read-only
view. This lets members see which variables are configured without
exposing the actual values.
- Backend: mask values with "****" instead of nullifying custom_env
- Added custom_env_redacted boolean to API response
- Frontend: EnvTab supports readOnly mode with lock icon and muted styling
* feat(sidebar): replace user menu ellipsis with full-row popover
Remove the three-dot menu from the sidebar footer user profile.
The entire row is now clickable and opens an upward popover showing
the user's full name, email, and a logout button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(sidebar): narrow user popover width
Reduce popover from w-64 to w-48 and tighten internal spacing
to better fit the sidebar proportions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: yushen <ldnvnbl@gmail.com>
* feat(desktop): restart local daemon when bundled CLI version differs
Desktop bundles a multica CLI binary at build time via bundle-cli.mjs.
If a local daemon is already running from a previous session with an
older CLI, the newly bundled version never takes effect until the user
manually restarts. Fix that on the login/auto-start path.
- Expose the daemon's CLI version on GET /health as cli_version (sourced
from cfg.CLIVersion, which is already set from the ldflag at daemon
startup in cmd_daemon.go).
- In the desktop main process, query the resolved CLI binary's version
once via `multica version --output json` and cache it for the process
lifetime.
- On daemon:auto-start, if the daemon is already running, compare the
two versions. Restart only when BOTH sides are known and the strings
differ — a restart kills in-flight agent tasks, so any uncertainty
(bundled CLI unknown, older daemon without cli_version field, read
failure) fails safe and leaves the daemon alone.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(daemon): defer version-mismatch restart until active tasks drain
Previous iteration restarted the daemon immediately on a confirmed CLI
version mismatch, which would kill any agent tasks mid-execution. Gate
the restart on an active-task counter so in-flight work always finishes.
- Daemon: add `activeTasks atomic.Int64` on the Daemon struct,
increment/decrement it around handleTask, and expose it as
`active_task_count` on GET /health.
- Desktop: when a version mismatch is confirmed but active_task_count >
0, set a pendingVersionRestart flag instead of restarting. The 5s
pollOnce loop retries ensureRunningDaemonVersionMatches on each tick
and fires the restart the moment the count drops to 0.
- Eventual consistency: if the user keeps the daemon permanently busy,
the version stays out of date — that's a strictly better failure mode
than silently killing hour-long agent runs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(daemon): cover version-check decision + /health counter exposure
Addresses the test-coverage gap from the second review.
- Go: extract the /health handler into a named method `(d *Daemon)
healthHandler(startedAt time.Time)` so it can be exercised via
httptest without spinning up a listener. Add health_test.go covering
cli_version + active_task_count field exposure and the increment /
decrement protocol used by pollLoop.
- Desktop: extract the pure version-check decision logic into
version-decision.ts (no electron, no I/O, no module state). The
ensureRunningDaemonVersionMatches wrapper now delegates the "what
should we do" decision to decideVersionAction and owns only the side
effects (logging, flag mutation, restartDaemon call).
- Desktop: bolt vitest onto apps/desktop (vitest.config.ts + catalog
devDep + test script) so main-process unit tests have a home. Add
version-decision.test.ts covering all four action branches and the
busy→idle drain transition.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(daemon): bust CLI version cache on retry-install, lock wire-level JSON keys
Two polish items from review.
- daemon:retry-install now also clears cachedCliBinaryVersion. Previously
a retry that landed a newly-downloaded CLI at a different version
would false-negative on the next version check because the cached
version string was sticky for the process lifetime.
- TestHealthHandlerReportsCLIVersionAndActiveTaskCount now decodes into
a raw map[string]any and asserts the exact snake_case keys
(cli_version, active_task_count, status). The desktop TS client keys
on these literal strings, so a silent struct-tag rename must fail the
test. Typed struct round-trip kept as a separate value check.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the three-dot menu from the sidebar footer user profile.
The entire row is now clickable and opens an upward popover showing
the user's full name, email, and a logout button.
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(agents): show runtime owner and add Mine/All filter in Create Agent dialog
Display the runtime owner (with avatar) in the runtime selector dropdown,
matching the pattern used in the Runtime list page. Add a Mine/All toggle
to filter runtimes by ownership, defaulting to "Mine" so the current user's
runtimes appear first.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(agents): show runtime owner and Mine/All filter in agent Settings tab
Apply the same owner display and Mine/All filter pattern to the Settings
tab's runtime selector, matching the Create Agent dialog. Uses ProviderLogo
and ActorAvatar for consistent runtime item rendering across both selectors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(agents): address PR review — use unfiltered runtimes for lookup, simplify IIFE
- Look up selectedRuntime from full `runtimes` array instead of
`filteredRuntimes` to avoid null flash when switching filters
- Replace IIFE with inline optional chaining for owner name display
- Fix indentation on the trigger subtitle div
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The daemon now automatically watches all workspaces the user belongs to,
fetched directly from the API. This removes the manual watch/unwatch
workflow, the config-based watched/unwatched lists, the /watch HTTP
endpoints, the CLI watch/unwatch commands, and the desktop app's watched
workspace UI and reconciliation logic.
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add GET /api/config endpoint exposing cdn_domain from CLOUDFRONT_DOMAIN
- Create packages/core/config/ zustand store, fetched at app startup
- Extract file card preprocessing to packages/ui/markdown/file-cards.ts
with isCdnUrl(url, cdnDomain) using exact hostname match
- Add file card support to packages/ui/markdown/Markdown.tsx (was missing)
- Remove hardcoded .copilothub.ai hostname check from file-card.tsx
- Fix LocalStorage.CdnDomain() to return hostname not full URL
- Always run preprocessFileCards regardless of cdnDomain availability
(!file syntax works without CDN domain, only legacy matching needs it)
- Use useConfigStore hook in common/markdown.tsx for reactive updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix issue mention cards incorrectly triggering Link Hover Card
- Guard editor.view access in BubbleMenu against unmounted/destroyed
view Proxy (fixes desktop Inbox fast-switching crash)
- Use useEditorState for precise formatting state subscriptions in
BubbleMenu instead of relying on parent re-renders
- Add markdownTokenizer to FileCard for unambiguous !file[name](url)
roundtrip syntax (legacy CDN hostname matching kept for compat)
- Extract shared openLink/isMentionHref into utils/link-handler.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- packages/ui/styles/base.css: add `text-autospace: ideograph-alpha
ideograph-numeric` to html. Native CSS feature (Chrome 119+,
Electron recent) that auto-inserts 1/4em space between CJK ideographs
and Latin letters/numerals. Progressive enhancement — older browsers
ignore the rule silently.
- docs/design.md: update font family table to reflect Inter + CJK system
fallback. Reword font-bold ban rationale to be font-agnostic
(information density / layout rhythm), not Geist-specific.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Full-width Chinese punctuation (e.g. ,) was rendering at Latin-font
metrics, making it look half-width in the editor. Root cause: Geist is
Latin-only, and neither web (next/font) nor desktop (@fontsource) declared
any CJK fallback, so CJK chars inherited Geist's em-box width through
Chromium's per-character fallback.
- Web (apps/web/app/layout.tsx): Geist → Inter via next/font/google,
with explicit fallback array: system fonts → PingFang SC (macOS) →
Microsoft YaHei (Windows) → Noto Sans CJK SC (Linux) → sans-serif.
- Desktop: removed @fontsource/geist-sans, added @fontsource-variable/inter
(single variable-weight file replaces 4 static weights). Updated
--font-sans in globals.css to match web's fallback chain.
- Geist Mono kept for code blocks; mono chain has no CJK fallback by
design (CJK is non-aligned in mono grids, listing CJK fonts would
falsely signal alignment guarantees). Added Consolas to web mono for
Windows symmetry with desktop.
- Cross-reference sync comments in both layout.tsx and globals.css:
CJK tail must stay in sync; Inter primary differs by design (next/font
injects `__Inter_xxx` with adjustFontFallback metric override;
fontsource uses raw "Inter Variable").
Currently covers English + Simplified Chinese. When ja/ko i18n lands,
extend fallback tails with Hiragino Kaku Gothic ProN / Yu Gothic /
Apple SD Gothic Neo / Malgun Gothic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(autopilot): add scheduled/triggered automation for AI agents
Introduce the Autopilot feature — recurring automations that assign work
to AI agents on a schedule or manual trigger. Supports two execution
modes: create_issue (creates an issue for the agent to work on) and
run_only (directly enqueues an agent task without issue pollution).
Backend: migration (3 tables + 2 columns), sqlc queries, AutopilotService
with concurrency policies (skip/queue/replace), HTTP CRUD + trigger
endpoints, background cron scheduler (30s tick), event listeners for
issue→run and task→run status sync.
Frontend: types, API client methods, TanStack Query hooks with optimistic
mutations, realtime cache invalidation, list page with create dialog,
detail page with trigger management and run history, sidebar nav + routes
for both web and desktop apps.
* feat(autopilot): improve UX — trigger config, edit dialog, template gallery
- Replace raw cron input with friendly frequency tabs (Hourly/Daily/Weekdays/Weekly/Custom), time picker, and timezone dropdown defaulting to user's local timezone
- Fix Select components showing UUIDs instead of names (Base UI render function pattern)
- Add Edit button on detail page opening a unified edit dialog
- Remove project/concurrency/issue-title-template from create/edit (simplify for users)
- Add trigger configuration inline during autopilot creation
- Add template gallery on empty state (6 step-by-step workflow templates)
- Rename "Description" to "Prompt" throughout UI
- Inject autopilot run timestamp into issue description for agent date awareness
- Treat issue status "in_review" as run completion (fixes skip on next trigger)
- Make migration idempotent with IF NOT EXISTS clauses
The login page now encodes the ?next= param into the Google OAuth state
so the auth callback can redirect to the right destination (e.g.
/invite/{id}) after login, instead of always going to /issues.
The email CTA now deep-links to /invite/{id} instead of the generic app
URL. If the user isn't logged in, they're redirected to login with a
?next= param that brings them back to the invite page.
Changes:
- Backend: GET /api/invitations/{id} endpoint (enriched with workspace/inviter names)
- Backend: Email template now links to /invite/{invitationId}
- Frontend: Shared InvitePage component (packages/views/invite/)
- Frontend: Web route at (auth)/invite/[id], Desktop route at invite/:id
- Frontend: /invite/ excluded from navigation history persistence
Gemini CLI support was added to the backend in v0.1.33 but was missing
from all user-facing documentation and the website. Added Gemini CLI
(and Hermes where missing) to the agents table, quickstart guides,
CLI reference, installation docs, self-hosting guide, and landing page
hero section with logo.
Uses the existing Resend email service to notify invitees.
Email includes inviter name, workspace name, and a link to the app.
Sent fire-and-forget in a goroutine to avoid blocking the API response.
* feat(security): replace instant member-add with invitation acceptance flow
Users invited to a workspace must now explicitly accept the invitation
before becoming a member. This fixes the security vulnerability where
knowing someone's email was enough to auto-register their runtime to
your workspace.
Changes:
- Add workspace_invitation table with pending/accepted/declined/expired states
- Replace CreateMember with CreateInvitation (same endpoint, new behavior)
- Add accept/decline/revoke/list invitation API endpoints
- Add invitation WS events for real-time notification
- Frontend: invitation accept/decline UI in workspace switcher
- Frontend: pending invitations section in members settings tab
* fix(invitation): address PR review nits
- Fix invitation:revoked listener to send event to invitee user (was no-op)
- Remove duplicate queryClient2 in app-sidebar.tsx, reuse existing queryClient
- Add expires_at > now() filter to ListPendingInvitationsByWorkspace query
Remove admin/owner-only restriction from skill creation and import routes.
Add canManageSkill helper that lets skill creators manage their own skills,
matching the existing canManageAgent pattern for agents.
Without a heartbeat, dead or silently-dropped WebSocket connections are
not detected until the next write fails. This causes goroutine and memory
leaks for each stale client, and breaks real-time updates for users whose
connections are dropped by a load balancer or proxy idle timeout (e.g.
Nginx default 60s, AWS ALB default 60s) without a TCP RST.
This commit applies the standard gorilla/websocket keepalive pattern:
- writePump sends a ping frame every pingPeriod (54 s) using a ticker.
The ticker replaces the simple range-over-channel loop with a select,
which also adds a proper write deadline on every write operation.
- readPump installs a pong handler that resets the read deadline on each
pong, keeping healthy connections alive indefinitely. A connection
that misses a pong is detected within pongWait (60 s) and closed,
which causes readPump to exit and send the client to hub.unregister
for clean removal.
Timing constants:
writeWait = 10 s (per-write deadline, prevents hung writers)
pongWait = 60 s (max silence before declaring a connection dead)
pingPeriod = 54 s (ping interval, 90 % of pongWait)
Also adds user_id and workspace_id to the write-error log line so that
connection problems can be attributed to a specific client in production.
All existing hub tests continue to pass unchanged.
Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>
On self-hosted deployments where the frontend is the public entrypoint,
uploaded files return 404 because /uploads/* requests aren't proxied to
the backend. Add a rewrite rule following the existing pattern for /api/*,
/ws, and /auth/*.
Closes#1004
Drops the VITE_REMOTE_API Vite-proxy path introduced in be8b099c.
The remote-backend proxy is no longer needed; direct dev via
VITE_API_URL covers every workflow we still support.
- remove dev:desktop:remote (root) and dev:remote (desktop) scripts
- revert electron.vite.config.ts to a flat config — no loadEnv, no
per-route proxies
- simplify App.tsx: single apiBaseUrl/wsUrl branch, and
DAEMON_TARGET_API_URL derives directly from VITE_API_URL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the CLI config has no watched workspaces (e.g. fresh desktop app
install), loadWatchedWorkspaces returns successfully but registers zero
runtimes. The runtime check immediately after fails with "no runtimes
registered" before workspaceSyncLoop gets a chance to discover
workspaces from the API.
Run one sync cycle inline when the watched list is empty so the daemon
can bootstrap itself without a pre-configured workspace list.
Full-screen modals (create-workspace) covered the app titlebar, so the
Back button landed on top of the macOS traffic lights — where native
hit-test always wins and the button couldn't be clicked. The modal
also swallowed the window's drag region.
Introduce a desktop IPC channel window:setImmersive that calls
BrowserWindow.setWindowButtonVisibility, exposed through the existing
desktopAPI preload bridge. A small useImmersiveMode() hook in
@multica/views/platform toggles it for the component's lifetime and
is a no-op on web / non-macOS.
CreateWorkspaceModal now:
- calls useImmersiveMode() so traffic lights disappear while it's open
- adds a transparent top h-10 drag strip to restore window dragging
- moves the Back button from top-6 left-6 to top-12 left-12 with an
explicit no-drag region so clicks always reach it
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract the main-area top bar into a MainTopBar component so it can
read sidebar state via useSidebar(). When the sidebar is collapsed,
apply pl-20 (80px) to the drag header so the TabBar starts clear of
the macOS traffic-light hit-test region (~x=16..68) that always
wins over HTML clicks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
bundle-cli.mjs now invokes `go build` with the same ldflags as
`make build` (version/commit/date) before copying the binary into
resources/bin/. Running this on every `pnpm dev:desktop`, `dev:remote`
and `package` guarantees the bundled CLI matches the current Go source,
so you can't accidentally ship a stale binary after editing server/
code. Go's build cache makes no-op builds ~a few hundred ms.
Graceful fallback preserved: if `go` is not on PATH (frontend-only
contributor), we warn, skip the build, and let cli-bootstrap download
the latest release at runtime. Compile errors remain fatal so broken
Go code blocks dev rather than silently falling back.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(desktop): add daemon management panel with sidebar status bar
Integrate multica daemon lifecycle management into the desktop app so
users can start/stop/restart the daemon and view live logs without
leaving the UI. Session tokens are automatically synced to the CLI
config file, making daemon authentication transparent.
- daemon-manager.ts: Electron main process module for daemon lifecycle
(health polling, start/stop via CLI, token sync, log tail)
- Preload bridge: new daemonAPI with IPC for all daemon operations
- Sidebar bottomSlot: persistent daemon status indicator in sidebar
footer (desktop-only, injected via AppSidebar slot)
- Daemon panel Sheet: right-side drawer with status details, controls,
and real-time log viewer with auto-scroll and level coloring
- Token sync: on login and app startup, JWT is written to
~/.multica/config.json so daemon can authenticate seamlessly
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(desktop): add P1+P2 daemon features — runtimes card, auto-start, settings
P1: Runtimes page Local Daemon card
- Add topSlot prop to shared RuntimesPage for platform injection
- DaemonRuntimeCard shows status, agents, uptime with Start/Stop/
Restart/Logs buttons (desktop-only, injected via slot)
P2: Auto-start and auto-stop
- Daemon auto-starts on app launch when user is authenticated
(controlled by autoStart preference, default: true)
- Daemon auto-stops on app quit (controlled by autoStop preference,
default: false — daemon keeps running in background by default)
- Preferences persisted to ~/.multica/desktop_prefs.json
P2: Daemon settings tab
- New "Daemon" tab in Settings > My Account section (desktop-only)
- Toggle auto-start and auto-stop behavior
- CLI installation status check with link to install guide
- SettingsPage gains extraAccountTabs prop for platform injection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): address PR review feedback on daemon management
Must-fix:
- before-quit handler now calls event.preventDefault(), awaits
stopDaemon(), then re-calls app.quit() so the daemon actually
stops before the app exits
- Add concurrency guard (operationInProgress lock) in daemon-manager
to reject overlapping start/stop/restart IPC calls
- Extract shared types (DaemonState, DaemonStatus, DaemonPrefs),
constants (STATE_COLORS, STATE_LABELS), and formatUptime to
apps/desktop/src/shared/daemon-types.ts — all renderer components
now import from this single source
Should-fix:
- Log viewer uses monotonic counter (LogEntry.id) instead of array
index as React key, preventing full re-renders on overflow
- All start/stop/restart handlers now show toast.error() with the
error message when the operation fails
- startLogTail retries up to 5 times with 2s delay when the log
file doesn't exist yet (handles first-run case)
Minor:
- Cache findCliBinary() result after first successful lookup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(logger): suppress ANSI color codes when stderr is not a TTY
Detect whether stderr is connected to a terminal and set tint's NoColor
option accordingly. Previously daemon.log files contained raw escape
sequences like \033[2m and \033[92m which made them unreadable in the
Desktop log viewer and any non-TTY sink (docker logs, systemd, etc).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(daemon): runtime watch/unwatch HTTP endpoints and denylist
Add GET/POST/DELETE /watch handlers on the daemon's health port so
clients (notably Desktop) can add or remove watched workspaces at
runtime without restarting the daemon or editing config.json. Each
handler updates in-memory state under d.mu and persists back to
~/.multica/profiles/<name>/config.json for survival across restarts.
- CLIConfig gains UnwatchedWorkspaces as an explicit opt-out denylist.
syncWorkspacesFromAPI skips entries in the denylist so a manual
unwatch isn't silently revived 30s later by the periodic sync.
- loadWatchedWorkspaces tolerates an empty config and returns nil
instead of erroring out, because Desktop starts daemons with a
fresh profile and relies on the sync loop / watch endpoint to
populate the list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(desktop): bundled CLI, per-backend profile, and watch UI
Make the Desktop app self-sufficient: it bundles its own multica
binary, manages its own daemon profile keyed by the backend URL, and
authenticates that daemon with a long-lived PAT it mints on first
login. The daemon panel gains a checkbox list of watched workspaces
and surfaces the active profile + server URL.
CLI bootstrap
- scripts/bundle-cli.mjs copies server/bin/multica into
apps/desktop/resources/bin/ before electron-vite dev and
electron-builder package. asarUnpack: resources/** already covers
this path, so the binary ships with the .app in prod.
- main/cli-bootstrap.ts adds an ensureManagedCli() fallback that
downloads the latest release from GitHub when no bundled binary
exists (first launch on a machine without developer tooling).
- daemon-manager.resolveCliBinary prefers bundled > managed > download
> PATH, so local iteration uses the freshly built binary.
Daemon profile
- resolveActiveProfile now derives a desktop-<host> profile name from
the target API URL and creates its config.json on demand. Never
reads or writes the user's hand-configured CLI profiles, avoiding
the "Desktop polluted my default profile" class of bug.
- syncToken detects a JWT input and exchanges it for a PAT via
POST /api/tokens; caches the resulting mul_* token in the profile
config so subsequent launches skip the round-trip.
- startDaemon / stopDaemon / log tail all operate on the resolved
profile; renderer sets the target URL via a new
daemon:set-target-api-url IPC.
Workspace watching
- daemon-manager exposes daemon:list-watched / daemon:watch-workspace /
daemon:unwatch-workspace IPCs backed by the daemon's new /watch
endpoints.
- App.tsx reconciles the user's workspace list against the daemon's
watched set whenever TanStack Query updates it — new workspaces are
registered instantly instead of waiting for the daemon's 30s sync,
and removed workspaces are unwatched.
- daemon-panel gains a "Watched Workspaces" section with per-workspace
checkboxes that call watch/unwatch directly. Opt-outs persist in the
profile's unwatched_workspaces denylist.
Lifecycle states + UI
- DaemonStatus gains `profile`, `serverUrl`, and an `installing_cli`
state. Panel shows Profile / Server info rows and a "Setting up…"
blurb during first-run CLI download; failure surfaces a Retry button.
- Status bar renders a spinner during installation and hides the Start
button until setup finishes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): register /onboarding route
The create-workspace modal navigates to /onboarding on success, but
the Desktop router only had flat routes (issues, projects, runtimes,
etc.) — resulting in an "Unexpected Application Error! 404 Not Found"
page after creating a new workspace.
Mirror the web app's wiring: render OnboardingWizard with onComplete
pushing to /issues, via the shared navigation adapter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(desktop): remove sidebar daemon status bar
Drop the bottom-left daemon indicator in favor of the DaemonRuntimeCard
at the top of the Runtimes page, which already shows the same info
plus full Start/Stop/Restart controls and the Logs entry point. A
single canonical place avoids fragmenting daemon status across the UI.
Also remove the now-unused `bottomSlot` prop from AppSidebar — Desktop
was the only consumer, Web never needed it, so keeping it would be
dead scaffolding.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): daemon panel layout and close button
- Logs section now fills the remaining vertical space down to the
sheet bottom instead of being capped at h-64, which left a huge
empty area below it. Top section (status, actions, watched list)
keeps natural height as shrink-0; the watched list gets its own
max-h-48 scroll so a long list can't push Logs off screen.
- Replace the Sheet's built-in close button with an explicit
<button> wired directly to onOpenChange(false). The Base UI
Dialog.Close wrapped in Button via the render prop wasn't firing
on click in this panel; going straight through the controlled
state guarantees it responds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): make daemon panel clickable inside Electron drag region
The sheet opens at the top of the window, which visually overlaps the
TabBar's -webkit-app-region: drag zone. Even though the sheet portals
to document.body, Chromium computes drag regions over the final
composited pixels, so the sheet inherited "drag" and swallowed the
mouseup of every click (mousedown fired but click never resolved) —
including the X close button.
Mark the entire SheetContent popup with -webkit-app-region: no-drag
to subtract it from the drag region. This also fixes future buttons /
checkboxes inside the sheet that would have hit the same issue.
While here, move the close button into the SheetHeader as a flex
sibling of SheetTitle instead of an absolutely positioned overlay —
simpler layout and avoids any stacking-context weirdness.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(desktop): clickable daemon runtime card row
The whole Local Daemon row now opens the sheet panel — icon, title,
and status line are all part of one click target. This replaces the
standalone "Logs" button, which was redundant now that clicking
anywhere on the row does the same thing.
The right-side action cluster (Start / Stop / Restart) wraps its
onClick in stopPropagation so pressing those buttons doesn't bubble
up and open the panel.
Keyboard access: Enter / Space on the focused row opens the panel,
with a focus-visible background for feedback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(runtimes): mark Desktop-launched daemons as managed
When the Multica Desktop app spawns the CLI it ships with, the
resulting daemon shares its binary with the Electron bundle — Desktop
is responsible for updating that binary on every release. Letting the
daemon self-update would just get clobbered on the next Desktop launch
and could brick the embedded binary mid-update.
Propagate a "launched_by" signal end-to-end so the UI can hide the
CLI self-update affordance (and the daemon refuses updates as a second
line of defense):
- Desktop's startDaemon spawns execFile with env MULTICA_LAUNCHED_BY=desktop.
- daemon.Config gains LaunchedBy; cmd_daemon reads the env var on boot.
- registerRuntimesForWorkspace includes launched_by in the request body.
- Server DaemonRegister folds launched_by into runtime.metadata (JSONB
— no migration needed).
- handleUpdate returns a "failed" status with an explanatory message
when LaunchedBy == "desktop", so even a bypass API call can't trigger
the self-update path.
- RuntimeDetail extracts metadata.launched_by and passes it to
UpdateSection, which swaps the Latest / → available / Update button
cluster for a muted "Managed by Desktop" label.
CLI-only users (brew install, direct tarball) keep the exact same
behavior — the env var is empty, the UI shows the update button,
the daemon still self-updates on request.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): harden daemon manager from PR review
- syncToken now takes userId and mints a fresh PAT on user switch,
restarting a running daemon so it picks up the new credentials.
A .desktop-user-id sidecar in each profile records the owner so a
previous user's cached PAT can't be reused on the next login.
- App.tsx wires onLogout on CoreProvider to daemonAPI.clearToken()
and daemonAPI.stop() so the cached PAT and live daemon don't
outlive the session.
- startLogTail replaced with a cross-platform watchFile
implementation (initial 32 KB window + poll for new bytes,
handles truncation). spawn("tail") was broken on Windows.
- writeProfileConfig now serializes through a promise chain to
prevent concurrent writes from corrupting config.json.
- startDaemon keeps the "starting" state until pollOnce confirms
/health, avoiding a running → stopped flash when the Go daemon
isn't yet listening after the supervisor returns.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): verify downloaded CLI against checksums.txt
Download goreleaser's checksums.txt alongside the release archive,
parse the sha256 lookup, stream the archive through createHash, and
refuse to install on mismatch or missing entry. Closes the supply-
chain gap where auto-install would execute an unverified binary on
first launch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore(desktop): lint and style cleanups from PR review
- eslint.config.mjs: add scripts/**/*.{mjs,js} override with
globals.node so bundle-cli.mjs lints clean (was erroring on
undefined process/console).
- daemon-panel.tsx: log level classes now use semantic tokens
(text-info, text-warning, text-destructive) instead of hardcoded
Tailwind colors; escape the apostrophe in the retry copy.
- daemon-settings-tab.tsx: import DaemonPrefs from shared/daemon-
types instead of redefining it.
- runtimes-page.tsx: fix indentation inside the new topSlot wrapper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: yushen <ldnvnbl@gmail.com>
Switching to a session whose messages aren't cached showed the empty
state (starter prompts) for the ~300ms the fetch took — jarring, because
you're clicking into an existing conversation, not starting a new one.
Now there's a three-branch render: skeleton while loading, empty state
for real new-chat (activeSessionId === null), messages when ready.
Cached switches still return data synchronously — no flash.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Wrap ChatMessageList and ChatInput in mx-auto max-w-4xl px-5 so wide
chat windows don't sprawl — matches the issue-detail / project-detail
width convention
- draftKey now includes the selected agent id in the new-chat state.
Tiptap's Placeholder only applies at mount, so key-driven remount is
the simplest way to refresh it when the user switches agents before
sending the first message. Side benefit: per-agent new-chat drafts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add Trendshift GitHub Trending badge to READMEs
Add dynamic GitHub Trending badge from Trendshift.io (repo ID 24695)
to both English and Chinese READMEs, placed below existing CI/stars
badges.
* docs: replace Trendshift badge with Star History chart
Remove the Trendshift trending badge and add a Star History chart
section at the end of both English and Chinese READMEs. The chart
supports dark/light mode and links to the interactive star-history page.
* fix(docs): use light theme for Star History chart in both color schemes
Remove &theme=dark from the dark mode source so the chart always
renders with a light background regardless of GitHub's color scheme.
* docs: add Trendshift GitHub Trending badge to READMEs
Add dynamic GitHub Trending badge from Trendshift.io (repo ID 24695)
to both English and Chinese READMEs, placed below existing CI/stars
badges.
* docs: replace Trendshift badge with Star History chart
Remove the Trendshift trending badge and add a Star History chart
section at the end of both English and Chinese READMEs. The chart
supports dark/light mode and links to the interactive star-history page.
Recent issues store was duplicating server data (title, status, identifier)
in Zustand, violating the single-source-of-truth architecture. Now the store
only tracks visit records (id + visitedAt), and the search command joins
fresh data from the TanStack Query issue list cache at render time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
State management
- Pending task / live timeline are now Query-cache single source;
Zustand mirror removed (fixes duplicate assistant render caused by
the invalidate→refetch race window)
- WS subscriptions moved from ChatWindow to global useRealtimeSync so
pending state survives minimize and refresh
- New GET /chat/sessions/:id/pending-task to recover live state on mount
- Drafts persisted per-session (was per-workspace)
Unread tracking
- Migration 040: chat_session.unread_since (event-driven; old chats
stay clean — no mass backfill)
- POST /chat/sessions/:id/read clears unread; broadcasts
chat:session_read so other devices sync
- New GET /chat/pending-tasks aggregate for the FAB
- ChatFab: brand-color impulse animation while running, brand-dot
badge of unread session count
- ChatWindow auto-marks read when user is viewing the session
Header redesign
- Two independent dropdowns: agent (avatar + name + My/Others
grouping) at the input bottom-left; session (title + agent avatar)
in the header
- ⊕ new-chat button replaces the old + and history buttons
- Session dropdown lists all sessions across agents with avatars
- Empty state: 3 clickable starter prompts that send immediately
- Mention link renderer falls through to default span on null —
fixes @member/@agent/@all silently disappearing app-wide
- User messages render through Markdown
- Enter submits in chat input only (with IME guard + codeBlock skip);
bubble menu hidden in chat
Misc
- Partial index on agent_task_queue for fast pending-task lookup
- 2 new storage keys added to clearWorkspaceStorage
- useMarkChatSessionRead has onError rollback
- chat.* namespace logs across store, mutations, components, realtime
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a member replies in a member-started thread without @mentioning the
assigned agent, the on_comment trigger was suppressed — even if the agent
had already replied in that thread. This meant the common flow of
"member posts → agent replies → member follows up" would not re-trigger
the agent on the follow-up.
Add HasAgentRepliedInThread SQL query and check it in isReplyToMemberThread
so that agent participation in a thread is treated as an ongoing conversation.
When `multica login` runs against production (multica.ai), the CLI was
using the app URL hostname as the callback host, producing a callback
URL like `http://multica.ai:PORT/callback`. This URL fails frontend
validation (which only allows localhost and private IPs) and can't
actually reach the CLI's local HTTP server.
Now only private IPs (RFC 1918) are used as the callback host, which
matches the intended self-hosted LAN scenario. Public hostnames
correctly fall back to localhost.
Fixes#974
Add finalFocus={false} to DialogContent in create-issue and create-workspace
modals so Base UI does not attempt focus restoration on close. These modals
are always opened programmatically via useModalStore (no trigger element),
so there is no meaningful element to return focus to.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: clarify that local skills work automatically, workspace skills are for team sharing
Users were confused, thinking they needed to re-upload locally installed skills
(e.g. .claude/skills/) to Multica before agents could use them. Updated UI text
across the skills page, agent skills tab, onboarding, and docs to clearly
distinguish between local skills (auto-discovered) and workspace skills (for
team-wide sharing).
Closes#972
* feat(views): replace inline text with info banner for local skills hint
The "local runtime skills are always available" message was buried in
the description text and easy to miss. Move it into a visible info
callout banner with an icon so users notice it immediately.
Replace the original checklist + manual testing section with a
unified checklist modeled after the Paperclip open-source project:
thinking path, model disclosure, local tests, test coverage,
UI screenshots, documentation, risk assessment, and reviewer comments.
The merge from main introduced `editor?.state.selection.empty` in
ContentEditor. The test mock was missing `state.selection`, causing
a TypeError.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Base UI's DropdownMenu uses FloatingFocusManager which steals focus from
the editor (initialFocus + closeOnFocusOut), causing the BubbleMenu to
hide before dropdown item clicks can register. Popover supports
initialFocus={false} and finalFocus={false}, keeping editor focus intact
throughout the interaction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a new "Manual Testing / Acceptance" section to the PR template
with checklist items for verifying changes in a real environment:
happy path, edge cases, visual regressions, cross-platform testing,
API consumer compatibility, and log inspection.
Move the Environment Variables section from the Settings tab into its
own "Environment" tab (KeyRound icon) between Tasks and Settings. Each
tab now has independent save state.
EditorLinkPreview's useRef initializer accessed editor.view?.dom which
throws when the editor view is not yet mounted (Tiptap uses a Proxy
that rejects property access before mount). Defer the contextElement
assignment to the selectionUpdate callback where the view is guaranteed
to exist.
When a comment-triggered task resumes an existing session, the agent
may mistake the new comment for a previous one and skip it. Add [NEW
COMMENT] tag to the prompt and reinforce in AGENTS.md workflow that
the agent must respond to THIS specific comment, not prior ones.
When a task finishes between the UI rendering the Stop button and the
user clicking it, CancelAgentTask returns no rows. Previously this
surfaced as a 400 error. Now CancelTask checks for pgx.ErrNoRows and
returns the current task state instead of an error.
Closes#954
The onMouseDown preventDefault on HeadingDropdown and ListDropdown
triggers was interfering with Base UI's menu event flow, causing:
- Dropdown appearing at top-left corner (positioning mismatch)
- Menu item clicks not applying formatting
The BubbleMenu plugin's own preventHide mechanism (capture-phase
mousedown listener) already handles preventing the menu from hiding
during dropdown interaction. Our extra preventDefault was redundant
and conflicting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Issue mentions in comments showed only the identifier (no status icon
or title) after page refresh when the referenced issue wasn't in the
issueListOptions cache (e.g. done issues beyond the first 50).
Fall back to issueDetailOptions to fetch the individual issue when it's
not found in the list. The detail query is only enabled when the issue
is missing from the list, so it adds no overhead for the common case.
Show a floating card on link hover with truncated URL, Copy and Open
buttons. Uses @floating-ui/dom computePosition portaled to body
(escapes overflow:hidden). 300ms show delay, 150ms hide delay with
card hover support.
- New link-hover-card.tsx with useLinkHover hook + LinkHoverCard
- Integrated in ContentEditor (disabled when BubbleMenu active)
- Integrated in ReadonlyContent (always active)
- Styled with popover design tokens (matches bubble-menu)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both the useUpdateIssue mutation and the WS onIssueUpdated handler only
invalidated the OLD parent's children query. When parent_issue_id changes,
the new parent's sub-issues list was stale until page refresh.
OpenClaw outputs its --json result as pretty-printed multi-line JSON to
stderr. The line-by-line scanner never found a valid JSON object on any
single line, causing the raw JSON to be returned as the chat response.
After exhausting line-by-line parsing, try parsing the accumulated
output as a whole before falling back to raw text.
Closes MUL-725
Switch Gemini backend from `-o text` (batch output) to `-o stream-json`
(NDJSON streaming) so tool calls, text, and errors are forwarded to the
UI in real time instead of collected at the end.
Parses all Gemini stream-json event types: init, message, tool_use,
tool_result, error, and result — including per-model token usage from
the result stats.
When an agent CLI process hangs (e.g. a tool call blocks on unreachable
I/O), the daemon's scanner blocks indefinitely on stdout, preventing the
Result from ever being sent. This causes tasks to stay in "running"
state permanently with no further events.
Three-layer fix:
1. Agent backends (claude, opencode, openclaw, gemini): add a watchdog
goroutine that closes the stdout/stderr pipe when the context is
cancelled, forcing the scanner to unblock. Also set cmd.WaitDelay
so Go force-closes pipes after 10s if the process doesn't exit.
2. daemon executeAndDrain: add an independent drain timeout (backend
timeout + 30s buffer) with context-aware select on both the message
channel and the result channel, so the daemon never blocks forever.
3. daemon ping path: add context-aware select so pings don't deadlock
if the agent backend stalls.
Closes#925
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The CLI auth callback was hardcoded to localhost, breaking self-hosted
setups where the browser runs on a different machine than the CLI.
- CLI: derive callback host from configured app URL; bind to 0.0.0.0
when the app URL is not localhost so remote browsers can reach it
- Frontend: expand validateCliCallback to accept RFC 1918 private IPs
(10.x, 172.16-31.x, 192.168.x) in addition to localhost
Closes#923
* fix(daemon): prevent duplicate runtime registration on profile switch
The daemon_id included a profile name suffix (e.g. "hostname-staging"),
so switching profiles created a new daemon_id that bypassed the UPSERT
dedup constraint, leaving orphaned runtime records in the database.
Three changes:
- Remove profile suffix from daemon_id — use stable hostname only.
The unique constraint (workspace_id, daemon_id, provider) already
prevents collisions within the same workspace.
- Auto-migrate agents from old offline runtimes to the newly registered
runtime during DaemonRegister (same workspace/provider/owner).
- Add TTL-based GC in the runtime sweeper to delete offline runtimes
with no active agents after 7 days.
Closes MUL-695
* fix(daemon): address code review issues on PR #906
1. Move gcRuntimes() to the main sweep loop — previously it was inside
sweepStaleRuntimes() after an early return, so it only ran when new
runtimes were marked stale. Now it runs every sweep cycle independently.
2. Fix DeleteStaleOfflineRuntimes to exclude runtimes with ANY agent
reference (not just active ones). The FK agent.runtime_id is ON DELETE
RESTRICT, so archived agents also block deletion.
3. Scope MigrateAgentsToRuntime to the same machine by matching
daemon_id LIKE '<current_daemon_id>-%'. This prevents cross-machine
agent migration when the same user has multiple devices.
* fix(daemon): use runtime's owner_id for agent migration, not caller's
The migration was gated on ownerID.Valid which is only true for PAT/JWT
registrations. Daemon token registrations (the common case for background
daemon restarts) had ownerID as zero, skipping migration entirely.
Fix: use registered.OwnerID (preserved via COALESCE on upsert) instead
of the caller's ownerID. This ensures migration runs even when the daemon
re-registers via daemon token after an upgrade.
On Windows, `cmd /c start <url>` treats `&` in the URL as a shell
command separator, truncating the login URL at the first `&cli_state=`
parameter. This causes the OAuth state validation to fail silently,
requiring users to login a second time.
Adding an empty title argument (`""`) before the URL is the standard
Windows fix — `start` interprets the first quoted argument as a window
title, so without it, URLs containing special characters get mangled.
When a user cancels an issue, active agent tasks now get cancelled
automatically. Previously, task cancellation only triggered on assignee
changes — the cancelled status was incorrectly treated like any other
agent-managed status transition.
Closes#926
* fix(storage): scope S3 upload keys by workspace
Upload keys now use `workspaces/{workspace_id}/{uuid}.{ext}` instead of
flat `{uuid}.{ext}`, isolating file storage per workspace. Files uploaded
without workspace context (e.g. avatars) keep the flat key structure.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(storage): scope user uploads under users/{user_id}/ prefix
Non-workspace uploads (avatars, profile images) now use
`users/{user_id}/{uuid}.{ext}` instead of flat `{uuid}.{ext}`,
matching the workspace-scoped pattern from the previous commit.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(storage): fix LocalStorage for nested key paths
- Add MkdirAll before WriteFile to create intermediate directories
for workspace/user-scoped keys
- Fix KeyFromURL to preserve full path after /uploads/ prefix instead
of stripping to just the filename
- Update tests to match new behavior
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(upload): validate ownership before writing to storage
Move Storage.Upload after issue_id/comment_id ownership validation
to prevent orphaned files in S3 when validation fails. Previously,
the file was uploaded first and validation happened after, leaving
files in workspace-scoped S3 prefixes even on rejected requests.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(upload): restore workspace membership check before upload
The membership check was accidentally removed during the upload
reordering refactor. Without it, any authenticated user could upload
files to any workspace by setting the X-Workspace-ID header.
Also restores the comment explaining the 200-on-DB-error behavior.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Tiptap's BubbleMenu plugin with @floating-ui/react-dom for
all floating editor UI (formatting toolbar, link preview cards).
Architecture:
- useFloating({ strategy:"fixed" }) + createPortal(body) escapes
all overflow:hidden ancestors (Card component, scroll containers)
- autoUpdate + contextElement monitors all scroll ancestors for
repositioning; manual update() on transaction for virtual ref changes
- open prop resets isPositioned on visibility change (no stale-position
flash at 0,0)
- display:none for hiding (not return null which causes blur/focus
cycle, not visibility:hidden which leaves transition artifacts)
- No blur listener — portal DOM updates cause false editor blurs;
outside-click + scroll + resize + Escape handle all close cases
Bug fixes:
- BubbleMenu: remove all custom visibility hacks, let selection state
drive show/hide
- Link preview: new shared card (Copy + Open) for editable editor and
readonly markdown, portaled to body with fixed positioning
- TitleEditor: use JSON content format (not HTML interpolation that
loses < > characters)
- Blob URLs: strip from getMarkdown output during upload
- Markdown paste: check clipboard.files first to avoid intercepting
file paste events
- FileCard: escape HTML attributes in preprocessing
- Link extension: enable linkOnPaste, set defaultProtocol to https,
switch URL normalization to protocol blocklist (only block
javascript:/data:/vbscript:)
Dependencies: add @floating-ui/react-dom, remove @tiptap/extension-bubble-menu
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(server): validate workspace membership for subscription targets and file uploads
Closes MED-1 (cross-workspace subscription injection) and MED-2 (file upload
missing workspace member validation) from the security audit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(server): add negative tests for cross-workspace subscription and upload
Address PR review feedback:
- Add tests verifying cross-workspace user_id is rejected with 403 on
subscribe and unsubscribe
- Add test verifying upload with foreign workspace_id is rejected with 403
- Make isWorkspaceEntity explicitly enumerate "member"/"agent" and reject
unknown user types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Make --content and --content-stdin mutually exclusive with explicit error
- Use TrimSuffix instead of TrimRight to only strip the trailing newline
- Return "stdin content is empty" instead of misleading "required" error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Check runCtx.Err() before readErr/waitErr so that context-driven
process kills (timeout, user cancellation) report the correct status
("timeout" or "aborted") instead of "failed".
When exec.CommandContext kills the gemini process, io.ReadAll can
return a non-nil error as a side-effect of the closed pipe. The
previous code checked readErr first, masking the real cause. This
aligns gemini.go with the ordering already used in claude.go and
hermes.go.
Fixes#914
- Guard handleDownload to only trigger from "available" state
- Only allow dismiss when update is available, not during download/ready
- Use shadcn design tokens (text-success) instead of hardcoded colors
* fix(cli): auto-create workspace for new users during setup
When a new user runs `multica setup` and has no workspaces,
the onboarding flow now auto-creates a default workspace
(named "<name>'s Workspace") instead of failing when the
daemon tries to start with zero watched workspaces.
As a safety net, setup commands also skip daemon start
gracefully if no workspaces are configured, instead of
erroring out.
* fix(cli): redirect to web onboarding instead of auto-creating workspace
When no workspaces exist, the CLI now opens the web onboarding wizard
in the browser and polls until the user completes workspace creation.
This reuses the existing 4-step onboarding flow (workspace → runtime →
agent → done) instead of duplicating creation logic in the CLI.
* fix(cli): address code review — token login crash and misleading success msg
1. Token login (`multica login --token`) on a fresh account no longer
crashes: waitForOnboarding uses tryResolveAppURL (returns "" instead
of os.Exit(1)) and falls back to printing manual instructions.
2. Setup commands no longer print "✓ Setup complete!" when onboarding
was not finished. Shows "⚠ Setup incomplete" with next steps instead.
* fix(daemon): prevent duplicate runtime registration on profile switch
The daemon_id included a profile name suffix (e.g. "hostname-staging"),
so switching profiles created a new daemon_id that bypassed the UPSERT
dedup constraint, leaving orphaned runtime records in the database.
Three changes:
- Remove profile suffix from daemon_id — use stable hostname only.
The unique constraint (workspace_id, daemon_id, provider) already
prevents collisions within the same workspace.
- Auto-migrate agents from old offline runtimes to the newly registered
runtime during DaemonRegister (same workspace/provider/owner).
- Add TTL-based GC in the runtime sweeper to delete offline runtimes
with no active agents after 7 days.
Closes MUL-695
* fix(daemon): address code review issues on PR #906
1. Move gcRuntimes() to the main sweep loop — previously it was inside
sweepStaleRuntimes() after an early return, so it only ran when new
runtimes were marked stale. Now it runs every sweep cycle independently.
2. Fix DeleteStaleOfflineRuntimes to exclude runtimes with ANY agent
reference (not just active ones). The FK agent.runtime_id is ON DELETE
RESTRICT, so archived agents also block deletion.
3. Scope MigrateAgentsToRuntime to the same machine by matching
daemon_id LIKE '<current_daemon_id>-%'. This prevents cross-machine
agent migration when the same user has multiple devices.
Combined P0 and P1 improvements to the OpenClaw agent backend, informed
by PaperClip's adapter architecture:
P0 — User experience:
- Streaming output — emit MessageText as NDJSON events arrive in real
time, instead of waiting for the final result blob
- Tool use support — parse and emit MessageToolUse/MessageToolResult
from streaming events, matching Claude and OpenCode backends
- Model & system prompt — pass --model and --system-prompt to the
OpenClaw CLI when configured
P1 — Robustness:
- Hardened JSON parsing — tryParseOpenclawResult requires lines to
start with '{', eliminating fragile brace-scanning that could
false-match JSON fragments in log lines
- Lifecycle event handling — new "lifecycle" event type with phase
tracking (error/failed/cancelled), plus structured error objects
(error.name, error.data.message) matching PaperClip's pattern
- Usage field name variants — parseOpenclawUsage supports multiple
naming conventions (input/inputTokens/input_tokens, cacheRead/
cachedInputTokens/cache_read_input_tokens, etc.) with incremental
accumulation across step_finish events
Backwards compatible with the legacy single JSON blob format.
31 tests covering all new functionality.
Closes MUL-726
* fix(auth): detect cookie-based session during CLI setup flow
When users run `multica setup` after logging into multica.ai, the CLI
redirects to the login page which only checked localStorage for an
existing session. Since the web app stores auth tokens as HttpOnly
cookies (not localStorage), the session was never detected and users
had to log in again.
Now the login page also tries `api.getMe()` (which sends the HttpOnly
cookie automatically) when no localStorage token exists. A new
`POST /api/cli-token` endpoint lets cookie-authenticated sessions
obtain a bearer token to hand off to the CLI.
* fix(auth): prioritise cookie auth over localStorage in CLI setup flow
Address code review feedback: cookie-first detection avoids authorising
the CLI with a stale or mismatched localStorage token. The useEffect now
calls getMe() without a bearer token first (relying on the HttpOnly
cookie), and only falls back to localStorage if cookie auth fails.
handleCliAuthorize uses an authSourceRef to pick the matching token
source — issueCliToken for cookie sessions, localStorage for token
sessions — preventing the click handler from re-reading a potentially
stale localStorage entry.
When NEXT_PUBLIC_WS_URL is not set, the WebSocket URL defaulted to
ws://localhost:8080/ws. This broke real-time features (chat streaming,
live updates, notifications) for self-hosted deployments accessed over
LAN — the browser tried connecting to localhost on the client machine
instead of the Docker host.
Now the web app derives the WebSocket URL from window.location, routing
through the existing Next.js /ws rewrite. This works for localhost, LAN,
and custom domain setups without any extra configuration.
Also adds NEXT_PUBLIC_WS_URL as a Docker build arg for explicit override,
and documents LAN access configuration in SELF_HOSTING_ADVANCED.md.
Closes#896
BREW_PACKAGE="multica-ai/tap/multica" was defined but never used.
All brew install/upgrade/list commands used the bare name "multica",
which could fail to resolve the correct tap formula. Replace all
occurrences with "$BREW_PACKAGE" to match the Go CLI (update.go)
and Makefile behavior.
Workspace creation with duplicate slugs now auto-appends -2, -3, … on
the server side instead of returning 409. The onboarding wizard also
shows an editable Workspace URL field (multica.ai/<slug>) that
auto-generates from the name but can be manually customized.
The Install-CliScoop function referenced multica-ai/scoop-bucket.git
which does not exist, causing errors for Windows users with Scoop
installed. Always use direct binary download instead.
Closes#880
When an agent is triggered via @mention (not as the issue assignee),
the generated CLAUDE.md had no explicit agent identity. The agent would
infer its identity from the issue's assignee field, causing it to skip
work intended for it.
Now CLAUDE.md always includes "You are: <agent-name> (ID: <agent-id>)"
so the agent knows exactly who it is regardless of the issue assignee.
Closes MUL-709
Decouple install.sh from environment configuration — install.sh now only
installs the CLI binary (and optionally Docker via --with-server), while
all environment configuration moves to `multica setup` subcommands.
Key changes:
- install.sh: remove config writes, rename --local to --with-server
- multica setup: add cloud/self-host subcommands with --server-url,
--app-url, --port, --frontend-port flags and --profile support
- Add config overwrite protection with interactive prompt
- Remove redundant commands: `config local`, `auth login` alias
- Replace silent multica.ai fallbacks with explicit errors
- Onboarding wizard: dynamically show correct setup command for
Cloud vs Self-host environments
- Update all docs, landing page, and install scripts for consistency
Claude's stream-json flow can emit the terminal result event while the
child process still waits on open stdin. Closing stdin as soon as the
final result arrives lets the CLI exit cleanly instead of idling until
the daemon timeout fires.
Constraint: Must preserve the existing Claude stream-json protocol and child-process lifecycle
Rejected: Increase ping timeout only | masks the hang without fixing process exit
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Claude stdin handling aligned with the stream-json terminal result semantics; do not defer closure until goroutine teardown
Tested: Reproduced self-hosted runtime ping timeout locally; verified ping succeeds after closing stdin on result; cd server && go test ./pkg/agent
Not-tested: Full make check; Bedrock/Vertex-specific Claude auth flows
Remove the archive button, active/archived grouping, and related
imports from ChatSessionHistory. This also fixes the nested <button>
hydration error (GitHub #875) since the inner archive Button was the
only nested button inside the row's outer <button>.
* fix(comment): set trigger_comment_id to actual reply, not thread root
When a user replies in a thread and @mentions an agent, the enqueued
task's trigger_comment_id was incorrectly set to the parent (thread
root) comment instead of the reply that contained the mention. This
caused the agent to read the wrong comment and miss the user's actual
instructions.
Always pass comment.ID to EnqueueTaskForMention so agents see the
comment that triggered them.
Fixes MUL-708
* fix(task): resolve thread root in createAgentComment for reply triggers
With trigger_comment_id now correctly pointing to the actual reply
(not the thread root), createAgentComment must resolve to the thread
root before posting. Otherwise error/system comments would have
parent_id pointing to a nested reply, making them invisible in the
frontend's flat thread grouping.
Part of MUL-708
Some OpenClaw JSON outputs contain durationMs but lack payloads field.
The original condition rejected these results, causing the agent to
return "openclaw returned no parseable output" instead of the actual
execution result.
Fix by accepting results that have either payloads OR durationMs > 0.
Fixes#830
Co-authored-by: leaderlemon <leaderlemon@users.noreply.github.com>
AuthInitializer only checked for multica_token in localStorage. In
cookie auth mode (introduced by the HttpOnly cookie migration), there
is no localStorage token — so AuthInitializer immediately set the user
to null and triggered a logout redirect on every page load/reload.
Add a cookieAuth code path that calls api.getMe() using the HttpOnly
cookie sent automatically by the browser, matching the auth store's
initialize() logic.
Fixes MUL-705, fixes#864
base-ui's Menu.Item only supports onClick, not onSelect (which is a
Radix UI API). onSelect was being silently ignored, causing heading
and list dropdown actions to never execute.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Track child dropdown open state via ref to prevent blur handler from
hiding the menu while a heading/list dropdown is open
- Hide on ancestor scroll only (not sidebar/dropdown scroll)
- Hoist BubbleMenu options to module constant to avoid excessive plugin
updateOptions dispatches on every render
- Recover bubble menu after scroll via selectionUpdate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a floating toolbar that appears when text is selected in the editor.
Supports inline marks (bold/italic/strike/code), link editing with URL
auto-prefix, heading/list dropdowns, and blockquote toggle. Uses Tiptap's
BubbleMenu with fixed positioning and z-50 to escape overflow containers.
Hides on editor blur and ancestor scroll, recovers on new selection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- cmd_daemon.go: use filepath.Join for PID/log file paths instead of string concat with "/"
- codex_home.go: use os.TempDir() instead of hardcoded "/tmp" for cross-platform fallback
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The sub-issue progress indicator (e.g. "0/2") was undercounting because
it was computed from the client-side issue list, which only loads the
first 50 done issues. Sub-issues marked as done beyond that page were
excluded from both the total and done counts.
Added a dedicated backend endpoint (GET /api/issues/child-progress) that
aggregates child issue counts directly from the database, ensuring
accurate totals regardless of client-side pagination or filtering.
Fixes MUL-702
* feat(views): add keyboard navigation and auto-select to PropertyPicker
Add arrow key (up/down) navigation and Enter key selection to the
searchable PropertyPicker dropdown. When the search narrows results to
a single match, pressing Enter auto-selects it without needing to
arrow-navigate first. Fixes GitHub issue #793.
* fix(views): hide Unassigned option when search filter is active
When the user types a search query in the assignee picker, the
Unassigned option is no longer pinned at the top — it only shows
when there is no active filter.
* feat(views): auto-highlight first result when searching in picker
- Add optimistic update + rollback to archive mutation
- Replace Trash2 with Archive icon (correct semantics)
- Add Tooltip on archive button, replace native title
- Show spinner during archive, toast on error
- Use cn() for className composition
- Align chat window offset to bottom-2 right-2 (match FAB)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use the project's Tooltip component instead of native title attributes
for consistent styling, animation, and accessibility across the app.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On Windows, os.Symlink requires Developer Mode or admin privileges.
Extract symlink creation into platform-specific files: on non-Windows,
behavior is unchanged (os.Symlink). On Windows, try os.Symlink first,
then fall back to directory junctions (mklink /J) for dirs and file
copy for files.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Refactor store to persist raw user intent (chatWidth/chatHeight/isExpanded) with no clamp logic
- Add ResizeObserver-based resize hook for dynamic container tracking
- Add drag-to-resize handles (left, top, corner) with pointer capture
- Expand/Restore button uses visual state (isAtMax) not internal flag
- Open/close animation (scale + opacity from bottom-right)
- Resize animation on button click, instant on drag (isDragging gate)
- Move ChatWindow inside content area (absolute, not fixed)
- Add input draft persistence, remove agent prop from message list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevents console window flash when starting daemon in background on
Windows. This field existed in the original sysproc_windows.go but was
lost during merge conflict resolution.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Main introduced sysproc_unix.go/sysproc_windows.go with a simpler
version of the same refactoring. Our cmd_daemon_unix.go/windows.go
files are more comprehensive (reverse-scan tail, graceful CTRL_BREAK
stop, named constants), so we keep ours and remove the overlapping
sysproc_*.go files. Conflict in cmd_daemon.go resolved using our
function names (notifyShutdownContext, stopDaemonProcess).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Extract magic number 0x00000200 to createNewProcessGroup const
2. Replace os.ReadFile with reverse-scan from EOF in tailLogFile to
avoid loading entire log file into memory
3. Try CTRL_BREAK_EVENT for graceful shutdown before falling back to
process.Kill(); register sigBreak in notifyShutdownContext
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): add Windows installation support (MUL-689)
Add PowerShell install script and Windows binary builds so Windows users
can install the CLI without WSL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): address PR review for Windows install script
- Use GitHub REST API for Get-LatestVersion (PS 5.1 compatible)
- Add SHA256 checksum verification after download
- Use [System.Version] for proper semantic version comparison
- Refactor $arch assignment for readability
- Warn before git reset --hard in Install-Server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(release): add Windows build target to GoReleaser
Add windows to goos list, use .zip archive format for Windows builds,
and extract platform-specific SysProcAttr into build-tagged files to
fix cross-compilation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(release): Windows daemon signal handling and process group
Add CREATE_NEW_PROCESS_GROUP to Windows SysProcAttr so the daemon child
process can receive CTRL_BREAK_EVENT. Extract signal handling into
platform-specific helpers: Unix uses SIGTERM for graceful stop, Windows
uses os.Interrupt (CTRL_BREAK_EVENT).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The frontend's listTaskMessages() was calling /api/daemon/tasks/{id}/messages
which uses DaemonAuth middleware (requires Authorization header). After the
cookie auth migration (#819), cookie-mode sessions don't send an Authorization
header, causing 401 on this endpoint. The 401 then triggers handleUnauthorized()
which clears the workspace context, cascading into 400 errors on all subsequent
requests.
Fix: add GET /api/tasks/{taskId}/messages under regular user auth middleware,
and update the frontend to use it instead of the daemon endpoint.
Closes#833
* feat(onboarding): add full-screen onboarding wizard for new workspaces
Replace auto-provisioned workspace with an interactive 4-step onboarding
wizard: Create Workspace → Connect Runtime → Create Agent → Get Started.
- Remove server-side ensureUserWorkspace() so new users land in onboarding
- Add onboarding wizard in packages/views/onboarding/ (4 steps)
- Wire login/OAuth callbacks to redirect to /onboarding when no workspace
- Add DashboardGuard onboardingPath fallback for workspace-less users
- Sidebar "Create workspace" navigates to /onboarding instead of modal
- Remove CreateWorkspaceModal (replaced by wizard step 1)
- Auto-generate workspace slug from name (no user-facing URL field)
- Unified CLI install flow: install.sh + multica setup (auto-detects local)
- Create onboarding issues on completion with interactive "Say hello" task
* test(auth): update workspace tests to match onboarding flow
Login no longer auto-creates workspaces — new users start with zero
workspaces and create one through the onboarding wizard. Update both
integration and handler tests to assert 0 workspaces after verify-code.
Extract Unix-only syscalls (Setsid, SIGTERM, tail command) into
cmd_daemon_unix.go and provide Windows alternatives in
cmd_daemon_windows.go using CREATE_NEW_PROCESS_GROUP, process.Kill(),
os.Interrupt, and native Go file reading.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Security: add isBlockedEnvKey() blocklist that rejects MULTICA_*
prefix and critical system vars (HOME, PATH, USER, SHELL, TERM,
CODEX_HOME) from custom_env injection
2. Observability: log warnings when json.Unmarshal fails on custom_env
(agentToResponse + claim endpoint)
3. UX: use stable auto-increment IDs for env entry React keys instead
of array index to prevent input focus/state issues on add/remove
* fix(security): use first-message auth for WebSocket instead of URL query param
Token was exposed in URL query parameters (HIGH-4 from security audit),
visible in server/proxy logs, browser history, and referrer headers.
Now non-cookie clients (desktop, CLI) send the token as the first
WebSocket message after the connection opens. Cookie-based auth (web)
continues to work unchanged. Server-side auth priority flipped to
cookie-first.
Closes MUL-580
* fix(security): add auth_ack and fix test JSON construction
Server sends auth_ack after successful first-message auth so the client
knows auth completed before firing reconnect callbacks. Test now uses
json.Marshal instead of string concatenation for the auth message.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(test): update WebSocket integration test for first-message auth
The integration test still passed the token as a URL query param,
causing a timeout since the server now expects first-message auth
for non-cookie clients.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace raw <button> with <Button variant="ghost" size="icon-sm"> in header and history
- Add aria-expanded:bg-accent to agent selector trigger for open state
- Add max-h-60, w-auto max-w-56, truncate to agent dropdown
- Switch FAB to bg-card, chat window to bg-sidebar
- Switch user message bubble from bg-primary to bg-muted, drop text-primary-foreground
- Reduce user bubble max-w from 85% to 80%
- Remove agent avatar from AI messages, make AI content w-full
- Strip arbitrary text-[10px] from AvatarFallback
- Remove manual icon size overrides inside Button components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The create workspace button was hidden behind a hover interaction on the
"Workspaces" label, making it very hard to discover. Replace it with a
standard DropdownMenuItem at the bottom of the workspace list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(issue): default create status to todo instead of backlog
Issues created without an explicit status now default to `todo` so the
local daemon picks them up immediately. Previously they defaulted to
`backlog`, which daemons ignore, leaving new issues silently idle until
a user manually moved them.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(issue): verify create defaults to todo, explicit backlog still works
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Add per-agent custom_env configuration that gets injected into the agent
subprocess at launch time. This enables users to configure custom API
endpoints (ANTHROPIC_BASE_URL), API keys (ANTHROPIC_API_KEY), and cloud
provider modes (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX) without
requiring code changes.
Changes:
- Migration 040: add custom_env JSONB column to agent table
- Backend: custom_env in agent CRUD API + claim endpoint
- Daemon: merge custom_env into subprocess environment variables
- Frontend: env var editor in agent settings (key-value pairs with
visibility toggle for sensitive values)
Closes#816
Related: #807, #809
Main introduced executeAndDrain/mergeUsage refactor. Resolve by keeping
main's refactored structure and re-applying EnvRoot to the switch/case
in runTask. Rename newTestDaemon → newGCTestDaemon to avoid collision
with the helper added in daemon_test.go on main.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The server's WS origin whitelist (added in #819) rejects connections
from localhost dev origins. Desktop app doesn't need Origin-based
security since it runs in Electron with webSecurity disabled.
Strip the Origin header from WS upgrade requests in the main process
so the server's checkOrigin allows the connection.
Desktop Google login flow: click "Continue with Google" → opens default
browser to web login page with platform=desktop → Google OAuth completes
→ web callback redirects to multica://auth/callback?token=<jwt> →
Electron receives deep link, extracts token, completes login.
Changes:
- Register `multica://` protocol in Electron (main process + builder)
- Add single-instance lock with deep link forwarding (macOS + Win/Linux)
- Expose `desktopAPI.onAuthToken` and `openExternal` via preload IPC
- Add `loginWithToken(token)` to core auth store
- Pass `state=platform:desktop` through Google OAuth flow
- Web callback detects desktop state and redirects via deep link
- Desktop renderer listens for auth token and hydrates session
Check for updates on startup via electron-updater. When a new version is
detected, show a notification in the bottom-right corner with download
and restart-to-install actions.
The repoCache.Sync() call in loadWatchedWorkspaces runs synchronous git
clone/fetch operations that can take minutes for large repos. Because
heartbeatLoop and pollLoop only start after loadWatchedWorkspaces returns,
the runtime's last_seen_at is never updated during the sync, causing the
server's sweeper to mark it offline after 45 seconds.
Move repo cache sync to a background goroutine so heartbeat and poll
loops start immediately after runtime registration.
Closes#825
- Switch from pill shape (px-4 py-2) to 40×40 circle (size-10)
- Replace Send icon with MessageCircle
- Add hover scale animation (scale-110) and active press (scale-95)
- Add Tooltip with side=top, sideOffset=10, delay=300ms
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The context cancel in pruneRepoWorktrees was called explicitly after
CombinedOutput inside a loop. Extract to a helper method so defer
cancel() works correctly (scoped to the function, not the loop).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move WriteGCMeta from runTask() to handleTask() so it runs after
task completion, not at start. Mid-task crashes leave orphan dirs
that get cleaned by GCOrphanTTL.
- Strengthen isBareRepo to check both HEAD and objects/ directory.
- Remove empty workspace directories after all task dirs are cleaned.
- Add 30s context timeout to git worktree prune to prevent hangs.
- Add comprehensive unit tests for shouldCleanTaskDir (8 scenarios),
cleanTaskDir, gcWorkspace empty-dir cleanup, isBareRepo, and
WriteGCMeta/ReadGCMeta roundtrip.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(daemon): add fallback for failed session resume
When the daemon tries to resume a prior session (--resume flag for
Claude, --session for OpenCode, session/resume RPC for Hermes) and the
session no longer exists, the agent fails immediately. This adds a
fallback that retries the execution with a fresh session instead of
marking the task as blocked.
Extracts the execute+drain logic into a reusable executeAndDrain method
to avoid code duplication between the initial attempt and the retry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(daemon): narrow session resume retry and merge usage
Address review feedback:
1. Narrow retry trigger: only retry when result.SessionID == "" (no
session was established), not on any failure with PriorSessionID set
2. Merge token usage from both attempts so billing is accurate
3. Log errors when the retry itself fails to start
4. Add unit tests for mergeUsage, fallback behavior, and no-retry
when session was already established
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Isolation directories accumulate indefinitely because they're preserved
for session reuse but never cleaned up after the issue is closed.
This adds a background GC loop that periodically scans local workspace
directories and removes those whose issue is done/canceled and hasn't
been updated for 5 days (configurable via MULTICA_GC_TTL). Orphan
directories with no metadata are cleaned after 30 days.
Changes:
- Write .gc_meta.json (issue_id, workspace_id) at task completion
- Add GET /api/daemon/issues/{issueId}/gc-check endpoint for status queries
- Add gcLoop goroutine to daemon with configurable interval/TTL
- Prune stale git worktree references from bare repo caches each cycle
- New env vars: MULTICA_GC_ENABLED, MULTICA_GC_INTERVAL, MULTICA_GC_TTL,
MULTICA_GC_ORPHAN_TTL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add online status indicator dot on agent & member avatars
Backend:
- Track member presence via WebSocket connections in the Hub
- Broadcast member:online/offline events when users connect/disconnect
- Add GET /api/workspaces/{id}/members/online endpoint
- Add member:online and member:offline event type constants
Frontend:
- Add isOnline prop to ActorAvatar with a status dot at top-right corner
- Green dot = online, gray dot = offline, no dot = status unknown
- Fetch online member list via new query, update optimistically on WS events
- Derive agent online status from existing agent.status field
- Wire online status through ActorAvatar views wrapper (enabled by default)
* fix: address code review — fix hub tests and avatar rounding
1. Hub tests: consume the member:online presence event from the first
connection before asserting on broadcast messages.
2. ActorAvatar: use rounded-[inherit] on the inner wrapper so callers
can override rounding (e.g. rounded-lg for agent list items).
* fix: consume member:online presence event in integration test
Same fix as the hub unit tests — read and discard the member:online
event before asserting on issue:created in TestWebSocketIntegration.
* fix(auth): fall back to token-mode WS for users with legacy localStorage token
Users who logged in before the cookie-auth migration still have multica_token
in localStorage but no multica_auth cookie. Forcing cookieAuth=true for every
session caused their WebSocket upgrade to 401 with only workspace_id in the URL.
Detect the legacy token at boot and run that session in token mode (Bearer HTTP
+ URL-param WS). Pure cookie-mode is used only when no legacy token is present,
so new users get the intended path and legacy users migrate naturally on their
next logout/login cycle (logout already clears multica_token).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(auth): note sunset plan for legacy-token WS fallback
Make the XSS-exposure tradeoff explicit and give future maintainers a
concrete signal (<1% of sessions) for when to delete the compat branch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LoginPage now calls useQueryClient() after the workspace list migration.
Mock it in packages/views tests so render calls don't need wrapping in
QueryClientProvider — setQueryData becomes a no-op spy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a task is triggered by a comment, the agent prompt now includes
the comment content directly. This prevents the agent from ignoring
the comment when stale output files exist in a reused workdir.
Closes#805
workspace:deleted and member:removed handlers were calling fetchQuery
without staleTime: 0. With staleTime: Infinity on the QueryClient, this
returns the cached list (which still contains the deleted/left workspace)
instead of fetching fresh data — so hydrateWorkspace never switches away.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LoginPage now calls useQueryClient() after the workspace list migration.
All test renders need a QueryClientProvider; add a createWrapper() helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- staleTime: 0 on fetchQuery after leave/delete so fresh data is fetched
- setQueryData before switchWorkspace in createWorkspace so sidebar is
consistent on first render
- seed workspaceKeys.list() cache in login, Google callback, and
settings save so the first useQuery(workspaceListOptions()) hit is free
- remove dead onError from WorkspaceStoreOptions (used only by the
deleted refreshWorkspaces action)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously only Claude and Codex had log-scanning-level token usage
reporting (Flow B). This adds scanners for the remaining three runtimes:
- OpenCode: reads JSON message files from ~/.local/share/opencode/storage/message/
- OpenClaw: reads JSONL session files from ~/.openclaw/agents/*/sessions/
- Hermes: reads JSONL session files from ~/.hermes/sessions/
All three are registered in Scanner.Scan() and follow the same
(date, provider, model) aggregation pattern as existing scanners.
- Remove workspaces[] from workspace store — list is server state, belongs in React Query
- Change switchWorkspace(id) → switchWorkspace(ws) — caller provides full object from Query
- Remove createWorkspace/leaveWorkspace/deleteWorkspace store actions (duplicated mutations)
- Remove refreshWorkspaces store action — replaced by qc.fetchQuery + hydrateWorkspace
- Enhance useLeaveWorkspace/useDeleteWorkspace mutations to re-select workspace when current is removed
- useCreateWorkspace mutation now switches to new workspace on success
- AuthInitializer seeds React Query cache on boot to avoid double fetch
- Realtime sync: replace refreshWorkspaces() calls with qc.fetchQuery + hydrateWorkspace
- Sidebar reads workspace list from useQuery(workspaceListOptions()) instead of Zustand
- create-workspace modal and workspace settings tab use mutations directly
- AGENTS.md: rewrite to match current monorepo architecture, pointing to CLAUDE.md
Fixes workspace rename not updating sidebar without page refresh.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds CSP middleware to the global middleware chain as a browser-level
defense against XSS: script-src 'self', object-src 'none',
frame-ancestors 'none', base-uri 'self', form-action 'self'.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Allow agents to pipe comment content through stdin instead of the
--content flag, avoiding shell escaping issues with backticks, quotes,
and other special characters in markdown content.
Usage: cat <<'COMMENT' | multica issue comment add <id> --content-stdin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(auth): migrate auth token to HttpOnly cookie & implement WebSocket Origin whitelist
Security improvements from the MUL-566 audit report:
1. Auth token is now set as an HttpOnly, SameSite=Lax cookie on login,
preventing XSS-based token theft. Cookie-based auth includes CSRF
protection via double-submit cookie pattern. The Authorization header
path is preserved for Electron desktop app and CLI/PAT clients.
2. WebSocket upgrader now validates the Origin header against a
configurable allowlist (ALLOWED_ORIGINS env var), rejecting
connections from unauthorized origins.
Backend: new auth cookie helpers, middleware reads cookie as fallback,
WS handler accepts cookie auth, Origin whitelist, logout endpoint.
Frontend: CSRF token in API headers, cookie-aware auth store and WS
client, web app opts into cookieAuth mode while desktop keeps tokens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auth): address PR review — Strict cookies, HMAC-bound CSRF, origin sync
1. SameSite=Lax → SameSite=Strict per spec requirement
2. CSRF token now HMAC-signed with auth token (nonce.signature format),
preventing subdomain cookie injection attacks
3. allowedWSOrigins uses atomic.Value to eliminate data race
4. Removed magic "cookie" sentinel string in WSProvider — pass null token
and guard with boolean check instead
5. Removed dead delete uploadHeaders["Content-Type"] in API client
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
broadcastTaskDispatch was the only task event broadcast missing issue_id
in its WebSocket payload. The frontend task:dispatch handler had no way
to filter by issue, causing AgentLiveCard to briefly show activity for
the wrong issue when multiple tabs are open.
Closes https://github.com/multica-ai/multica/issues/791
Codex tasks running in workspace-write sandbox mode could not resolve
api.multica.ai because the hardcoded sandbox parameter in thread/start
overrode any config.toml settings, and the default sandbox policy blocks
network access.
Changes:
- Remove hardcoded `sandbox: "workspace-write"` from thread/start RPC —
let Codex read sandbox config from its own config.toml instead
- Auto-generate config.toml in per-task CODEX_HOME with
`sandbox_mode = "workspace-write"` and `network_access = true`,
preserving any existing user settings
- Fix Reuse() to restore CodexHome for Codex provider on workdir reuse
Closes#368
Skills stored under .claude/skills/{name}/SKILL.md (the Claude Code
native discovery convention) were not found during skills.sh import,
causing a 502 error. Add this path to the candidate list.
Fixes#777
BatchUpdateIssues was missing the ancestor-walk cycle detection that
single UpdateIssue has. This allowed creating circular parent
relationships (e.g. A→B→A) via the batch API. Added the same
depth-limited walk (up to 10 ancestors) to detect and skip issues
that would create cycles, consistent with UpdateIssue behavior.
Replace LIMIT $2 with AND date >= $2 in ListRuntimeUsage query. When a
runtime uses multiple models each day has multiple rows, so a row LIMIT
silently returns fewer days than requested.
Also fixes displayName warnings in issue-detail test mocks and adds
missing setOpen to useCallback deps in search-command.
Co-authored-by: jayavibhavnk <jaya11vibhav@gmail.com>
Closes#731
The logout handler was clearing `multica_workspace_id` from storage,
so re-login always defaulted to the first workspace. The workspace ID
is a user preference, not session-sensitive data — keep it so both
web and desktop restore the correct workspace after re-authentication.
Also pass `lastWorkspaceId` in the desktop login page, which was
previously missing.
Bluemonday operates on raw text, so characters like && and <> inside
markdown code blocks/inline code were being HTML-escaped (e.g. && → &&),
causing them to render incorrectly in the frontend.
Now extracts fenced code blocks and inline code spans before sanitization,
runs bluemonday on the remaining content, then restores the code verbatim.
The create-issue modal started importing useFileDropZone and FileDropOverlay
from the editor module, but the test mock was not updated to include them,
causing CI to fail.
- Log a warning when HasActiveTaskForIssue fails, matching the existing
pattern for UpdateIssueStatus errors. Silent failures here make
debugging DB issues unnecessarily difficult.
- Track processed issues to skip redundant GetIssue + HasActiveTaskForIssue
queries when multiple tasks for the same issue are swept in one cycle.
- Rename `filepath` local var to `dest` in LocalStorage.Upload to avoid
shadowing the path/filepath package import
- Remove unused detectContentType and overrideContentType functions from
util.go (no longer needed after ServeFile switched to http.ServeFile)
* feat(storage): add local file storage fallback
- Add local storage implementation for file uploads
- Update .env.example with LOCAL_UPLOAD_DIR and LOCAL_UPLOAD_BASE_URL
- Integrate local storage into server router and handlers
- Add storage abstraction layer with util functions
* ♻️ refactor(storage): improve path handling and file serving
switch from path to filepath for better cross-platform support and replace manual file serving logic with http.ServeFile to enhance security against path traversal. update unit tests to use t.Setenv for cleaner environment variable management.
* chore: add issue templates and improve PR template
Add GitHub issue templates (bug report, feature request) using YAML
forms, referencing hermes-agent's template structure. Update the PR
template with clearer sections for changes made, related issues, and
a more comprehensive checklist.
* chore: add AI disclosure section to PR template
Since most PRs are now authored or co-authored by AI coding tools,
add a dedicated AI Disclosure section to the PR template. Includes
authorship type, tool used, and a human review checklist to ensure
AI-generated code is properly reviewed before merge.
* chore: simplify AI disclosure to focus on prompt sharing
Remove the review-status checklist — it was too heavy and users won't
actually do it. Instead focus on what's useful: which AI tool was used
and what prompt/approach produced the code, so the team can learn from
each other's AI workflows.
* chore: simplify issue templates to lower submission friction
Bug report: just what happened + steps to reproduce (required),
plus an optional context field for logs/env.
Feature request: just what you want and why (required),
plus an optional proposed solution.
Removed all dropdowns, environment fields, checkboxes, and
other fields that discourage users from filing issues.
* chore: add screenshots section to issue templates
Add optional screenshots field to both bug report and feature request
templates so users can attach images for richer context.
Registers `gemini` as a sixth supported agent provider alongside claude,
codex, opencode, openclaw, and hermes.
- Daemon config probes for `gemini` on PATH (MULTICA_GEMINI_PATH /
MULTICA_GEMINI_MODEL env overrides mirror the other providers).
- New agent.geminiBackend in pkg/agent/gemini.go: spawns
`gemini -p <prompt> --yolo -o text [-m <model>] [-r <session>]`,
reads stdout to completion, and returns a single MessageText plus
the standard Result struct (Status / Output / DurationMs).
- Execution environment writes a GEMINI.md file into the task workdir
(mirroring the existing CLAUDE.md / AGENTS.md injection for other
providers) so Gemini discovers the Multica runtime meta-skill
through its native mechanism.
Tests:
- pkg/agent/gemini_test.go — unit coverage for buildGeminiArgs
(baseline, model override, resume session, omit-when-empty).
- internal/daemon/execenv/TestInjectRuntimeConfigGemini — verifies
GEMINI.md is written and that CLAUDE.md/AGENTS.md are NOT.
Scope (intentional for v1):
- Text output only (`-o text`). Streaming tool events via
`--output-format stream-json` is a follow-up once we have a
reliable reproduction of Gemini's event schema.
- No MCP config plumbing. Gemini's `--allowed-mcp-server-names`
filter pairs well with the per-agent MCP work on feat/per-agent-mcp;
stacking the two can land as a follow-up.
- No token usage scraping (Gemini's accounting lives on the Google
Cloud side, not a local JSONL log like claude/codex).
- No session resume wiring beyond accepting the ExecOptions field —
the daemon does not yet persist Gemini session IDs because the text
output mode does not expose them.
Migration / env changes:
- New optional environment variables MULTICA_GEMINI_PATH and
MULTICA_GEMINI_MODEL. Default path is the string "gemini" (resolved
via PATH at daemon startup). If no Gemini install is detected, the
provider is simply absent from the runtime — no behavior change for
existing deployments.
Adds a terminal-style one-click copy block below the CTA buttons showing
the curl install command, with a copy-to-clipboard button that shows a
checkmark on success.
* fix(auth): log email send errors and gracefully degrade in non-production
In non-production environments (APP_ENV != "production"), if sending the
verification code email fails, log the error as a warning and still return
success. This lets self-hosting users log in with the master code (888888)
even when their Resend configuration is incomplete (e.g. unverified from-domain).
In production, the behavior is unchanged — email failures return 500.
Also adds guidance in .env.example about RESEND_FROM_EMAIL for self-hosters.
Closes#723
* fix(auth): remove APP_ENV degradation, keep error logging only
Remove the APP_ENV-based graceful degradation for email send failures
— it's risky if users forget to set APP_ENV=production. Instead, always
return 500 on email failure (safe for production) and rely on the error
log (slog.Error) with the actual Resend error for debugging.
Self-hosters who don't need real emails should leave RESEND_API_KEY empty
(codes print to stdout, master code 888888 works).
<!-- What does this PR do? Keep it to 1-3 sentences. -->
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
## Why
<!-- Why is this change needed? Link the related issue. -->
Closes #<!-- issue number -->
## Related Issue
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
Closes #
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Refactor / code improvement (no behavior change)
- [ ] Documentation update
- [ ] Tests (adding or improving test coverage)
- [ ] CI / infrastructure
- [ ] Other (describe below)
## Changes Made
<!-- List the specific changes. Include file paths for code changes. -->
-
## How to Test
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
1.
2.
3.
## Checklist
- [ ]`make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ]Changes follow existing code patterns and conventions
- [ ]No unrelated changes included
- [ ]I have included a thinking path that traces from project context to this change
- [ ]I have run tests locally and they pass
- [ ]I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge
## AI Disclosure (optional)
## AI Disclosure
<!-- If AI tools were used: -->
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
**Prompt / approach:**
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
## Screenshots (optional)
<!-- If applicable, add screenshots showing the change in action. -->
-`packages/core/` — zero react-dom, zero localStorage, zero process.env
-`packages/ui/` — zero `@multica/core` imports
-`packages/views/` — zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
-`apps/web/platform/` — only place for Next.js APIs
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
| Feature | Purpose | Exports |
|---|---|---|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
- Do not use React Context for data that can be a zustand store.
**Store conventions:**
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
### Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
### Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm build # Build frontend
make dev # Auto-setup + start everything
pnpm typecheck # TypeScript check
pnpm lint# ESLint via Next.js
pnpm test# TS tests (Vitest)
# Backend (Go)
make dev # Run Go server (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries to server/bin/
make cli ARGS="..."# Run multica CLI (e.g. make cli ARGS="config")
pnpm test# TS unit tests (Vitest)
make test# Go tests
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single TS test
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make check# Full verification pipeline
```
### CI Requirements
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
make start-worktree # Start using .env.worktree
```
## Coding Rules
- TypeScript strict mode is enabled; keep types explicit.
- TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons.
- Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`.
- Go code follows standard Go conventions (gofmt, go vet). Use domain-oriented filenames like `issue.go` or `cmd_issue.go`.
- Do not hand-edit generated code in `server/pkg/db/generated/`.
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
- Avoid broad refactors unless required by the task.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
- **Feature-specific components** → `features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module.
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
- When unsure about interaction or state design, ask — the user will provide direction.
## Testing Rules
- **TypeScript**: Vitest with Testing Library. Shared test setup lives in `apps/web/test/`. Mock external/third-party dependencies only.
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
- End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running.
- Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
## Commit & Pull Request Rules
- Use atomic commits grouped by logical intent.
- Conventional format with scopes:
-`feat(web): ...`, `feat(cli): ...`
-`fix(web): ...`, `fix(cli): ...`
-`refactor(daemon): ...`
-`test(cli): ...`
-`docs: ...`
-`chore(scope): ...`
- Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes.
- Before opening a PR, run `make check` or the relevant frontend/backend subset.
## Minimum Pre-Push Checks
```bash
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
```
Run verification only when the user explicitly asks for it.
- If any step fails, read the error output, fix the code, and re-run `make check`
- Repeat until all checks pass
- Only then consider the task complete
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
## E2E Test Patterns
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
```
### CI Requirements
@@ -133,6 +134,7 @@ make start-worktree # Start using .env.worktree
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Avoid broad refactors unless required by the task.
- 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.
### Package Boundary Rules
@@ -161,7 +163,7 @@ When the two apps need different behavior for the same concept (e.g., different
When adding a new page or feature:
1.**New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2.**Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
2.**Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.**Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
3.**Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4.**Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5.**Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
@@ -175,6 +177,79 @@ Both apps share the same CSS foundation from `packages/ui/styles/`.
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
### Route categories
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.**`WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace identity singleton
`setCurrentWorkspace(slug, uuid)` in `@multica/core/platform` is the single source of truth for "which workspace is active right now". Three consumers depend on it:
1. API client's `X-Workspace-Slug` header.
2. Zustand per-workspace storage namespace.
3. Chrome gating (`{slug && <AppSidebar />}` on desktop, similar on web).
Normally set by `WorkspaceRouteLayout` when its route mounts. Critically: **unmount does NOT clear it.** Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call `setCurrentWorkspace(null, null)` explicitly — otherwise the realtime `workspace:deleted` handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and `useWorkspaceId` throws.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order:
1. Read destination from cached workspace list (no extra fetch).
2.`setCurrentWorkspace(null, null)`.
3.`navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
4. THEN `await mutation.mutateAsync(workspaceId)`.
Reversing step 4 with steps 1–3 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS window-move)
Every full-window desktop view (login, onboarding, new-workspace, invite, no-access, create-workspace modal) — i.e. anything that isn't inside the dashboard shell — needs a top drag strip so users can move the window. The native macOS traffic lights are **kept visible** for every such surface (Linear/Notion/Arc pattern); no `useImmersiveMode` by default.
**Pattern**: use the shared `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root. It's a 48px transparent row with `-webkit-app-region: drag` — the parent's bg fills through it so the page reads edge-to-edge while the top 48px stays draggable under the traffic lights.
{/* page content — interactive elements placed at y ≥ 48 clear the strip;
any element at y < 48 needs WebkitAppRegion: "no-drag" */}
</div>
</div>
);
```
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Web browsers silently ignore `-webkit-app-region`, so shared views render the strip as a plain 48px spacer on web — safe cross-platform.
**Horizontal clearance**: traffic lights occupy roughly x ∈ [16, 76] on macOS. Interactive UI (Back buttons, menus) should start at x ≥ 80 on desktop-sized viewports. The shared views default to sufficient `lg:px-20` padding; re-examine when laying out anything in the top-left corner.
Canonical example: `packages/views/platform/drag-strip.tsx`. Used by `onboarding/steps/step-welcome.tsx` (per-column), `onboarding/onboarding-flow.tsx`, `workspace/new-workspace-page.tsx`, `invite/invite-page.tsx`, `workspace/no-access-page.tsx`, `modals/create-workspace.tsx`, and desktop's `pages/login.tsx`.
**When to use `useImmersiveMode`**: only when a view must place interactive UI in the traffic-light hit-zone (y < 28 AND x < 80). For every current non-dashboard surface, buttons sit at y ≥ 48, so immersive mode is unnecessary. Hook is preserved as an escape hatch but has no callers.
### UX vs platform chrome
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (tab system interaction, native-window IPC, `useImmersiveMode`) lives in desktop-only code. The `DragStrip` + `useImmersiveMode` primitives live in `packages/views/platform/` because they're cross-platform safe (web no-op) and need to be callable from shared views that own the page layout — keeping them in desktop-only would force every shared page to leave top-padding decisions to the platform shell, fragmenting the design.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
belongs to a workspace and can optionally have a lead (member or agent).
`multica setup` detects whether a local Multica server is running, configures the CLI, opens your browser for authentication, and starts the daemon — all in one step.
`multica setup` configures the CLI, opens your browser for authentication, and starts the daemon — all in one step. Use `multica setup self-host` to connect to a self-hosted server instead of Multica Cloud.
## Configuration
@@ -349,15 +462,6 @@ multica config show
Shows config file path, server URL, app URL, and default workspace.
multica config local --port 9090 --frontend-port 4000# Custom ports
```
Sets `server_url` and `app_url` for a local Docker Compose deployment in one command.
### Set Values
```bash
@@ -366,6 +470,63 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
### List Autopilots
```bash
multica autopilot list
multica autopilot list --status active --output json
```
### Get Autopilot Details
```bash
multica autopilot get <id>
multica autopilot get <id> --output json # includes triggers
```
### Create / Update / Delete
```bash
multica autopilot create \
--title "Nightly bug triage"\
--description "Scan todo issues and prioritize."\
--agent "Lambda"\
--mode create_issue
multica autopilot update <id> --status paused
multica autopilot update <id> --description "New prompt"
multica autopilot delete <id>
```
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
### Manual Trigger
```bash
multica autopilot trigger <id> # Fires the autopilot once, returns the run
Only cron-based `schedule` triggers are currently exposed via the CLI. The data model also defines `webhook` and `api` kinds, but there is no server endpoint that fires them yet, so they're not surfaced here.
This downloads the latest Windows binary from GitHub Releases, installs it to `%USERPROFILE%\.multica\bin\`, and adds it to your user PATH.
Verify:
```powershell
multicaversion
```
**If this fails:**
- Restart your terminal so the updated PATH takes effect.
- If you use Scoop, the installer will use it automatically: `scoop bucket add multica https://github.com/multica-ai/scoop-bucket.git && scoop install multica`
- If your execution policy blocks the script: `Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned` then re-run.
---
## Step 3: Log in
@@ -136,12 +166,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude` or `codex`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
---
@@ -155,12 +185,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`)
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) or [Codex](https://github.com/openai/codex) (`codex`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL:=help
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
##@ Help
help:## Show available make targets and common local workflows
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev\033[0m Bootstrap the current checkout and start everything\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
@@ -31,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and**OpenCode**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
Use this if Homebrew is not available. The script installs the Multica CLI on macOS and Linux by using Homebrew when it is on `PATH`, otherwise it downloads the binary directly.
After installation:
### Windows (PowerShell)
```bash
multica login # Authenticate (opens browser)
multica daemon start # Start the local agent runtime
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
---
## Getting Started
### 1. Log in and start the daemon
### 1. Set up and start the daemon
```bash
multica login# Authenticate with your Multica account
multica daemon start # Start the local agent runtime
multica setup# Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
### 2. Verify your runtime
@@ -94,7 +108,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -102,6 +116,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
| **User model** | Multi-user teams with roles & permissions | Single board operator |
@@ -142,7 +170,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
## Development
@@ -157,3 +185,13 @@ make dev
`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.
# 2. Configure CLI, authenticate, and start the daemon
multica setup self-host
```
This automatically clones the repository, starts all services via Docker Compose, and installs the `multica` CLI.
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
Once complete, open http://localhost:3000, log in with any email + verification code **`888888`**, then:
```bash
multica login # Authenticate (opens browser)
multica daemon start # Start the agent daemon
```
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.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
> **CLI only?** If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew:
>
> ```bash
> brew install multica-ai/tap/multica
> ```
---
@@ -49,6 +54,10 @@ make selfhost
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
Once ready:
- **Frontend:** http://localhost:3000
@@ -58,9 +67,15 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** 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`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **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.
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`.
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
@@ -14,6 +14,15 @@ All configuration is done via environment variables. Copy `.env.example` as a st
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Database Pool Tuning (Optional)
These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_MAX_CONNS` | pgxpool max connections per pod. `pod_count × DATABASE_MAX_CONNS` should stay well below the Postgres `max_connections` ceiling. With a connection pooler (PgBouncer / RDS Proxy / Supavisor) in front, this can be raised significantly. | `25` |
| `DATABASE_MIN_CONNS` | pgxpool warm baseline connections per pod. Auto-clamped to `DATABASE_MAX_CONNS`. | `5` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
@@ -23,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
> **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.
### Google OAuth (Optional)
@@ -33,6 +42,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
@@ -44,7 +65,14 @@ For file uploads and attachments, configure S3 and CloudFront:
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
### Server
@@ -66,6 +94,27 @@ These are configured on each user's machine, not on the server:
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
@@ -197,12 +246,32 @@ When using separate domains for frontend and backend, set these environment vari
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (set before building the frontend image)
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## LAN / Non-localhost Access
By default, Multica works on `localhost`. If you access it from another machine on the LAN (e.g. `http://192.168.1.100:3000`), you need to tell the backend to accept that origin:
```bash
# .env — replace with your server's LAN IP
FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then restart the stack:
```bash
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.
> **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`.
## Health Check
The backend exposes a health check endpoint:
@@ -217,8 +286,9 @@ Use this for load balancer health checks or monitoring.
## Upgrading
```bash
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
@@ -11,7 +11,7 @@ Go to [multica.ai](https://multica.ai) and create an account.
## 2. Install the CLI and start the daemon
Give this instruction to your AI agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):
Give this instruction to your AI agent (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, etc.):
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
@@ -19,17 +19,33 @@ Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow
Or install manually:
```bash
# Install
brew tap multica-ai/tap
brew install multica
### macOS / Linux (Homebrew - recommended)
# Authenticate and start
multica login
multica daemon start
```bash
brew install multica-ai/tap/multica
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
Then configure, authenticate, and start the daemon:
```bash
# Configure, authenticate, and start the daemon
multica setup
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
## 3. Verify your runtime
@@ -39,7 +55,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
## 4. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
# Configure CLI, authenticate, and start the daemon
multica setup self-host
```
This clones the repo, starts all services, installs the CLI, and configures everything. Then:
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.
1. Open http://localhost:3000 — log in with any email + code **`888888`**
2. Run `multica login` and `multica daemon start`
<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`.
</Callout>
<Callout>
For a step-by-step setup, see below.
@@ -48,21 +53,31 @@ make selfhost
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
<Callout>
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
</Callout>
### Step 2 — Log In
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to 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:
- **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.
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>
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
<Callout>
Your local Docker services are unaffected. Stop them separately if you no longer need them.
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
---
@@ -178,6 +198,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
@@ -189,7 +221,14 @@ For file uploads and attachments, configure S3 and CloudFront:
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
### Server
@@ -211,6 +250,23 @@ These are configured on each user's machine, not on the server:
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
| Hermes | `hermes` | Nous Research coding agent |
The daemon auto-detects which CLIs are available on your PATH and registers them as available runtimes.
## Reusable Skills
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
Multica supports two layers of skills:
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
- Deployments
- Migrations
- Code reviews
- Common patterns
Skills are shared across the workspace, so any agent (or human) can leverage them.
Your skill library compounds over time. Local skills give individual agents their capabilities; workspace skills align the entire team.
@@ -5,14 +5,13 @@ description: Assign your first task to an agent in under 5 minutes.
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent.
## 1. Log in and start the daemon
## 1. Set up and start the daemon
```bash
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) available on your PATH.
## 2. Verify your runtime
@@ -22,7 +21,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
## 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
@@ -7,7 +7,7 @@ description: Multica — the open-source managed agents platform. Turn coding ag
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **Gemini CLI**, **OpenClaw**, **OpenCode**, and **Hermes**.
## Features
@@ -24,7 +24,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
| Agent Runtime | Local daemon executing Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes |
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.