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