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
* 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.
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
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
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.
* 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>
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>
* 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.
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
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>
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>
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>
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>
* 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.
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
* 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.
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>
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>
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.
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>
* 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.
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>
* 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>
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>
* 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
* 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>
* 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(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>
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>