Use the same env var name as the desktop renderer so one variable name
controls both apps. The desktop renderer is bundled by Vite, which only
exposes VITE_-prefixed vars to client code, so the shared name must carry
the VITE_ prefix; web reads it server-side where the name is unconstrained.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the web wiring for the Electron renderer: injects the react-grab
overlay (hold ⌘C / Ctrl+C + click to copy an element's source path +
component stack) only when VITE_REACT_GRAB is set in a local, gitignored
apps/desktop/.env.development.local. Guarded by import.meta.env.DEV so the
branch is tree-shaken out of production builds; never activates for other
developers. No CSP/sandbox blocks the unpkg script (webSecurity is off).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loads the react-grab overlay (hold ⌘C / Ctrl+C + click to copy an
element's source path + component stack) only when REACT_GRAB is set in
a local, gitignored apps/web/.env.local. Both the NODE_ENV and REACT_GRAB
guards are evaluated server-side in the root layout, so the <Script> tag
is omitted from the HTML for anyone who hasn't opted in — no effect on
other developers or production.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
- expose `--callback-host` on `multica setup` and `multica setup cloud`, reusing the existing login callback override
- print an SSH tunnel hint when browser login runs in an SSH session with a loopback callback
- show local flags in parent-command help so `multica setup --help` documents the callback option
Fixes#4357
## Tests
- `go test ./cmd/multica -run 'TestCallbackHostFlagValueReadsParentSetupFlag|TestSetupHelpShowsCallbackHostFlag|TestSetupCallbackHostFlagWiring|TestBrowserLoginInstructionsSSHRemoteHint'`
- `go run ./cmd/multica setup --help`
- `go run ./cmd/multica setup cloud --help`
- `git diff --check`
- `go test ./...`
* fix(projects): require admin for project deletion
* test(projects): clean up orphaned member rows in delete-permission helper
The schema uses no foreign keys or cascades, so deleting the test user
left its member row behind in the shared test workspace, polluting later
tests in the package. Delete the member row before the user in both the
pre-seed cleanup and t.Cleanup.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): drop WS frames without a string type (MUL-3418)
The onmessage handler dispatched every parsed frame to onAny and the
ws.on subscribers without validating its shape. A frame whose parsed JSON
lacked a string `type` (an out-of-protocol frame injected by a proxy /
extension, or a bare JSON primitive) reached the realtime-sync onAny
dispatcher, where `msg.type.split(":")` threw an uncaught TypeError out of
onmessage. The browser reported it via window.onerror, flooding PostHog
`$exception` (~12k events). Non-fatal (no crash, connection stays up), but
pure noise.
Validate once at this trust boundary: a frame must be an object carrying a
string `type`, otherwise drop it as a no-op. The bad-frame log is
rate-limited to one entry per connection — a misbehaving source can repeat
the frame hundreds of times per session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(analytics): drop benign ResizeObserver exceptions from telemetry
ResizeObserver "loop ..." errors are the dominant `$exception` bucket
(~31k events / ~1.5k users). They are a benign, self-recovering browser
quirk with no actionable signal and otherwise drown real failures and burn
the event budget. Drop them entirely in before_send (ahead of redaction
and the dedupe fuse, which only caps repeats). The match is narrow — only
the benign "loop" phrasing — so a genuine ResizeObserver bug still reports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test: enable -race detector in Go test pipeline (WOR-61)
Add the -race flag to all three Go test invocation sites so the existing
concurrency regression harness (workdir_race_test.go for #3999,
runtime_gone_test.go, runtime_profile_drift_test.go) actually exercises
the race detector. The daemon package alone has 28+ goroutine launch
points with no automated race coverage before this change.
Sites updated:
- Makefile:299 (make test, local)
- .github/workflows/ci.yml:101 (CI backend job)
- .github/workflows/release.yml:55 (release verify job)
go test already runs a vet subset by default, so no separate -vet flag
is added. No production code touched.
Co-authored-by: multica-agent <github@multica.ai>
* test(execenv): serialize runtimeGOOS-mutating test (WOR-61)
TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged called
t.Parallel() while mutating the package-level runtimeGOOS to drive the
windows/linux branches, racing with the other parallel tests that read
runtimeGOOS in buildMetaSkillContent. The -race flag enabled in the
prior commit surfaced it as 3 WARNING: DATA RACE reports and 11
"race detected" failures in CI (only the execenv package failed).
Drop t.Parallel() and add the "// Not parallel: mutates the package-level
runtimeGOOS." comment already used by the six sibling writer tests across
execenv_test.go and reply_instructions_test.go. This is test-isolation
only; no production code, no mutex/atomic, no signature change.
Verified locally:
go test -race -count=1 ./internal/daemon/execenv/ -> ok 2.276s
go test -race -count=1 ./internal/daemon/... -> all 3 pkgs ok
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: hzz <331380069@qq.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): discover local skills from ~/.agents/skills (MUL-3333)
Upgrade local skill discovery and import from a single provider root to an
ordered multi-root scan: the runtime's own skill directory (e.g.
~/.claude/skills) first, then the cross-tool universal root ~/.agents/skills.
- Rename localSkillRootForProvider -> localSkillRootsForProvider, returning
ordered roots [provider, universal] with a kind classifier.
- listRuntimeLocalSkills iterates the roots, gives each root its OWN visited
set (so a cross-root symlink alias is not collapsed), dedupes strictly by
Key with the provider root winning, and sorts once after the merge.
- loadRuntimeLocalSkillBundle walks the same priority order and only falls
through to the next root on os.IsNotExist; any other stat error is returned
so import never silently resolves a different same-key skill.
- Add a Root ("provider" | "universal") field to the local skill summary
(daemon + handler structs and the TS RuntimeLocalSkillSummary type) so the
UI can label a skill's origin without a future schema break.
Backward compatible: every skill visible today keeps its Key, SourcePath and
FileCount; the universal root only surfaces additional, non-conflicting skills.
Out of scope (follow-up issues): execution-time injection of ~/.agents/skills
into runtimes (e.g. Codex seedUserCodexSkills) and workspace-relative
.agents/skills discovery.
Tests cover universal-root discovery + import, provider-wins conflict
priority, both-roots merge, missing/both-missing roots, nested layouts,
IsNotExist fallback, the no-fallthrough-on-read-error guarantee, and the
per-root visited cross-root symlink alias. Docs updated in en/zh/ja/ko.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): fall through to next root when a same-key dir has no SKILL.md
loadRuntimeLocalSkillBundle previously only fell through to the next root on
os.IsNotExist for the skill DIRECTORY. A provider-root directory that shares a
skill's key but contains no SKILL.md (so listRuntimeLocalSkills descends past
it and surfaces the universal-root skill instead) made load stop on the
invalid provider dir and error — list and load disagreed, and the import the
user picked from the list could not be fetched.
Make the validity predicate match list: a root "has" the skill at a key only
when it is a directory containing a SKILL.md. A missing entry, a non-directory,
or a directory without a SKILL.md all mean "this root doesn't have it" and we
continue to the next root. Only a genuine non-IsNotExist stat error or an
unreadable existing SKILL.md (permission/IO) is returned, so we still never
silently substitute a different-content same-key skill from a lower-priority
root (Eve review #1, preserved by the existing read-error guard test).
Adds regression tests for the provider-dir-without-SKILL.md and provider-non-dir
fall-through cases.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
In context mode (chat) a query merges context items (current page / recently
viewed) with search results into one popup. The old contextLayout made only the
"Recent" group scrollable (`min-h-0`) while every other group was `shrink-0`, and
the Recent `<section>` did not clip its own overflow. When the search groups
(Users/Issues) filled the height, the flex algorithm squeezed Recent toward zero
and its un-clipped rows painted on top of the groups below — the overlap users saw.
Collapse the two render branches into a single `overflow-y-auto` flex column so
every group just stacks and the whole popup scrolls; no group can collapse onto
another. The context variant only differs in width / max-height / chrome.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(desktop): coerce commit-hash versions to valid semver
normalizeGitVersion checked only the first character (/^\d/) to tell a real
version from a bare commit hash. A hash beginning with a digit (e.g.
'2f24057b') passed that check and was stamped as the app version, but bare
'2f24057b' is not valid semver, so electron-updater threw on launch.
Require a full major.minor.patch prefix; anything else (including a
digit-leading hash) falls back to 0.0.0-<hash> as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(desktop): prefix bare-hash fallback with `g` for valid semver
normalizeGitVersion coerced an untagged build's bare commit hash into
`0.0.0-<hash>`, but that is not guaranteed valid semver: an all-digit
short hash with a leading zero (e.g. `0123456`) produced `0.0.0-0123456`,
and a numeric semver pre-release identifier must not have a leading zero.
electron-updater then threw on launch for exactly the untagged builds
this fallback exists to protect.
Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
shorthand) so the pre-release is always a single alphanumeric identifier.
Add a regression test for the all-digit leading-zero case.
Addresses PR #4183 review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
#4288 swapped the chip cap from a fixed `max-w-72` to `max-w-[min(18rem,100%)]`.
A percentage max-width on a flex item is dropped while its flex-container wrapper
(`<a class="inline-flex">`) computes its own max-content size, so the wrapper
ballooned to the untruncated title width while the chip truncated to the cap —
leaving an empty, clickable strip after the visible chip.
Fix it as standard atomic-inline behavior instead of a fixed magic cap:
- IssueChip caps at `max-w-full` with the title truncating to an ellipsis, so it
wraps to the next line as a unit and only truncates once a whole line can't
hold it.
- Drop `inline-flex` from the editor NodeView `<a>` (issue + project) and the
markdown AppLink so the chip's only wrapper is a plain inline box with a
definite (line-based) percentage basis — no second flex container to balloon.
ProjectChip uses a definite `max-w-72`, so it never hit the gap; left as-is.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a dry-run-first operator command for historical Codex usage cache correction, packages it in the backend image, documents the operator-job flow, and covers execution with DB-backed tests.
Bundles the MUL-3404 disk-usage feature with the two Preflight BLOCK fixes.
- feat(daemon): `disk-usage --all-profiles` aggregates across every workspace
root (default + each ~/.multica/profiles/* root, incl. the Desktop app's),
with a per-root breakdown and combined grand total; the cross-root hint now
also fires when the current root is non-empty.
- fix(db): drop DB-level foreign keys/cascades from the new autopilot_subscriber
and comment.source_task_id migrations (resolved in the app layer — autopilot
delete now removes subscribers in a transaction); the autopilot_subscriber
down-migration relabels reason='autopilot' to 'manual' instead of deleting.
- fix(server): readiness verifies every required migration is applied, not just
the lexically-last one, so an out-of-order migration can't be masked.
MUL-3404.
* fix(daemon): reclaim autopilot_run workdir on terminal status (MUL-3403)
Autopilot run workdirs are never reused — there is no PriorWorkDir path
that hands a later run the same directory, so every run gets a fresh one.
Yet GC waited the full GCTTL (default 24h) before reclaiming a terminal
run's dir. Combined with one fresh dir per run, high-frequency autopilots
piled up hundreds of stale dirs (508 dirs / 22GB in the field report).
Drop the TTL gate so a terminal run (completed/failed/skipped/
issue_created) is reclaimed immediately, mirroring gcDecisionQuickCreate.
Existing safety constraints are untouched: active-env-root short-circuit,
404 -> orphanByMTime, non-404 error -> skip, and the local_directory
override all still apply.
Co-authored-by: multica-agent <github@multica.ai>
* docs(daemon): fix GetAutopilotRunGCCheck comment — completed_at is not a TTL anchor
The endpoint comment still claimed the daemon uses completed_at as the TTL
anchor for terminal runs. GC now decides purely on terminal status (the
workdir is never reused, so a terminal run is reclaimed on sight);
completed_at is returned for the API contract / diagnostics only. Addresses
the review nit on #4287.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The create-workspace and onboarding UI hardcoded `multica.ai/` as the
workspace slug URL prefix, so self-hosted deployments showed the wrong
domain. Add a `workspaceUrlHost` helper that derives the host from the
deployment's app URL (`daemon_app_url` from `/api/config`, via the config
store) and falls back to the brand host when none is configured, then use
it in both views. Fixes#4263.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Disambiguate client-side model pricing by provider: generic ids (e.g. `auto`) resolve ${provider}/${model} first, so they only price under their real provider instead of borrowing Cursor's rate. Provider is LOWER()-normalized on read and write so mixed-case historical rows merge.
Closes#4199. MUL-3346
Extract three module-local helpers in content-editor.tsx and route the
duplicated call sites through them — no behavior change:
- normalizeMarkdown(md) / normalizeEditorMarkdown(editor): the single
definition of the "strip blob URLs + trimEnd" canonical form, replacing
the five editor-markdown sites and the one incoming-string site.
- hasUploadingNode(editor): replaces the two byte-identical document scans
(content-sync Guard 0 and hasActiveUploads).
The imperative getMarkdown() is deliberately left untrimmed; a safety-net
test pins its exact current return value.
Scope: WOR-59 upload-readiness review. file-upload.ts untouched; follow-up
items B1/F4/F5 are out of scope.
Co-authored-by: hzz <331380069@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The `codebuddy` case in ProviderLogo was aliased to ClaudeLogo when the
backend was integrated (#3186), so CodeBuddy runtimes rendered the Claude
mark and were visually indistinguishable from Claude in the runtime list,
runtime detail, agent runtime picker, and onboarding.
Add a dedicated CodeBuddyLogo using the official mark shipped in Tencent's
own @tencent-ai/codebuddy-code CLI package (dist/web-ui/logo.svg) — the
same CLI this runtime spawns. The artwork overflows the 24×24 box and is
cropped by a clipPath whose id is per-instance (useId), mirroring KiroLogo,
so multiple logos on one page don't collide on a shared id.
Presentation-only: no provider string, backend/API, DB, daemon/runtime,
CLI, mobile, or copy changes. Shared views package, so web + desktop both
pick it up.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): workspace-scoped attachment binding + fire-and-forget send
Uploads are now workspace-scoped: the chat session is created and
attachments are bound to the message at send time, so a paste/drop no
longer creates an empty session the user never sends.
- LinkAttachmentsToChatMessage returns the ids it actually bound; the
client diffs requested-vs-bound and warns on partial bind, replacing
an extra listChatMessagesPage fetch.
- Cancelling an empty chat task detaches attachments before deleting the
user message (attachment FK is ON DELETE CASCADE) and returns them via
cancelled_chat_message.attachments, so a restored draft can re-bind.
- SendChatMessageResponse.attachment_ids has no omitempty: "requested but
bound zero" serializes [] so the client can tell it apart from an older
server and still warn.
- Send is fire-and-forget: it no longer steals focus when the user has
navigated to another session (guarded on the live store + new-chat agent
id); the reply surfaces via the unread dot. commitInput gets clearEditor
so a navigated-away commit doesn't wipe the editor now showing another
session, while still clearing the sent draft's data.
- Draft restore is session-aware so a failed fire-and-forget send restores
into the session it was sent from, never the one the user moved to.
- Removed the now-unreferenced migrateInputDraft store action.
Verified: core/views typecheck, chat-input (15) / store (3) / api client
(24) unit tests, go build + vet, handler SendChatMessage + CancelTaskByUser
DB tests. Full make check / E2E left to CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(chat): guard attachment survival on empty-chat cancel
Cancelling an empty chat task deletes the user message, and
attachment.chat_message_id is ON DELETE CASCADE (migration 083), so the
detach-before-delete in finalizeCancelledChatMessage is the only thing
keeping the user's attachment from being silently destroyed. Nothing
covered it.
Add a DB regression test that binds an attachment to the cancelled user
message and asserts: the row survives the cascade (chat_message_id NULL,
chat_session_id retained), the cancel response returns it via
cancelled_chat_message.attachments, and a resend re-binds it to the new
message. Verified red when the detach step is removed.
Related issue: MUL-3364
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(comment): pessimistic submit for comment/reply composers
The comment and reply composers cleared the editor after `await onSubmit`
returned, with no in-flight lock. On a slow send the WS `comment:created`
event already dropped the real comment into the timeline while the box
still held the same text + spinner, so it read as two comments. And
because `submitComment`/`submitReply` swallow errors (toast, no rethrow),
a failed send still reached `clearContent` and silently discarded the
user's draft.
Recover the comment/reply portion of the closed#4236: make the submit
callback resolve a success boolean (true on success, false on the caught
failure), lock the editor while in flight (pointer-events-none + dimmed
wrapper + aria-busy, since ContentEditor can't toggle Tiptap `editable`
post-mount), keep the button spinning, and clear only on success — a
failed send keeps the draft. Chat composer is out of scope (already
reworked on this branch); attachment binding is untouched.
Adds two view tests (in-flight lock then clear-on-success; failed send
keeps the draft); both verified red against the un-fixed code.
Related issue: MUL-3364
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): default subscriber template (MUL-2533) — server
Add per-autopilot member subscriber template that fans out to every
issue the autopilot spawns. New autopilot_subscriber table; extend
issue_subscriber.reason with 'autopilot' so the dispatch-time fanout
is distinguishable from manual subscriptions.
API: POST/PATCH /api/autopilots accept a `subscribers` array (member
user_type only for the first version); PATCH semantics are full-replace.
GET returns subscribers on the detail endpoint; the list endpoint omits
them to avoid an N+1.
Dispatch: dispatchCreateIssue lists the template inside the same tx as
the issue insert and writes the rows with reason='autopilot' before
EventIssueCreated fires, so notification listeners see the full
subscriber set on the first event.
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): default subscriber template (MUL-2533) — frontend
New SubscriberMultiSelect picker (members-only search + chips) wired
into the create / edit AutopilotDialog. The detail page renders the
saved template as read-only chips; edits flow through the dialog.
TS types expose the new `subscribers` field on Autopilot, plus an
AutopilotSubscriberInput shape for the create/update wire payloads.
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): notify template subscribers on issue creation (MUL-2533)
The autopilot create-issue path fans out template subscribers into
issue_subscriber inside the same tx as the issue insert, but the
issue:created notification listener only matches handler.IssueResponse
payloads and only direct-notifies the assignee + @mentions. The autopilot
publishes a map[string]any payload, so the listener falls through and the
template subscribers never receive an inbox item for the creation event —
breaking OQ3 ("reason='autopilot' subscribers receive all subscription
events, consistent with reason='manual'").
Fix it where the divergence lives: in dispatchCreateIssue, right after
EventIssueCreated fires, write an inbox_item (type='issue_subscribed',
severity='info') for each member subscriber and publish EventInboxNew so
the recipient's inbox WS feed updates in real time. The write is after
the tx commit so an inbox hiccup can't roll back the issue; failures are
logged, not propagated. The manual path is unchanged — manual subscribers
don't exist at creation time, so there is nothing to notify there.
Adds a new InboxItemType 'issue_subscribed' (en/zh labels) and two
covering tests in autopilot_subscriber_test.go: one asserts the inbox
row lands for a template subscriber on dispatch, the other asserts the
no-subscriber autopilot stays silent.
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): align subscriber PR with current main
Co-authored-by: multica-agent <github@multica.ai>
* fix autopilot subscriber template transaction
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The runtimes page header has three outline action buttons — "Add runtime",
"Cloud Runtime", and "Add a computer" — that previously rendered with
inconsistent dimensions and responsive behavior:
- "Add a computer" used `h-8 w-8 ... md:w-auto md:px-2.5` (icon-only
below md, expands to icon+label on md+), with an `aria-label` for the
icon-only state and the label wrapped in `<span class="hidden md:inline">`.
- "Add runtime" had no responsive className, no aria-label, and the label
stayed visible at every width.
- "Cloud Runtime" had the same gaps as "Add runtime", plus the Cloud
icon was sized `h-3 w-3` instead of `h-3.5 w-3.5` like the others.
Visually they read as three different button styles crammed into one
header. Aligning all three to the "Add a computer" pattern gives the
header a single, consistent action group at all breakpoints, fixes the
mobile layout (header no longer wraps onto multiple lines for users with
both Cloud Runtime enabled and admin role), and makes every icon-only
state screen-reader accessible.
Changes:
- Apply `h-8 w-8 gap-1 px-0 md:w-auto md:px-2.5` to all three buttons.
- Add `aria-label` to "Add runtime" and "Cloud Runtime".
- Wrap the label of "Add runtime" and "Cloud Runtime" in
`<span className="hidden md:inline">` so they collapse to icon-only
below md, matching "Add a computer".
- Normalize the Cloud icon size from `h-3 w-3` to `h-3.5 w-3.5`.
Verification: typecheck + lint + 1395 unit tests across @multica/views
all pass.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The Custom runtimes dialog repeated the same idea in three places: the
header tagline, the empty-state body, and the right-panel default state.
Per MUL-3367, tighten the prose so each surface adds new information:
- dialog_description: short tagline that points at the protocol families
list directly below.
- empty_description: drops the "such as Claude or Codex" example (already
enumerated in the protocols list right under it) and the "your daemon
should run" filler.
- detail.default_title / default_description: replace the
"Manage custom runtimes" + paragraph (which restated the header) with
a quiet "Select a runtime" placeholder.
- detail.default_builtin_hint: dropped entirely. The supported-protocols
section is already visible in the left column and each row carries a
Reference badge — pointing at it from the right panel is noise.
Updated en, zh-Hans, ja and ko locales in lockstep, plus the dialog test
assertions.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(markdown): don't auto-link bare filenames as external URLs
Agent comments that mention a project file like `plan.md` were turned into
clickable links to https://plan.md (dead external site). linkify-it fuzzy
detection matches `plan.md` as a domain because its extension is also a valid
TLD (md = Moldova; likewise io, sh, rs, py).
Suppress schemeless (fuzzy) linkify matches whose token is a bare filename
(single segment ending in a known source/config extension). Explicit schemes
(`https://plan.md`) and real domains (`example.com`) are unaffected. The file
extension list is now shared between the file-path and bare-filename detectors
so they can't drift.
Fixes#4222
Co-authored-by: multica-agent <github@multica.ai>
* docs(markdown): drop inaccurate .io example from bare-filename comment
io is not in the FILE_EXTENSIONS list, so .io domains are never suppressed.
Listing it as an example was misleading.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The runtime detail page disabled the Delete button — and the list-page
kebab dropped its only action — whenever the runtime was an online
local daemon (isSelfHealingRuntime). The intent was to spare users a
delete that the daemon would silently undo via re-register, but the
side effect was an Owner staring at an action they had every
permission for, with no obvious explanation.
The fix moves the warning into the confirmation dialog instead of
hiding the action:
- runtime-detail.tsx: drop the disabled+tooltip branch around the
Diagnostics card Delete button. canDelete (workspace owner/admin OR
runtime owner) now fully governs visibility, and the button is
always clickable when present.
- runtime-list.tsx: the row kebab no longer hides itself for
self-healing runtimes. showActions follows along.
- delete-runtime-dialog.tsx: drop the defensive self-healing guard in
handleConfirm (it returned early with a toast on the very click the
user just confirmed). Add a SelfHealNotice banner that renders in
both LightBody and CascadeBody when isSelfHealingRuntime is true,
so the user sees the trade-off before pressing the destructive
button rather than after.
- locales: drop delete_disabled_tooltip and the cascade
self_healing_blocked_toast across en/zh-Hans/ja/ko; add a single
detail.delete_dialog.self_heal_notice key that powers the banner.
- tests: flip the row-menu self-healing assertion (kebab is now
visible), add a banner-present / banner-absent / confirm-proceeds
triplet to delete-runtime-dialog.test.tsx, and pin the
enabled-Delete-for-owner case on runtime-detail-visibility.test.tsx.
Verified with @multica/views typecheck (clean) and a targeted vitest
run across the four affected suites — 73/73 tests pass. Lint stays at
0 errors on the package.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): surface in-flight CI on PRs and recover out-of-order check_suite events
Two bugs caused PR cards to render "checks not reported yet" while CI
was actually running (MUL-2392):
1. handleCheckSuiteEvent dropped every action except `completed`, so
`requested`/`rerequested` events (status queued/in_progress) never
landed in the suite table. Aggregated `checks_pending` stayed at 0
until the first suite finished, and the frontend fell through to the
unknown bucket. Persist all actions; the ListPullRequestsByIssue
aggregation already counts status<>completed as pending.
2. A check_suite for an unmirrored PR was logged and dropped, with no
replay path. Add a `github_pending_check_suite` stash keyed by
(workspace, repo, pr_number, suite_id); the pull_request webhook
drains it after the PR upsert and replays each entry through the
normal check_suite upsert. One-shot drain via DELETE … RETURNING
keeps it idempotent and free of retry storms.
Follow-ups for fork PRs (empty `pull_requests[]`) and a more specific
frontend placeholder ship in separate issues.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): guard pending check_suite stash against out-of-order events
UpsertPendingCheckSuite previously overwrote unconditionally on
conflict, so an older `requested/in_progress` event arriving after a
newer `completed/success` for the same suite would roll the stash
back to pending. The subsequent PR upsert then drained the stale
state and the PR card stuck on "pending" until the next suite. Mirror
the suite_updated_at guard from UpsertPullRequestCheckSuite and add a
regression test covering the PR-missing path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): unify trigger chip copy to will-start phrasing
Make the comment trigger chip's on/off states symmetric around the verb
'start' instead of mixing natural language with the 'trigger' jargon:
- on: Will start when sent
- skipped: Won't start this time
- all skipped: No agents will start
- multi on: N agents will start when sent
Updates all four locales (en/zh-Hans/ja/ko); CJK on-state copy already
reads as future-conditional so only the skip states are realigned to the
'start' verb. Updates the component test expectations to match.
Refs MUL-3211
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): align restore hint to will-start phrasing
Carry the trigger-chip copy unification into the suppressed-agent restore
hint (trigger_click_to_restore), the last surface still mixing 'trigger'
with the chip's 'start' wording:
- en: Won't start this time. Click to restore.
- CJK: skip-state term realigned to the 'start' verb, rest unchanged.
Refs MUL-3211
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): trim trigger preview popover copy and drop redundant reason lines
The active hover popover stacked header + reason + presence, repeating the
same fact across lines and again against the chip. Tighten it:
- Drop the reason line for assignee / @mention: the header (name · source)
already conveys why they fire. Keep reason only for squad-leader (the link
is non-obvious) and the unknown fallback, both trimmed of the duplicated
name.
- Shorten presence (Starts right away. / Offline now — starts once online.)
and de-jargon the skip/manage hints (no more 'trigger').
- Align the popover title to the chip wording (Will start when sent).
All four locales updated; removes the two now-unused reason keys.
Refs MUL-3211
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The agent record already carries a top-level `thinking_level` field —
exposed by `agent get --output json`, settable from the web inspector,
and accepted/validated by `PUT /api/agents/{id}` — but the CLI had no
flag to write it. Scripted or version-controlled agent management could
set `--model` but not the thinking depth, forcing a drop to raw HTTP.
Add `--thinking-level` to `agent create` and `agent update`, mirroring
`--model`: a thin pass-through to the top-level `thinking_level` field.
On update an empty string clears it back to the runtime default (the
server reads it as a tri-state pointer: omitted = no change, "" = clear,
value = set). The CLI deliberately does not enumerate valid levels —
they are runtime/model-specific and the server already owns the catalog
(`agent.IsKnownThinkingValue`, `server/pkg/agent/thinking.go`), returning
a 400 for an unknown value or a runtime with no thinking concept, which
the CLI surfaces verbatim.
- server/cmd/multica/cmd_agent.go: register the flag on both commands,
Changed-gate it into the request body, add it to the no-fields error.
- server/cmd/multica/cmd_agent_test.go: cover create/update send,
unset-omission, empty-clears, the flag-exposed guard, and that a
server-side rejection surfaces to the user.
- multica-creating-agents builtin skill + source map: document the new
CLI write surface and re-derive shifted cmd_agent.go line numbers.
Closes#4170🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matt Voska <voska@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
* feat(analytics): session-level $exception dedupe in before_send
A runaway client error (a render loop, a polling fetch that keeps
throwing) emits 100+ identical $exception events per session, which
showed up as a top PostHog cost/noise source after exception
autocapture landed (MUL-3331 / MUL-3330).
Add a per-tab-session fuse in before_send, after redaction: fingerprint
the already-redacted exception (type + redacted value + one deterministic
stack frame incl. colno), keep the first 3 per (session, fingerprint),
drop the rest. State lives in sessionStorage as a hash->count blob, so no
PII is persisted. Every storage failure fails open (keep the event);
before_send never throws.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(diagnostics): global 60s cooldown for client_unresponsive
A single sustained freeze is delivered as several long-task entries, so
emitting per entry made client_unresponsive volume grow without bound
with the freeze length (MUL-3331). Cap it with a module-level (page-
lifetime) global cooldown: at most one event per 60s window. No route
bucketing — a global window is the most direct cap on volume.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(analytics): add exception-dedupe safety matrix
This file was written alongside the dedupe implementation but missed the
original commit, so the $exception fail-open / cap / fingerprint matrix
never landed on the branch. No implementation change — the tests pass as
written against the existing exception-dedupe.ts.
Covers: first-3-then-drop, fingerprint independence, colno discrimination,
hash-only storage (no PII), degraded/missing frames, undefined / throwing
/ corrupt-JSON sessionStorage fail-open, setItem-failure under-counts, and
the distinct-fingerprint cap (51st new fingerprint kept).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
`multica version` now outputs two lines (version + go info). The old
`awk '{print $2}'` captured both lines, causing the version comparison
to always mismatch and triggering unnecessary upgrades.
Fixes https://github.com/multica-ai/multica/issues/4226
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* MUL-3332: daemon picks up new custom runtime profiles without restart
The workspaceSyncLoop's already-tracked branch refreshed only settings and
repos via refreshWorkspaceRepos and never re-fetched runtime profiles, so
a custom runtime profile created via the web UI / CLI did not become a
registered runtime row until the daemon restarted (or a runtimeGone
recovery happened to fire).
Detect server-side profile drift each sync tick by hashing the workspace's
profile list with profileSetSignature(), caching the digest on
workspaceState.profileSetSig, and triggering reregisterWorkspaceAfterRuntimeGone
when the live signature differs from the cached one. Steady-state syncs cost
exactly one extra GetRuntimeProfiles round trip; only real drift fans out to
a Register call.
The fetch is best-effort: a 404 / network blip preserves the cached signature
so a transient failure cannot loop the daemon into spurious re-registrations.
Tests in runtime_profile_drift_test.go cover digest stability under reorder,
field-by-field drift detection (add / enable-flip / command_name /
protocol_family / fixed_args / visibility), the no-drift hot path (no
re-register), the new-profile drift path (single re-register + index update +
sig converges), and best-effort fetch error handling.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3332: split orphan recovery from profile drift; converge to zero
Addresses two blocking review concerns on #4225 (raised by GPT-Boy):
1. Profile drift must not kill running tasks on existing runtimes.
The first cut reused reregisterWorkspaceAfterRuntimeGone, which after
re-register calls /recover-orphans for every returned runtime ID. The
server's RecoverOrphanedTasksForRuntime hard-fails every
dispatched/running/waiting_local_directory row on that runtime — the
correct response when a runtime row was actually deleted server-side,
but a catastrophic false positive on profile drift: a built-in runtime
still actively executing the user's tasks would have its work killed
just because the user added an unrelated sibling custom profile.
Fix: extract applyRegisterResponseInPlace as the shared in-place state
converger between the two paths, and stop calling /recover-orphans from
the drift path. reregisterWorkspaceAfterRuntimeGone keeps the
/recover-orphans call because in that path the rows really were gone.
2. Disabling the only profile on a custom-only daemon must converge.
The first cut hit registerRuntimesForWorkspace's len(runtimes)==0 guard
and bailed out, so the disabled profile's runtime stayed alive in
local tracking and on the server (still polling, still heartbeating,
still online for the full 150 s stale-heartbeat window).
Fix: introduce ErrNoRuntimesToRegister as a sentinel, have
registerRuntimesForWorkspace return profileSig even on the empty case
(so the drift path can cache the converged-empty signature), and have
the drift refresh's error handler take a convergeWorkspaceRuntimesToZero
branch that clears local runtimeIDs / runtimeIndex entries and
Deregisters the orphaned IDs so the server marks them offline
immediately. The same Deregister step also runs on partial drift (a
built-in survives, the disabled profile's runtime drops) so the user
sees the dropped runtime go offline within the next sync tick instead
of after the 150 s sweep.
Tests:
- TestRefreshWorkspaceRuntimeProfiles_DriftWithRunningRuntimeSkipsOrphanRecovery
(mixed built-in + custom, add another profile, asserts zero
/recover-orphans calls).
- TestRefreshWorkspaceRuntimeProfiles_DisableConvergesCustomOnlyDaemon
(custom-only daemon, disable only profile, asserts local state
cleared, signature converges to empty digest, Deregister called with
the orphaned ID, no recover-orphans, follow-up tick is no-op).
- TestRefreshWorkspaceRuntimeProfiles_DisableOneOfManyDeregistersDroppedID
(partial drift: only the dropped ID is Deregistered, surviving
built-in is left alone and not orphan-recovered).
- TestRefreshWorkspaceRuntimeProfiles_NewProfileTriggersReregister
extended to also assert no /recover-orphans calls.
- TestRegisterRuntimes_SkipsProfileNotOnPath strengthened to assert the
ErrNoRuntimesToRegister sentinel and that profileSig is still returned
on the empty path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override
- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
/ unset-path for a per-machine command override. protocol-family validated
client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
validated; update only sends changed flags (protocol_family immutable);
delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
appendProfileRuntimes now prefers an override path when set AND executable,
else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
relative-path rejection, config preservation); cli/config round-trip;
daemon prefers-override / falls-back-when-not-executable.
Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page
Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).
- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
RuntimeProtocolFamily / RuntimeProfile types; api client
list/get/create/update/deleteRuntimeProfile against
/api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
protocol family → step 2 display_name/command_name/description; edit form
for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
create/edit/delete flow is keyboard + screen-reader operable.
Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.
Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)
Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".
- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
detail row, the create body field, the update patch field, and the form
textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
update and stopped sending `fixed_args`; updated the "no fields" message.
Test now asserts the CLI never sends fixed_args.
Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.
Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)
Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.
- Server (runtime_profile.go): drop `visibility` from the create + update
request structs; CreateRuntimeProfile always stores 'workspace'
(runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
removed validRuntimeProfileVisibility. The column + response field stay
(always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
the VisibilityOption component, the detail row, the visibility state, and the
create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
(profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
the VISIBILITY list column; removed validateVisibility; stopped sending the
field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
visibility:"private" -> response and DB row are 'workspace'); CLI create test
now asserts visibility is never sent.
Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.
Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: add runtime_profile schema (custom runtime PR1)
Schema-only foundation for custom runtimes. Additive migration 120:
- New workspace-level `runtime_profile` table: the shared, team-visible
definition of a custom runtime (e.g. an in-house Codex wrapper).
protocol_family is CHECK-constrained to the exact backend list in
agent.New() (server/pkg/agent/agent.go). The only args column is
`fixed_args` (args every agent on the runtime must inherit); there is
deliberately no generic per-agent args field — those stay on
agent.custom_args.
- `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE
CASCADE): NULL = built-in runtime, non-NULL = a registered instance of
a custom profile.
- Partial unique index agent_runtime_workspace_daemon_profile_key on
(workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL.
The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left
INTACT so the existing registration upsert
(ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps
resolving its arbiter and the server stays green. Converting that key to
a partial (WHERE profile_id IS NULL) index and making the upsert
profile-aware is PR2's registration work, not this migration.
Verified up + down against Postgres 17: full `migrate up` applies 120;
schema shows the table, column, partial index and intact legacy
constraint; functional checks pass (partial index blocks dup
(ws,daemon,profile), allows same profile on another daemon; CHECK and
display_name uniqueness reject bad input; legacy ON CONFLICT still
resolves; profile delete cascades to instances); down/up round-trip is
clean.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix)
Per review (house rule: no new database foreign keys / cascades; relational
integrity lives in the application layer):
- runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE
-> plain UUID NOT NULL.
- runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL
-> plain UUID.
- agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE
-> plain UUID.
CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index,
and the partial unique index agent_runtime_workspace_daemon_profile_key are
unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint
remains untouched.
Behavioral consequence: the database no longer auto-removes a profile's
agent_runtime instance rows on profile delete. That cleanup moves into PR2's
profile-delete path. Up-migration comments document this; down-migration
comment no longer references FKs/cascade.
Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on
the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK
and display_name uniqueness still reject bad input; deleting a profile now
leaves the runtime row orphaned (proving cascade is gone); down/up round-trip
clean with the legacy constraint intact.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR2 (server): runtime_profile CRUD + profile-aware registration
Server/DB half of the custom-runtime feature.
- Migration 121: convert the legacy UNIQUE (workspace_id, daemon_id, provider)
constraint on agent_runtime into a partial unique index scoped to built-in
rows (WHERE profile_id IS NULL). With 120's partial index on profile_id this
lets one daemon host the built-in provider AND custom profiles of the same
protocol family without collision.
- Queries: runtime_profile CRUD; ListEnabledRuntimeProfilesForWorkspace
(daemon-facing); CountAgentsByProfile + DeleteAgentRuntimesByProfile for the
app-layer cascade; profile-aware UpsertAgentRuntimeWithProfile; the built-in
UpsertAgentRuntime ON CONFLICT now spells out WHERE profile_id IS NULL so it
targets the right partial index. sqlc regenerated.
- agent.SupportedTypes / IsSupportedType: single-source protocol_family
whitelist, in lockstep with agent.New and the migration 120 CHECK.
- Handlers + routes: runtime_profile CRUD (member-read, admin-write) with
protocol_family whitelist validation, display_name uniqueness (409), and
fixed_args validation (no generic per-agent args — iron rule); a
daemon-token endpoint GET /api/daemon/workspaces/{id}/runtime-profiles;
DeleteRuntimeProfile does the app-layer cascade (delete instance rows then
profile, in one tx) and refuses (409) while active agents are bound.
- DaemonRegister accepts an optional per-runtime profile_id: validates the
profile belongs to the workspace and is enabled, registers via the
profile-aware upsert, and skips legacy hostname merge for custom rows.
AgentRuntimeResponse now carries profile_id.
Verified on Postgres 17: migrate up through 121; built-in + custom codex
coexist on one daemon; both upsert arbiters are idempotent; delete-by-profile
cascade removes only the custom instance; migrate down reverses 121 then 120
and replays clean. go build ./... and go vet pass; handler test package
compiles.
Daemon-side wiring (fetch profiles, PATH-resolve command_name, register with
profile_id, exec uses command_name) lands in a follow-up commit on this branch.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR2 (daemon): pull profiles, PATH-resolve, register, exec command
Daemon-side half of custom runtime profiles, against the server contract on
this branch.
- client.go: GetRuntimeProfiles(workspaceID) -> GET
/api/daemon/workspaces/{id}/runtime-profiles (mirrors GetWorkspaceRepos);
RuntimeProfile / RuntimeProfilesResponse types.
- types.go: Runtime gains profile_id (parsed from the register response so
runtimeIndex carries it).
- daemon.go:
* appendProfileRuntimes — called inside registerRuntimesForWorkspace before
the empty-runtimes guard. Best-effort fetch (older server 404s are logged
and swallowed; never fails registration). Per enabled profile: resolve
command_name via PATH (exec.LookPath, behind a `lookPath` test hook),
skip+log when absent, best-effort version probe, record the resolved
absolute path keyed by profile_id, and append a registration entry
{name, type=protocol_family, version, status:online, profile_id}. A
custom-only host (no built-in agents) still registers.
* profileCommandPaths map (guarded by d.mu) + recordProfileCommandPath /
customCommandPathForRuntime helpers.
* runTask: looks up the claimed task's RuntimeID -> profile command path and
overrides the executable path, synthesizing an AgentEntry so a custom
runtime runs even when the host has no built-in agent of the same
provider. provider (=protocol_family) is unchanged so agent.New still
selects the right backend.
- Tests: GetRuntimeProfiles request shape; profile runtime appended + path
recorded (custom-only host); profile skipped when command not on PATH;
profiles-fetch-404 is best-effort; customCommandPathForRuntime bookkeeping.
- agent: lockstep test pinning SupportedTypes to agent.New and the migration
120 protocol_family CHECK.
Iron rule honored: profile carries no generic per-agent args. fixed_args are
parsed and carried but intentionally NOT wired into the launch command yet
(optional/best-effort; explicit TODO(MUL-3284) in appendProfileRuntimes).
Verified: go build ./... clean; go vet ./internal/daemon/... clean;
go test ./internal/daemon/... pass (existing + 5 new); full
go test ./internal/handler/ suite passes against a migrated Postgres 17;
agent lockstep test passes.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR2: profile delete runs full archived-agent cascade (fix 500)
Review fix. DeleteRuntimeProfile previously guarded only on ACTIVE agents, but
agent.runtime_id is ON DELETE RESTRICT — a profile whose runtimes had only
ARCHIVED agents passed the guard, then DeleteAgentRuntimesByProfile hit the FK
and the handler 500'd.
Now it mirrors the mature runtime-delete cascade (DeleteAgentRuntime): in one
transaction it enumerates the profile's runtime rows, refuses (409) any with
active agents or active squads led by archived agents, then for each runtime
pauses autopilots pinned to its archived agents, drops archived squads led by
them, and hard-deletes the archived agents before removing the runtime rows
and the profile. No code path can now fall through to a raw FK error.
- queries: ListAgentRuntimeIDsByProfile (sqlc regen). Reuses the existing
per-runtime teardown queries (CountActiveSquadsWithArchivedLeadersByRuntime,
ListArchivedAgentIDsByRuntime, PauseAutopilotsByAgentAssignees,
DeleteSquadsByArchivedAgentsOnRuntime, DeleteArchivedAgentsByRuntime).
- tests: TestDeleteRuntimeProfile_ArchivedAgentCascade (archived-only profile
deletes cleanly: 204, runtime + archived agent + profile gone) and
TestDeleteRuntimeProfile_ActiveAgentBlocks (active agent → 409, survives).
Verified against Postgres 17: both new tests pass; full handler suite, daemon
tests, and agent lockstep test pass; go vet clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: add runtime_profile schema (custom runtime PR1)
Schema-only foundation for custom runtimes. Additive migration 120:
- New workspace-level `runtime_profile` table: the shared, team-visible
definition of a custom runtime (e.g. an in-house Codex wrapper).
protocol_family is CHECK-constrained to the exact backend list in
agent.New() (server/pkg/agent/agent.go). The only args column is
`fixed_args` (args every agent on the runtime must inherit); there is
deliberately no generic per-agent args field — those stay on
agent.custom_args.
- `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE
CASCADE): NULL = built-in runtime, non-NULL = a registered instance of
a custom profile.
- Partial unique index agent_runtime_workspace_daemon_profile_key on
(workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL.
The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left
INTACT so the existing registration upsert
(ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps
resolving its arbiter and the server stays green. Converting that key to
a partial (WHERE profile_id IS NULL) index and making the upsert
profile-aware is PR2's registration work, not this migration.
Verified up + down against Postgres 17: full `migrate up` applies 120;
schema shows the table, column, partial index and intact legacy
constraint; functional checks pass (partial index blocks dup
(ws,daemon,profile), allows same profile on another daemon; CHECK and
display_name uniqueness reject bad input; legacy ON CONFLICT still
resolves; profile delete cascades to instances); down/up round-trip is
clean.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix)
Per review (house rule: no new database foreign keys / cascades; relational
integrity lives in the application layer):
- runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE
-> plain UUID NOT NULL.
- runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL
-> plain UUID.
- agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE
-> plain UUID.
CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index,
and the partial unique index agent_runtime_workspace_daemon_profile_key are
unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint
remains untouched.
Behavioral consequence: the database no longer auto-removes a profile's
agent_runtime instance rows on profile delete. That cleanup moves into PR2's
profile-delete path. Up-migration comments document this; down-migration
comment no longer references FKs/cascade.
Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on
the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK
and display_name uniqueness still reject bad input; deleting a profile now
leaves the runtime row orphaned (proving cascade is gone); down/up round-trip
clean with the legacy constraint intact.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): don't wipe in-flight uploads on external content sync
When a brand-new chat's first file upload triggers lazy session creation,
`setActiveSession(null → uuid)` flips ChatInput's draft key mid-upload, which
changes `defaultValue` to the new (empty) session draft. ContentEditor's
"sync external defaultValue" effect then ran `setContent` over a document that
still held the `uploading` image/fileCard node, wiping it — so the upload's
finalize could no longer find the node. The file vanished and the draft was
left with an empty `!file[name]()`.
The editor was never remounted (instance stays alive); the node was removed by
the content-sync effect. An uploading node is local state an external sync must
not overwrite, exactly like the existing dirty/focused guards. Add a guard that
bails the sync while any `uploading` node is present.
Pure frontend; affects only the first upload in a new chat (subsequent uploads
hit an existing session, so no draft-key flip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(editor): cover the in-flight-upload content-sync guard
The content-sync effect now reads `editor.state.doc.descendants` on every run
to detect uploading nodes; the mocked editor didn't implement it, crashing all
ContentEditor tests. Add `descendants` (driven by `editorState.uploadingNodes`)
to the mock and a regression test asserting an external `defaultValue` change
does not setContent while an upload is in flight, and resumes once it settles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): migrate new-chat draft onto the session id on lazy create
The first file upload in a brand-new chat lazily creates the session, flipping
ChatInput's draft key from `__new__:agent` to the session id mid-upload. The
in-progress (empty-href) file-card markdown the editor had already written into
the `__new__:agent` draft was neither migrated nor cleared, so it stayed
stranded under that key — and resurfaced as a stale `!file[name]()` the next
time a new chat opened for the same agent (the send only cleared the
session-keyed draft).
Migrate the `__new__:agent` draft onto the new session id the moment the
session is created (upload path only — text send already clears the pre-flip
key via `keyAtSend`). Add a shared `newSessionDraftKey` helper so ChatInput and
ensureSession agree on the slot name, and a `migrateInputDraft` store action.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(execenv): switch agent prompt to --content-file to prevent heredoc flag swallowing (#4182)
The Linux/macOS reply template recommended --content-stdin with a quoted
HEREDOC. That pattern is safe for the trivial single-flag comment-add case
that BuildCommentReplyInstructions emits, but as soon as a model wraps
extra flags around the heredoc on multica issue create / update — assignee,
project — the bash heredoc/flag boundary is fragile in two ways the model
cannot see:
- A 'BODY \\' terminator with a trailing token is not recognised as the
heredoc end, so flag lines after it are swallowed into the description
(OXY-78: residual flag text leaked into the description, command exit 0).
- A clean terminator turns the trailing '--assignee ...' line into a
separate failing shell statement, while the create itself already exited
0 with no assignee (OXY-76: assignee silently dropped, no residual text).
In both cases the CLI never receives the swallowed flags, the API request
omits the fields, and the daemon has no visibility. The created issue lands
with assignee_id: null / project_id: null.
This commit:
* Switches the Linux/macOS branch of BuildCommentReplyInstructions to
--content-file with a 3-step recipe (write file, post, rm) so the body
never reaches the shell and all flags live on one shell-token line.
There is no heredoc boundary for flags to leak across.
* Adds a parallel cleanup step (Remove-Item) to the Windows branch so the
cross-platform template is one shape.
* Rewrites the runtime_config.go ## Comment Formatting non-Windows section
to mandate --content-file and explicitly ban --content-stdin HEREDOC for
agent-authored comments, citing #4182.
* Reorders the Available Commands menu lines for issue create / update /
comment add to put --content-file / --description-file ahead of the
stdin variant and add a per-line note pointing at #4182.
* Updates and renames the affected tests
(TestBuildCommentReplyInstructionsCodexLinux,
TestBuildCommentReplyInstructionsNonCodexLinux,
TestInjectRuntimeConfigLinuxCommentFormattingEmphasizesFile,
TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged) so the
new file-first contract is pinned and the old HEREDOC mandate is in the
banned-strings lists.
This converges Linux/macOS with the long-standing Windows file-only path,
so the cross-platform guidance is now one shape. It also strictly improves
on the previous MUL-2904 guardrail by eliminating shell exposure of the
body entirely (no body ever reaches the shell, so backtick / $() / $VAR
substitution cannot corrupt it).
Closes GitHub multica-ai/multica#4182.
No CLI or backend changes — --content-file / --description-file already
exist.
Co-authored-by: multica-agent <github@multica.ai>
* docs(prompt): correct stale BuildPrompt comment to file-first (#4182)
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
* feat(lark): add proxy support for WebSocket connections
- Add Proxy field to GorillaDialer (func(*http.Request) (*url.URL, error))
- Default to http.ProxyFromEnvironment when Proxy is nil, so standard
HTTPS_PROXY/HTTP_PROXY/NO_PROXY env vars are respected automatically
- Allow explicit override via GorillaDialer.Proxy for custom proxy auth
or fixed proxy URLs
- Add unit tests for proxy defaults and error forwarding
Closes#4032
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): add missing net/url import in ws_connector_test.go
TestGorillaDialerProxyDefaults and TestGorillaDialerProxyForwardsError
use *url.URL in their Proxy func signatures but net/url was not imported.
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): preserve configured websocket proxy
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
* fix(web): preserve CLI callback params across Google OAuth redirect
When 'multica login' runs in a headless/WSL2 environment, the CLI generates
a login URL with cli_callback and cli_state query parameters. These params
were being lost during the Google OAuth redirect because:
1. The login page did not encode cli_callback/cli_state into the Google
OAuth state parameter (only platform and nextUrl were included).
2. The callback page had no code path to redirect the JWT back to the
CLI's local HTTP listener after Google OAuth completed.
Fix:
- Login page: encode cli_callback and cli_state into the Google OAuth
state parameter alongside existing platform/nextUrl values.
- Callback page: parse cli_callback/cli_state from the returned state,
validate the callback URL, and redirect the JWT token to the CLI's
local HTTP listener after successful Google login.
Closes#3049
Co-authored-by: multica-agent <github@multica.ai>
* refactor(auth): reuse redirectToCliCallback helper in OAuth callback
Export the existing redirectToCliCallback helper from @multica/views/auth
and reuse it in the Google OAuth callback page instead of duplicating the
token+state redirect string inline, so the CLI callback URL contract lives
in one place.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
* feat(analytics): capture JS exceptions to PostHog
Turn on posthog-js exception autocapture (window.onerror + unhandled
rejections, with stack) and add a buffered captureException() wrapper for
boundary-caught React errors those handlers can't see. Wire the web
route-level global-error boundary to report through it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(diagnostics): add shared freeze watchdog
Long-task observer (>=2s) emits client_unresponsive via captureEvent;
client_type super-property tags desktop vs web for free. Installed once in
CoreProvider so web and desktop share one in-thread, SSR-safe detector for
recoverable freezes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(desktop): report true hangs and crashes via breadcrumb
A real hang or crashed renderer can't report itself. The main process now
persists a breadcrumb on unresponsive / render-process-gone, and the next
renderer boot flushes it to PostHog (client_unresponsive / client_crash).
A recovered hang clears its breadcrumb so it isn't double-counted by the
in-thread watchdog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(analytics): scrub PII from $exception before send
Error messages can interpolate user input (typed values, URLs with tokens).
Add a before_send hook that redacts emails, URL query strings, and long
opaque tokens from the exception message and $exception_list values, keeping
type + stack frames (code locations, not user data). Addresses the privacy
gap from leaving capture_exceptions on with no sanitizer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: cover breadcrumb state machine and freeze watchdog
The breadcrumb persist/clear orchestration is the correctness-critical part
and was untested. Cover: hang->write, recover->clear (no double-count),
recover-before-delay->no-op, force-quit->retained, crash->write-and-never-
clear, clean-exit->no-write. Add watchdog tests (threshold, idempotent,
SSR/PerformanceObserver no-op) via a fake observer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(desktop): breadcrumb field precedence + document limits
Spread the persisted context FIRST so explicit event fields (source,
recovered) always win over a future colliding context key. Document why
preload-error skips the breadcrumb and the single-slot last-write-wins
undercount limitation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two related cleanups to the issue comment/reply/edit composer:
- Drop the trigger-preview "context" copy added in #4147 (chip prefix
`trigger_context_*` and per-context popover titles `trigger_preview_title_*`).
The actual "align context" fix in #4147 was the backend/hook work; the copy
was redundant decoration. Removes the `context` prop, the dead i18n keys
across en/zh/ja/ko, and the corresponding test assertions; the popover title
falls back to the original single `trigger_preview_title`.
- Edit-comment footer: lay the trigger chip on a single row with the action
cluster (📎 Cancel Save) on the right, attachments on their own full-width
row above. The 📎 now sits with the action buttons, matching the new-comment
and reply composers.
- Unify composer buttons on shadcn `Button`: `FileUploadButton` renders a
ghost icon button instead of a hand-rolled circle, and the reply submit
button uses `Button` (icon-xs, ghost-when-empty / primary-when-typed) instead
of a hand-rolled element. Sizes: 📎 and reply submit are both icon-xs (24px).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking a row's ⋯ kebab (or any in-row control) full-page reloaded the
app. The row was a whole-row <AppLink>, so a child's stopPropagation
stopped the event before AppLink's onClick (which calls preventDefault to
cancel native anchor navigation and do an SPA push) could run — leaving
the browser to perform the native <a> navigation, i.e. a full reload. It
was also invalid HTML: interactive content (button/menu) nested in an <a>.
Rework all five ListGrid row surfaces (agents, runtimes, skills,
autopilots, squads) to a plain <div> row whose whole-row navigation is a
mouse onClick (new useRowLink hook): left-click pushes, cmd/ctrl/middle
opens a background tab. Interactive cells (checkbox, kebab) stopPropagation
so they never trigger row nav — and with no <a> ancestor there is no native
navigation to cancel, so the reload class of bug is gone. Names are plain
text since the row itself is the click target. projects is unchanged — its
inline-editable cells make it a deliberate name-link exception.
Also fixes two adjacent defects found in the same menus:
- agents/runtimes kebab triggers reused the shared <Button>, which lacks
the data-popup-open styling the other surfaces have, so the trigger
vanished and lost its background while its menu was open. Switch them to
the bare-button trigger with data-popup-open: visible + highlighted.
- agents archive menu items used className="text-destructive" instead of
variant="destructive", so the base focus style overrode the red on hover.
Switch to variant (list row + detail page).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(issues): render thread replies in chronological order (#3691)
collectThreadReplies walked the parent_id tree depth-first, so an agent
reply forced to nest under its trigger comment rendered before earlier
sibling replies (A-D-B-C instead of A-B-C-D) whenever the agent returned
late. Sort the collected subtree by created_at (id tie-break) so the
thread reads in arrival order — the same order the server already feeds
agents via `comment list --thread` (ListThreadCommentsForIssue).
All other consumers of the array (resolution derivation, fold bars,
counts, deep-link) are order-independent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): rebuild skills list on shared Linear-style list grid
- new ListGrid primitives (subgrid: single source of truth for column tracks)
- skills list: sortable columns, used-by avatar stack, source/creator columns,
row kebab + batch toolbar with add-to-agent and delete
- skill view store in core; addAgentSkills client method; HoverCheck extracted
to views/common (issues header now imports the shared copy)
- locale keys for list actions/filters and the reworked detail page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): rework detail page into overview/files tabs
- tabs directly under the breadcrumb header: overview (default) and files
- overview: identity block + rendered SKILL.md as the main column, right
rail with metadata card (source/creator/updated, inline name+description
edit toggle) and used-by panel with bind/unbind
- files: file tree + viewer/editor unchanged; SKILL.md "edit" jumps here
- header kebab menu (copy skill ID, delete); page-level save bar shared by
both tabs; tab state persisted in ?tab=
- file tree: ARIA tree roles + roving-tabindex keyboard navigation
- drop the old right sidebar (metadata dl, permissions paragraph)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* revert(skills): restore detail page to main, keep branch list-only
Drop the overview/files tabs rework from this branch so the PR scope is
the list rebuild only. skill-detail-page.tsx and file-tree.tsx are back
to the main versions; the locale detail/file_tree sections are restored
to match. The detail rework is preserved on stash/skills-detail-tabs
for a follow-up PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): drop description column from skills list
Description is agent-facing routing metadata, not a scannable list
property — Linear's display options expose no description column for
the same reason. Removes the cell, column key, display toggle, lg grid
track, skeleton cells, and the now-dead table.description /
table.no_description locale keys.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): drive list column hiding by container width, drop by priority
Replace viewport sm:/lg: breakpoints with Tailwind v4 container query
variants (@2xl/@4xl) on the list wrapper, so an open sidebar or split
pane narrows the column set instead of squashing tracks. Remove the
min-w-fit + overflow-x-auto horizontal-scroll fallback: when space runs
out, low-priority columns (created/source/creator, then updated) drop
and return as the container widens; name and usedBy never drop. ListGrid
conventions comment updated — this is the template for all list pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): virtualize list rows with @tanstack/react-virtual
Linear-style headless virtualization: the virtualizer computes the
visible index range and offsets; offsets land as padding on the
scrolling ListGridBody so mounted rows stay direct subgrid children and
column alignment is untouched. Fixed 48px rows skip per-row measurement.
Hideable column tracks move from max-content to deterministic widths
(CSS vars) — with only the visible slice mounted, content-driven tracks
would resize during scroll. A user-hidden column zeroes its var so the
track still collapses; per-cell max-w caps move into the tracks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills): list tiers must fit their container trigger width
The @4xl tier's track sum (~1080px with gaps) exceeded its 896px
trigger; with the horizontal-scroll fallback gone, the right-side
columns were clipped unreachably between 896-1080px. Move tier 3 to
@5xl (1024px), trim usedBy/source/creator tracks, and document the
fit invariant with its arithmetic next to the template and in the
ListGrid conventions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): show description as subtext under the skill name
Lives in the name track as a second truncated line (max-w 36rem,
title attr for the full text) — no track, no header, no slot in the
responsive arithmetic. Both lines fit the fixed 48px row, so the
virtualizer contract is untouched; rows without a description center
the name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Revert "feat(skills): show description as subtext under the skill name"
This reverts commit f39721301b.
* fix(skills): anchor batch toolbar to the page, not the viewport
fixed bottom-6 left-1/2 centered the bar on the window; with the
sidebar open the list's visual center sits ~120px right of the window
center, so the bar looked off-center (worse with desktop split panes).
Page root becomes the positioning context (relative) and the bar uses
absolute — same rule applies to future list pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): show matching count next to search while list is narrowed
"n / total" appears right of the search box only when search or
filters are active — idle state would duplicate the total already in
the page header.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(autopilots): derive trigger kinds, next run, last run status in list
The list endpoint only selected the autopilot table, so the list UI
could not answer "is this automation working" without N+1 detail
calls. Each list row now carries trigger_kinds + next_run_at (enabled
triggers only — the columns describe how it fires today) and
last_run_status (most recent run). Fields are omitempty and absent
from detail/create/update responses; clients must treat them as
optional per the API compatibility rules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(autopilots): list schema, parsed client, and view store in core
- listAutopilots now runs through parseWithFallback with a zod schema
(this endpoint was a bare fetch — overdue per the API compatibility
rules); malformed bodies degrade to an empty list, old-server rows
without assignee_type or the new derived fields parse cleanly, and
enum drift passes through as plain strings
- Autopilot type gains the three optional list-only derived fields
- New autopilots view store (scope/sort/columns/filters, persisted per
workspace): status is the promoted scope dimension so it does NOT
appear in filters — one dimension lives in exactly one place
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(autopilots): rebuild list on shared ListGrid with scope buttons
Same skeleton as the skills list (container-query tiers, deterministic
var-width tracks with documented fit arithmetic, virtualized 48px rows,
sortable headers, filter + display toolbar, page-anchored batch
toolbar), plus the autopilots-specific pieces:
- Status is the promoted SCOPE dimension: 全部/运行中/已暂停/已归档
segmented buttons with full-set counts; "all" = active+paused
(archived gets its own visible home, Linear archive semantics);
status is therefore absent from the filter dropdown
- Columns: name (paused marker inline), assignee (agent/squad),
trigger kind badges, last run (outcome dot + time, enum-drift safe
default), next run; mode/creator/created opt-in hidden
- Filters: assignee, trigger kind, mode, creator (composite type:id
values for polymorphic actors); sort name/lastRun/nextRun/created
with lastRun desc default
- Row kebab (pause/resume/archive/unarchive/delete) and batch toolbar
share one delete dialog; status changes ride useUpdateAutopilot's
optimistic cache
- Fix noUncheckedIndexedAccess errors the branch had never typechecked
(skills virtual rows, UsedByCell, added_toast)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(autopilots): scope buttons follow the issues header pattern
Replace the bespoke segmented-pill control with the existing scope
button convention from the issues page: outline buttons with bg-accent
active state on md+, collapsing to a radio dropdown below md. Counts
stay (stage inventories from the full set).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills,autopilots): toolbar small-screen treatment follows issues header
Below md: the search box (and its result count) disappear entirely,
and the filter/display controls collapse to square icon-only buttons
(labels and the clear-X are md+), matching the issues header's
responsive pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills,autopilots): two-zone columns — WYSIWYG with scroll escape valve
Static width tiers silently hid user-enabled columns (toggle on,
nothing appears — autopilots' mode/creator/created sat behind a 1280px
container gate no laptop reaches; skills' source/created behind
1024px). Tiers can't know how many columns are enabled, so the
mechanism is replaced, not retuned:
- ≥@2xl container: every enabled column renders; the grid carries
min-width = Σ(enabled tracks + gaps) (pure constants, no
measurement) and the wrapper scrolls horizontally only when the
enabled set outgrows the container
- <@2xl: static core set (skills: name+usedBy; autopilots:
name+assignee), no scroll, toggles don't apply
Per-tier templates and the hand-maintained fit arithmetic retire;
ListGrid conventions updated accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills,autopilots): widen name column minimums (120px base, 200px wide)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(autopilots): drop the archived scope and the list search box
Archiving never existed as a UI flow (the DB status value is only
reachable via direct API; the detail page disables its switch when
archived), so the list stops inventing it: no archived scope, no
archive/unarchive row or batch actions. API-archived rows are excluded
everywhere; a persisted retired scope value falls back to "all".
The search box goes too — scope buttons already partition the small
set, search is redundant (product call). Skills keeps its search (no
scope there).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills,autopilots): quiet outline create buttons in page headers
Page-header chrome shouldn't carry the loudest element on the page:
the create button becomes outline with text on md+ and collapses to a
square plus icon below md (same responsive treatment as the toolbar
controls). Primary stays reserved for empty-state CTAs. Agents follows
when its list migrates to ListGrid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(agents): rebuild list on shared ListGrid with identity rows
Same skeleton as the skills/autopilots lists (two-zone container
responsiveness, deterministic var tracks + min-width scroll escape
valve, virtualized fixed-height rows, issues-style scope buttons,
page-anchored batch toolbar, quiet outline create button), plus the
agents-specific decisions:
- Identity rows: the documented exception to the single-line rule —
avatar + name + description two-line cells, 64px rows (agents are
few, identity-rich entities); the italic "no description"
placeholder is gone, empty descriptions just center the name
- Scope: Mine (historical default) | All | Archived with full-set
counts; archived ignores the ownership lens; no search box
- The 7d sparkline column is replaced by a sortable "Last active"
column derived from the same 30-day activity buckets (zero API
change) — per-row-normalized mini bars can't be compared across
rows, and the default sort finally has a visible anchor; the
detailed histogram stays on the hover card / detail page
- Workload folds into the status cell ("Online · 2 tasks") — a 0-2
integer doesn't earn a column
- Columns: status, runtime, last active, runs (30d); model/created
opt-in hidden; filters: availability, runtime
- Operations unchanged: row kebab reuses AgentRowActions
(cancel-tasks/duplicate/archive/restore with permissions); batch
archive (confirmed) + restore; no delete — the API has none
- View store extended (scope incl. archived, sort, columns, filters);
agent-columns.tsx (DataTable columns) deleted
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): trim status track to its real worst case (160 -> 144px)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(runtimes): machine detail's runtime table on the shared ListGrid
The master-detail console keeps its shape (machines are few and
strongly categorized; left list, charts, update section untouched) —
only the right pane's runtimes table moves from TanStack DataTable to
the ListGrid family, taking the paradigm pieces that earn their keep
at 1-5 rows: subgrid template + var tracks, two-zone container
responsiveness (the pane is squeezed by the machine list, so the
core-set collapse below @2xl matters more here than on full-width
pages), min-width scroll escape valve, shared header/row/hover visual
language. Deliberately NOT taken: virtualization, sorting, filters,
column toggles, and batch selection — dead weight at this row count,
and batch-deleting runtimes (a cascade-confirm operation) is unsafe
by design.
Workload folds into the health cell ("Online · Working 2") like the
agents status cell; the owner column keeps its only-when-multiple-
owners rule via a zeroed track var. runtime-columns.tsx is deleted;
the row-menu/CLI tests render the exported cells directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(runtimes): collapse the kebab track when no row has actions
On a healthy local machine every row's only action (delete) is hidden
by the self-healing rule, leaving a permanent ~64px dead zone after
the CLI column. The action track now follows the owner column's
conditional-var mechanism: zeroed unless at least one row will show
the menu.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(runtimes): drop doubled header border, align create button with convention
PageHeader already carries border-b; the content wrappers' border-t
stacked a second line right under it (the only list page doing this).
"Add a computer" follows the chrome-button convention: outline with
text on md+, square plus icon below md — primary stays reserved for
the empty state CTA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(runtimes): health cell load suffix matches the agents status cell
"Healthy · 2 tasks" instead of the old workload vocabulary
("Working 2 +1q") — the count is unit-bearing and both surfaces now
speak one language. The queued-anomaly distinction the old words
hinted at belongs to the health layer if it ever earns surfacing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(lists): pin overflow-y-hidden on the horizontal-scroll wrappers
CSS coerces overflow-x:auto into overflow:auto on both axes, which
silently armed the list wrappers with a vertical scrollbar they were
never meant to have. Combined with the h-full grid's percentage
resolution across scrollbar-induced reflows, the wrapper's vertical
bar and horizontal bar fed each other in a non-converging layout loop
(visible as two stacked, flickering scrollbars on the agents list —
the same latent loop exists in all four wrappers; agents' wider
min-width and 64px rows just hit the trigger zone first). Vertical
scrolling belongs solely to ListGridBody; declare overflow-y-hidden
explicitly to break the loop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): single scroll container for the list (trial before rollout)
Both scroll axes move to the outer wrapper; the grid drops h-full and
the rows wrapper drops its own overflow. Kills the percentage-height
bridge between the two scroll elements that fed the flickering double
scrollbars and clipped the last row under the horizontal scrollbar.
Sticky header pins inside the scroller; vertical scrollbar now spans
the full pane (Linear's structure). Skills/autopilots follow after
visual confirmation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(lists): roll single scroll container out to skills/autopilots, add bottom clearance
ListGridBody retires its own scrolling entirely (the agents trial
confirmed the structure): both axes live on the single outer wrapper,
grids drop the h-full percentage bridge, virtualizers point at the
wrapper. The rows wrapper gains LIST_GRID_BOTTOM_CLEARANCE (64px)
appended to the virtualization padding so the last row scrolls clear
of the chat FAB (~48px at bottom-right) and the batch toolbar (~62px).
Runtimes' machine table is untouched: content-height at the top of a
tall pane, no bridge and no practical FAB overlap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(squads): rebuild list on shared ListGrid (identity rows, minimal)
The last list joins the family. Squads are the fewest entity (1-5 rows),
so this is the agents identity-row shell on the runtime-list minimal
skeleton: ListGrid subgrid + var tracks + two-zone responsiveness +
single scroll container, but NO virtualization, checkbox, or batch.
- Identity two-line rows (squad avatar + name + description, 64px) like
agents; columns: name / leader / members (polymorphic ActorAvatar
stack from member_preview), creator + created opt-in hidden
- Scope Mine/All (creator-based, issues-header styling, <md dropdown);
no archived scope (list API hard-filters archived + no restore
endpoint), no search (scope-bearing), no filters (set too small)
- Sort name (default) / members / created
- Row kebab = Archive (= the delete endpoint, which archives + transfers
issues/autopilots to the leader); workspace owner/admin only, so the
kebab track collapses for non-admins. Reuses the existing
archive_dialog copy. No batch.
- View store extended (scope + sort + columns); zero API change — pure
frontend (member_preview/count already in the list payload)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(agents,squads): owner/created-by columns + owner filter
Surface ownership as a real column on both lists, named by what the
field actually means in each permission model:
- Agents: "Owner" — owner_id is the creator (set at creation, never
transferred) and carries management rights. Promoted to a default-
visible column (avatar + name); the half-baked inline owner avatar in
the name cell is removed ("You" badge stays).
- Squads: "Created by" (NOT Owner) — creator_id holds no rights
(archiving is workspace-admin only), so Owner would mislead. Now a
default-visible column with avatar + name.
Agents also gains an Owner filter, kept orthogonal to the Mine scope by
the single-axis rule: "Mine" is the clean no-filter personal view, so
applying any filter (owner or otherwise) leaves Mine for All, and
clicking Mine clears all filters. Owner and Mine therefore never
coexist — no "mine + owner=someone-else = empty" contradiction. Squads
keep the plain Mine/All toggle (too few rows for a creator filter).
Both lists keep a Created (date) column, opt-in hidden.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): backfill new filter dimensions on rehydrate (owners crash)
A view payload persisted before the owners filter existed overwrote the
default filters wholesale on rehydrate, dropping filters.owners to
undefined and crashing the list's filter predicate (.length on
undefined). The store merge now deep-merges filters over
EMPTY_AGENT_FILTERS so newly-added dimensions always get their default.
Regression test added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills,autopilots): deep-merge filters on rehydrate too
Same latent crash the agents store just hit: the copied view-store
merge spread persisted.filters wholesale, so adding a new filter
dimension later would drop it to undefined for users with older
persisted state. Harden skills and autopilots the same way (merge over
their EMPTY_*_FILTERS) before that bug can ship.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(projects): rebuild table view on ListGrid + filters + pin/delete kebab
Projects is the dual-view list: the compact table moves onto the shared
ListGrid (subgrid tracks, two-zone responsiveness, single scroll
container, FAB bottom clearance) while the comfortable card grid stays
as the alternate view, toggled by a restyled view switch (Table/Cards
outline buttons, active = bg-accent). Inline editing is preserved —
rows are NOT whole-row links; the name navigates and status/priority/
lead stay click-to-edit (matching prior behaviour, no navigate-vs-edit
conflict).
- View store extended: viewMode + sort (name/priority/status/progress/
created) + hidden columns + filters (status/priority/lead); merge
deep-merges filters (migration-safe). No scope (lead optional/often
an agent; status is a 5-value lifecycle → filter, not scope).
- Toolbar: search (kept — scopeless list) + result count + Filter
(status/priority/lead) + Display (sort+columns, table view only).
- Row kebab: Pin/Unpin (any member, reuses the existing project pin
API — zero new endpoints) + Delete (workspace admin). Pin is the
flexible per-user favourite the list previously lacked.
- Zero API change; status/priority filtering is client-side like the
other lists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(projects): GRID_COLS must be a literal string (Tailwind can't see interpolation)
The table view's grid-cols template interpolated ${STATUS_WIDTH}px, so
Tailwind never generated the arbitrary-value class — the grid collapsed
to one column and every cell stacked vertically. Inline the literal
116px. This is the documented ListGrid rule (keep the class literal so
Tailwind scans it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(projects): single view-toggle button, decouple Display from view mode
Two fixes from the same principle — view mode is pure presentation and
must not couple to anything:
- The view switch is now ONE button that flips table ⇄ cards (shows the
current view's icon+label, tooltip names the target), instead of two
side-by-side buttons.
- The Display (sort/columns) control no longer disappears when you
switch to cards — it was gated on isCompact, so flipping the view
made it vanish (the "filter gone after switching" weirdness). It's
always present now; only the columns *section* inside the popover is
table-only (cards have no columns). Sort applies to both views.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(projects,squads): projects multi-select + squads FAB clearance/toast
Cross-list consistency audit fixes:
- projects: add multi-select (checkbox column + select-all header +
page-anchored batch toolbar) — it's a dozens-scale full-page list
like skills/autopilots/agents but was the only one missing it. Batch
ops: Pin all (any member) + Delete (workspace admin). Table view
only (cards have no checkboxes). GRID template + min-width updated
for the checkbox track.
- squads: add the FAB bottom clearance the other full-page lists have
(last row/kebab was sliding under the chat FAB).
- squads: archive success toast was showing the dialog's question
title ("Archive this squad?"); use a proper "Squad archived" key.
Intentional and left as-is (documented): squads/runtimes have no
multi-select/virtualization (1-5 rows); projects table isn't
virtualized yet (dual-view + card grid; tracked as low-risk debt).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(agents,squads): close the filter/column consistency gaps
Apply the principle "every categorical column is filterable" where it
was missing:
- agents: add a Model filter (model was a categorical column with no
filter). Distinct non-empty models from the in-scope rows.
- squads: add filters entirely (it had leader/creator columns + a
column-toggle panel but no Filter button — the only such outlier).
Leader (agent) + Creator (member) filters, with the result count and
the same Filter dropdown shape as the other lists. Store gains
SquadListFilters + toggleFilter/clearFilters + migration-safe
filters deep-merge.
autopilots creator stays default-hidden per product call (not every
"who made it" must be visible). Filter stores' partialize tests
updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(autopilots): match list-page root to flex-1 convention
skills/agents/projects roots use `relative flex flex-1 min-h-0 flex-col`;
autopilots used `h-full`. Both anchor the batch toolbar correctly, but
align the flex sizing for consistency across the six list surfaces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Fixes Cursor agent token usage parsing for top-level camelCase, nested camelCase, and legacy nested snake_case result usage shapes. Includes tests for the locally verified nested camelCase stream-json output.
The two prior MUL-3254 fixes preserved draft/description state across a
modal close, but Desktop still could not RENDER the reopened image: in
CloudFront signed-URL mode every URL the renderer holds after reopen is
unloadable. The persisted record strips the expired signed download_url,
the raw CDN url is unsigned (403 on a signed distribution), and the
durable /api/attachments/<id>/download endpoint needs credentials that a
cross-site file:// <img> fetch cannot carry (web works via the same-site
session cookie, which is why the bug was desktop-only).
Two changes close the last mile:
- /api/config now reports cdn_signed when CloudFront signing is enabled,
and pickInlineMediaURL stops picking the raw (unsigned) CDN url in
that mode — it is a guaranteed 403.
- The Attachment renderer upgrades an auth-gated media URL to a freshly
signed one via authenticated GET /api/attachments/<id> (the same
re-sign the click-time download path already does), but only on
clients without a same-origin /api proxy (api.getBaseUrl() non-empty:
Desktop, mobile webview). Cached via TanStack Query with a 20-minute
staleTime, inside the server's 30-minute signed-URL TTL.
Old servers omit cdn_signed; the schema defaults it to false so behavior
is unchanged there. Non-CloudFront deployments return the API path again
from the metadata fetch and the renderer keeps the original URL.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(openclaw): support connecting to existing OpenClaw gateway (#3260)
When the daemon host is a lightweight dev machine or CI coordinator, the
heavy agent work (LLM inference, code execution, tool use) often belongs
on a more powerful remote server already running an OpenClaw gateway.
Multica historically hard-coded `openclaw agent --local`, forcing every
turn to execute in-process on the daemon host.
This change adds an opt-in gateway routing mode controlled per-agent via
`runtime_config`:
{
"mode": "gateway",
"gateway": { "host": "...", "port": 18789, "token": "...", "tls": false }
}
- Backend: ExecOptions gains OpenclawMode + OpenclawGateway; buildOpenclawArgs
drops `--local` when mode == "gateway". Per-task openclaw-config.json
wrapper pins gateway.{host,port,auth.{mode,token},tls} so users do not
need to edit the daemon host's `~/.openclaw/openclaw.json` to point at
a different endpoint.
- Daemon: AgentData carries the raw runtime_config; decoding is fail-soft
(malformed JSON falls back to local mode rather than blocking dispatch).
- API: gateway.token is masked to "***" on every GET; PATCH replays the
sentinel back, and the update handler restores the persisted token so
the round-trip never destroys the secret. Defense-in-depth masking on
WS broadcasts, plus String/MarshalJSON masking on the in-memory struct
to block stray `%+v` / json.Marshal leaks.
- UI: openclaw-only "Routing" tab on the agent detail page with mode
selector + structured endpoint form. Token uses a "saved — submit a
new value to rotate" UX and matching backend preserve hook.
Empty `runtime_config` keeps the historical embedded behaviour, so
existing agents are unaffected.
* fix(openclaw): address #3664 review — drop dead gateway field, gate pin on mode
Per Bohan-J's review:
- Remove the dead ExecOptions.OpenclawGateway field (+ its String/MarshalJSON and
the daemon.go construction block). It carried the plaintext bearer token but was
never read — buildOpenclawArgs only consumes OpenclawMode and the live gateway
path runs through execenv.OpenclawGatewayPin — so this narrows the secret's
footprint.
- Gate the gateway pin on mode=="gateway" in decodeOpenclawRuntimeConfig: a
{"mode":"local","gateway":{...,"token"}} payload no longer writes the token into
the 0o600 per-task wrapper that --local makes openclaw ignore.
- Warn on an unrecognized non-empty mode (e.g. "gatway") instead of silently
falling back to local.
- Run preserveMaskedGatewayToken in CreateAgent too, so a literal "***" at create
time can't persist as a real bearer token.
- Document the gateway host:port trust boundary (SSRF note for shared daemon hosts).
Adds regression tests for the local-mode pin drop and the unknown-mode warning.
* fix: flush issue description editor on close
Co-authored-by: multica-agent <github@multica.ai>
* fix: make unmount flush opt-in via flushPendingOnUnmount
The unconditional unmount flush re-emitted discarded content into
composers that clear their draft and then unmount (comment edit cancel,
create-issue / feedback submit), resurrecting the cleared draft.
- Add flushPendingOnUnmount prop (default false); only the issue-detail
description editor opts in.
- Cache the pending markdown in a ref at onUpdate time and emit that
cached copy on unmount, instead of reading the editor instance during
teardown.
- Regression tests: default drops the pending update on unmount, opt-in
flush emits the cached value even when the editor is already
destroyed, no double-emit after the debounce fired, and issue-detail
pins the opt-in wiring.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
remark-math defaults to singleDollarTextMath: true, so any paragraph
containing two dollar amounts (e.g. "costs $120/mo (~$85 net)") has
the text between them parsed as inline TeX and rendered by KaTeX in an
italic math font, with ~ treated as a non-breaking space. Disable
single-dollar parsing in both web render paths, matching GitHub's
behavior; explicit $$...$$ math still renders.
Co-authored-by: Matt Voska <voska@users.noreply.github.com>
* fix: keep issue draft attachment records
Co-authored-by: multica-agent <github@multica.ai>
* fix: avoid persisting signed draft attachment urls
Co-authored-by: multica-agent <github@multica.ai>
* fix: reuse resolved media url for draft previews
Co-authored-by: multica-agent <github@multica.ai>
* fix: address draft attachment review nits
- Backfill an empty caller download_url from the in-session upload on id
collision so a just-pasted image first-paints from the signed URL
instead of detouring through markdown_url.
- Prune draft attachments no longer referenced by the persisted
description when the create dialog reopens.
- Backfill EMPTY_DRAFT defaults on draft-store rehydrate so drafts
persisted before the attachments field existed get a stable shape.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The comment card exposed two identical add-reaction affordances: a
QuickEmojiPicker in the header's top-right actions and the add button
inside the bottom ReactionBar. Keep only the bottom one.
- Drop QuickEmojiPicker from the root header and reply-row headers
- Always show the ReactionBar add button (it is the only entry point
now), removing the isLongContent gating
- Remove the now-unused hideAddButton prop from ReactionBar
MUL-3262
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Adds migration 119 creating idx_user_created_at on "user"(created_at)
using CREATE INDEX CONCURRENTLY, matching the repo convention for
index-only migrations (114/115).
Co-authored-by: multica-agent <github@multica.ai>
Fixes GitHub issue #3999 by moving the daemon StartTask transition behind workdir provisioning and extending the active env-root guard through completion metadata writes.
- suggest other profile workspace roots when disk-usage sees an empty selected root
- include the default profile in reverse suggestions and shell-quote profile arguments
- keep JSON output and explicit --workspaces-root behavior unchanged
MUL-3232
* feat(skills): structured conflict + overwrite path for local skill re-import
Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.
- New terminal import status `conflict` carrying { existing_skill_id,
existing_created_by, can_overwrite }; can_overwrite = requester is the
skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
existence (workspace-scoped) and creator permission, then replaces
description/content/config and fully replaces files (pruning files absent
from the new bundle). Preserves id, created_by, created_at, name, and
agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
create-by-name); any mid-write error rolls back the tx, leaving the original
skill untouched. Retrying a terminal request is a no-op.
Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.
Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name
Addresses review feedback on PR #3498 (MUL-2800).
Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.
Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.
Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.
Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): resolve local import conflicts in desktop
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): preserve bulk flow after conflict resolution
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): show creator name instead of UUID in import conflict UI
When a local skill import hits a name conflict with a skill owned by
another user, the locked-creator message rendered the raw
existing_created_by UUID via the {{creator}} placeholder, which is
unreadable.
Resolve the UUID against the workspace member list and render the
display name instead. When the creator has left the workspace (or the
member list hasn't loaded), fall back to the unbranded conflict_locked
message rather than leak the UUID.
Adds two test cases covering both branches.
MUL-2701
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
Two bugs prevented the Lark binding flow from completing for already-logged-in users:
1. The useEffect ran before AuthInitializer's getMe() returned, setting state to
needs-auth; the guard then blocked re-entry once auth loaded.
2. The sign-in redirect used ?redirect= but the login page reads ?next=.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
collectThreadReplies walked the parent_id tree depth-first, so an agent
reply forced to nest under its trigger comment rendered before earlier
sibling replies (A-D-B-C instead of A-B-C-D) whenever the agent returned
late. Sort the collected subtree by created_at (id tie-break) so the
thread reads in arrival order — the same order the server already feeds
agents via `comment list --thread` (ListThreadCommentsForIssue).
All other consumers of the array (resolution derivation, fold bars,
counts, deep-link) are order-independent.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(agent): clear stale session id when a resumed ACP session is gone
When an agent's stored ACP session no longer exists on the runtime side,
session/resume still succeeds — hermes echoes the requested sessionId
back — so the failure only surfaces when session/prompt returns JSON-RPC
-32603 "Session not found". The backend then reported Status=failed with
the stale SessionID still set, which kept the daemon's resume-failure
fallback (gated on SessionID == "") from ever firing. The failed task
never updates the stored session, so every future mention on the same
(agent, issue) dispatched against the same dead id, forever (#4010).
handleResponse now returns a structured acpRPCError instead of a flat
string (rendered text unchanged), and the hermes/kimi/kiro prompt-error
paths clear the session id when the error is session-not-found class on
a resumed session. The daemon's existing retry then re-executes with a
fresh session and stores the replacement id, healing the mapping.
* fix(agent): clear stale session id when set_model hits a dead resumed session
With a model override, session/set_model runs before session/prompt,
so a resumed session that is gone on the agent side surfaces there
instead of at the prompt — and the error branch returned the stale
SessionID, so the daemon's fresh-session retry (gated on
SessionID == "") never fired. Apply the same clear-the-id fix in the
set_model error branch of all three backends.
Also relax isACPSessionNotFound to accept -32602: kimi-cli raises
RequestError.invalid_params({"session_id": "Session not found"}) for
every unknown-session path (src/kimi_cli/acp/server.py), so pinning
-32603 made the fix dead code for kimi. The wording gate keeps
unrelated invalid_params errors (e.g. "model not available") on the
preserve-the-id path.
Regression tests for all three backends: resumed session + model
override + set_model failing with each runtime's observed
session-not-found shape must yield status=failed with an empty
SessionID.
CLI backends key their session stores to the cwd (Claude Code looks
sessions up under ~/.claude/projects/<encoded-cwd>/), so a prior session
id can only resolve when the task runs in the exact workdir the session
was recorded against. When the prior workdir no longer exists (GC'd
after the issue went done, daemon reinstall, manual cleanup),
execenv.Reuse falls back to a fresh Prepare but the stale session id was
still passed to the backend: claude exited within a second and the run
failed before doing any work — permanently, because the failed run
records no session_id and the next claim serves the same stale pointer
again.
Gate ResumeSessionID on the workdir actually being reused, and correct
PriorSessionResumed so the runtime brief uses the cold-path wording when
the session is dropped.
Fixesmultica-ai/multica#3854 (MUL-3221)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): structured conflict + overwrite path for local skill re-import
Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.
- New terminal import status `conflict` carrying { existing_skill_id,
existing_created_by, can_overwrite }; can_overwrite = requester is the
skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
existence (workspace-scoped) and creator permission, then replaces
description/content/config and fully replaces files (pruning files absent
from the new bundle). Preserves id, created_by, created_at, name, and
agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
create-by-name); any mid-write error rolls back the tx, leaving the original
skill untouched. Retrying a terminal request is a no-op.
Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.
Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name
Addresses review feedback on PR #3498 (MUL-2800).
Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.
Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.
Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.
Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): resolve local import conflicts in desktop
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): preserve bulk flow after conflict resolution
Co-authored-by: multica-agent <github@multica.ai>
* feat(cli): add skill import conflict strategies
Co-authored-by: multica-agent <github@multica.ai>
* fix(i18n): sync skill import locale keys
Co-authored-by: multica-agent <github@multica.ai>
* docs: explain skill import conflict handling
Co-authored-by: multica-agent <github@multica.ai>
* docs: refresh skill import source map anchors
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The preview answer depends on live queue state (pending-task dedup), not
just the mention set, so three staleness bugs showed up around it:
- staleTime: Infinity pinned a "nobody triggers" snapshot taken while
the mentioned agent was still queued — the chip never appeared even
though sending really did wake the agent (create recomputes).
-> staleTime: 0, cached signatures revalidate in the background.
- The in-flight gap on a signature change rendered as an empty agent
list, flickering the chips and wiping the composer's suppressed-id
set via the pruning effect. -> placeholderData: keepPreviousData.
- Nothing refreshed an open composer when an agent's task finished.
-> the WS task-lifecycle handler now also invalidates the
commentTriggerPreviewAll prefix, so chips appear mid-typing the
moment the agent becomes triggerable again.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Five chip states get distinct copy instead of sharing one sentence and a
vague "not this time":
- single, will trigger: Starts working when sent (unchanged)
- single, skipped: Won't be triggered
- several, k will fire: {{count}} agents start working when sent —
the count covers only non-suppressed agents; skipped ones read as the
dimmed heads in the stack next to the number
- several, all skipped: No agents will be triggered
- popover row state: Skipped
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Table.configure had renderWrapper unset (defaults to false), so tables
rendered as bare <table> elements with no .tableWrapper div. The
overflow-x: auto rule in prose.css targets .tableWrapper and never
matched, so a wide table pushed the horizontal scrollbar onto the
issue detail's page-level scroll container instead of scrolling
within the table itself.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- Copy: one fixed sentence for single and stacked chips — the avatar(s)
carry who and how many, the text carries condition + outcome
("发送后开始工作" / "Starts working when sent"), killing the
"is it already running?" misread. Drops the per-name and count keys.
- Color: sidebar-style resting state — muted-foreground until hover so
the strip reads as metadata, not content.
- Motion: pure fade-in (no slide offset).
- Spacing: reply composer reserves pb-9 so the chip strip reads as a
footer instead of a second content line glued to the text.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The issue description editor bound pending uploads with
`md.includes(a.url)`, but the editor persists the durable markdownLink
(`/api/attachments/<id>/download` / markdown_url), never the raw storage
`a.url`. The filter therefore never matched, so description uploads were
never linked via `attachment_ids`.
After reload the attachment was absent from `issueAttachments`, so the
renderer could not resolve it to a freshly-signed CDN `download_url` and
fell back to the persisted auth-gated download endpoint. That endpoint
loads on web (same-site cookie / proxy) but fails as a native <img> on
Desktop/Electron (cross-origin file:// renderer carries no auth), leaving
the image broken — while comments rendered fine because they already bind
via contentReferencesAttachment.
Switch the description binding to contentReferencesAttachment, matching
the comment/reply/chat composers, so description images resolve to the
signed CDN URL on every client. Add a regression test pinning the
absolute-host markdown_url shape.
* Add comment trigger preview suppression
Co-authored-by: multica-agent <github@multica.ai>
* Use TanStack Query for trigger preview
Co-authored-by: multica-agent <github@multica.ai>
* Test note comments skip create triggers
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): redesign comment trigger chips as avatar chips
Single agent renders as avatar + presence dot + full sentence; several
agents collapse to an overlapping stack + active count, mirroring the
header working chip. Per-agent skip moves into a click-opened popover
(hover layers stay read-only tooltips); suppression reads as brightness,
not a ban glyph. Loading and preview errors render nothing.
Also: share one tooltip body across chip and popover rows, invalidate
cached previews after a comment lands (the enqueued task changes the
dedup answer), move the preview query key into issueKeys, and drop the
now-unconsumed status field from useCommentTriggerPreview.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(server): drop comment trigger wrappers kept only for tests
enqueueMentionedAgentTasks and shouldEnqueueSquadLeaderOnComment had no
production callers after the compute/enqueue split — the comment path
goes through computeCommentAgentTriggers. Tests now exercise the compute
functions directly via package-local helpers, so the legacy adapters
cannot drift from the real path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(skills): sync mentioning/squads source maps with shared trigger computation
The squads source map still pointed the comment-trigger contract at the
pre-refactor call chain (comment.go:940 -> shouldEnqueueSquadLeaderOnComment),
and the mentioning skill referenced the deleted wrapper. Re-anchor both
to computeCommentAgentTriggers / computeAssignedSquadLeaderCommentTrigger
/ computeMentionedAgentCommentTriggers with current line numbers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.
Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.
Server:
- AttachmentResponse gains a markdown_url field, computed by
buildMarkdownURL from the deployment policy:
• storage URL is already absolute + unsigned (public CDN, S3 public
bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
use it verbatim;
• CloudFront-signed mode → never expose the raw S3 URL (private
bucket); return cfg.PublicURL + /api/attachments/<id>/download so
the server can re-sign on every request;
• LocalStorage relative + cfg.PublicURL set → same prefixed API
endpoint;
• cfg.PublicURL unset → fall back to site-relative path so web's
Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
signature query params, so a freshly-signed download_url can never
leak into persistence — the original MUL-3130 bug stays closed.
Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
carry markdown_url. Schema lenient-defaults to '' so a backend old
enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
(1) att.markdown_url (modern server),
(2) attachmentDownloadPath(att.id) — legacy site-relative shape,
retained for backends old enough to omit markdown_url,
(3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
reframed as the legacy-compat fallback for already-persisted
/api/attachments/<id>/download or /uploads/<key> URLs in old
bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
AttachmentDownloadProvider so Quick Create's editor can swap the URL
via the resolver during the same session even before the server-side
binding lands.
Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
tests (public CDN passthrough, CloudFront-signed swap, relative
prefixing, PublicURL unset fallback, trailing-slash strip) + 15
table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.
Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.
Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.
Refs: MUL-3192
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): invalidate per-issue caches on WS reconnect (MUL-3189)
Per-issue caches (timeline, reactions, subscribers, usage, attachments,
tasks) are keyed without wsId, so the issueKeys.all(wsId) prefix in
invalidateWorkspaceScopedQueries never reached them. With the
staleTime: Infinity default they rely entirely on WS events for
freshness, so a comment:created event lost during a disconnect (e.g.
macOS sleep) left the timeline stale until a full view reload — the
inbox showed the agent's new comment while the issue's comment area
stayed empty.
Add *All prefix helpers for the per-issue key families and invalidate
them in the reconnect / WS-instance-change recovery path. Inactive
caches are only marked stale and refetch on next mount; the mounted
issue refetches immediately, matching its existing useWSReconnect
behavior, so this does not reintroduce the MUL-1941 memo thrash.
Fixes#3953
Co-authored-by: multica-agent <github@multica.ai>
* refactor(core): define issueKeys.tasks via tasksAll prefix helper
Review nit on #3992 — keep the per-issue key families consistently
defined in terms of their *All prefix helpers. No behavior change.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
A thread could hold multiple resolved comments at once: ResolveComment
was a plain per-row setter that never cleared the prior resolution, and
"replacing" one was a display-only illusion (deriveThreadResolution
picks the max resolved_at). The stale rows stayed resolved in the DB and
the optimistic update flashed the new resolution, then reverted.
Make single-resolution-per-thread a write invariant:
- ClearOtherThreadResolutions: thread-scoped clear via a RECURSIVE CTE
(root + descendants of the target, id <> target), returns each cleared
row.
- ResolveComment handler runs the clear + set in one tx so the replace
is atomic. It emits comment:unresolved per cleared sibling (granular
realtime consumers patch a single comment in place and would otherwise
keep showing the stale resolution). Target keeps its COALESCE
idempotency and the re-resolve event suppression.
- Frontend optimistic update mirrors the invariant: resolving clears
every other resolution in the same thread, so the cache never shows
two at once. Unresolve still only clears its own row.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilots): show creator in autopilot detail properties (MUL-3139)
The autopilot creator was already persisted end-to-end (created_by_type /
created_by_id on the autopilot table, exposed via AutopilotResponse and the
frontend Autopilot type) but never rendered. Add a "Created by" field to the
detail page Properties section, mirroring the existing assignee field and the
issue-detail creator row, reusing ActorAvatar + getActorName.
Creator may be a member or an agent (the HTTP create path stamps member today,
but backend logic also writes created_by_type=agent), so the display resolves
both actor types and does not assume member. List rows are intentionally left
unchanged, matching the issue convention (creator lives in detail, not lists).
Adds the field_created_by label to all four locale bundles (en/zh-Hans/ja/ko);
locale parity test enforces full coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilots): show creator in autopilots list (MUL-3139)
Add a Created by column to the autopilots list, mirroring the detail
page. Secondary columns (creator, mode, last run) are hidden below lg
so small screens keep only name, agent, and status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): report OS in /health response
The desktop app reads daemon liveness over HTTP but starts/stops it via the
native CLI, which acts on the host process namespace. On Windows with the
daemon in WSL2, /health is reachable via localhost forwarding yet the daemon's
process is unreachable — so the app needs a signal to tell a daemon it manages
from one it merely sees. Expose runtime.GOOS as `os` so the desktop can
compare it against its own host OS. MUL-3154, #3916
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): disable auto-start/stop for an unmanageable daemon
When the daemon runs in an environment the app can't drive — e.g. Linux in
WSL2 behind a Windows desktop, reachable only via localhost forwarding — the
Auto-start/Auto-stop toggles silently did nothing: the lifecycle CLI acts on
the host process namespace and never reaches the daemon's PID.
Detect it by comparing the daemon's reported OS (new /health `os` field)
against the host OS, and only when a daemon is actually running. When they
differ: disable both toggles with an explanatory note, skip the version-match
restart on auto-start, and skip the no-op stop on quit. Fails safe — a missing
`os` (older daemon) or a matching OS keeps the toggles live, so native
Mac/Windows/Linux daemons are unaffected.
MUL-3154, #3916
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): centralize externally-managed guard at the lifecycle boundary
Review follow-up. The first cut only disabled the Settings toggles, but the
same unmanageable daemon (WSL2 etc.) could still be Stop/Restart-ed from the
Runtime card and from automatic lifecycle entries (logout, user switch,
reauth, first-workspace restart) — each of which would shell out to a native
CLI that can't reach the daemon's process.
Move the guard into the main-process lifecycle functions so every entry point
is covered by construction: stopDaemon() and restartDaemon() no-op for an
externally-managed daemon, and ensureRunningDaemonVersionMatches() treats it
as up-to-date (no misleading restart). The per-branch checks in the auto-start
handler and before-quit are removed — the boundary now covers them. The
Runtime card hides Stop/Restart and shows a 'Managed outside the app' hint,
mirroring the Settings tab. Adds a component test for the card's two states.
MUL-3154, #3916
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): preflight the lifecycle guard against live /health
Review follow-up. The guard read a cached lastExternallyManaged, which only
fetchHealth() updates — but not every lifecycle entry polls before calling
stop/restart. syncToken()'s user-switch branch calls restartDaemon() directly
after its own fetchHealthAtPort(), without refreshing the cache; on a fresh
launch / account switch (no poll yet) the cache is still the initial false, so
restartDaemon() would shell out to the native CLI and hit the very WSL/native
PID-namespace problem this PR avoids.
Make stopDaemon()/restartDaemon() preflight against a live /health read each
call instead of trusting the poll cache. The decision is extracted to a pure
daemonLifecycleUnreachable(readDaemonOS, hostOS) so a unit test can prove the
*live* value (not a cache) drives it. lastExternallyManaged is removed — the UI
already reads the per-status externallyManaged field, so it had no other
consumer.
MUL-3154, #3916
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(transcript): reuse taskMessageToPayload in WS broadcast
The ReportTaskMessages WebSocket broadcast hand-built the payload and
duplicated the created_at formatting that taskMessageToPayload already
does. Reuse the helper with the just-inserted row, which carries the
same redacted values and the DB-assigned timestamp.
Co-authored-by: multica-agent <github@multica.ai>
* test(transcript): cover coalesce created_at behavior
Lock in that coalescing streaming fragments carries the latest
created_at, and falls back to the previous timestamp when the merged
fragment has none.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The initiator_user_id column (added in 117) carried a foreign key to the
"user" table. Adding that FK also locks the hot "user" table at migration
time, which made the ALTER time out on a busy production deploy. The
column only feeds a best-effort name/email lookup at claim time (a stale
id just yields no `## Task Initiator` section), so referential integrity
is not load-bearing.
- Edit 117 to add a plain `UUID` column (no FK). The original timed-out
deploy never recorded 117, so its retry now runs the FK-free version.
- Add 118 to `DROP CONSTRAINT IF EXISTS` for environments that already
applied the constraint-bearing 117 (they skip the edited 117 by
version). All environments converge to a plain, FK-free column.
No code/codegen change: dropping the FK does not affect the Go column
type, so sqlc output is unchanged. Verified locally: 118 drops the FK and
keeps the column; sqlc regen produces no diff; build/vet/tests pass.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Threads the existing task_message.created_at column through the full stack (Go protocol -> REST/WS handlers -> TS types -> transcript dialog) so agent run transcripts show per-entry timestamps, helping users spot stalled runs. Additive, no migration.
Treats Cursor's stream-json terminal `result` event as the protocol completion boundary so a lingering Cursor worker process can no longer hold the daemon task open after the agent has produced its final result.
- Tighten `cmd.WaitDelay` to 500ms (set before `Start()`)
- Set `resultSeen` and `cancel()` on terminal `result`
- Preserve completed/failed status across the cancellation via two `!resultSeen` guards in the post-loop status decision
- Add unix fake-CLI coverage for success and `is_error` terminal results
* feat(issues): per-comment thread resolution with sticky collapse
Allow resolving any comment, not just roots. Resolving a root folds the
whole thread into one bar (existing); resolving a reply marks it as the
thread's resolution ("Resolve thread with comment") and folds the other
replies behind a "N comments" bar, with the resolution kept visible and
badged. Which comment is the resolution is a pure frontend derivation
(root wins, else latest resolved reply), so no write-side bookkeeping is
needed and any resolved_at combination renders one resolution.
- backend: drop the "only root comments can be resolved" guard
- views: deriveThreadResolution + reply-resolution rendering, sticky
collapse/fold bars (overflow-clip on the card so sticky resolves to the
timeline scroll parent), scroll the folded thread back into view on
collapse, ListChevronsDownUp icon, locales (en/ja/ko/zh-Hans)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(issues): sticky comment headers for long comments
Pin each comment's header (root + replies) to the timeline's scroll
parent while reading, so a long comment keeps its author + actions
visible instead of scrolling out of reach. Exactly one header is pinned
at a time:
- Reply headers stick within their own CommentRow box (release at the
reply's end).
- The root header is wrapped in a root-section container so its sticky
containing block spans only the header + root body — without it the
containing block is the whole thread and the root header stays stuck
behind every reply. Replies render outside the wrapper, gated on open.
- Skip the root header sticky whenever a resolution collapse bar already
owns the top-0 slot (root resolved+expanded, or reply-resolution
expanded) to avoid two bars stacking at the same offset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize SyncRunFromLinkedIssueTask beyond Codex no-progress: any
terminal create-issue task failure with no retry still in flight now fails
the linked autopilot run, so it can no longer hang in issue_created
(invisible to the failure-rate auto-pause monitor).
- fail the linked run for any terminal task failure, gated by the existing
HasActiveTaskForIssue wait-for-retry guard
- remove the isNoProgressTaskFailure classifier (subsumed; drops duplicated
pkg/agent marker literals)
- drop the redundant GetIssue/origin lookup; GetAutopilotRunByIssue leads
and short-circuits ordinary failures in one query
- tests: keep no-progress regression, add agent_error (non-retryable) and
retry-pending cases
Follow-up to #3927. VEN-661 / VEN-662 / MUL-3164
Opening an inbox comment notification on an issue with a running agent
shoved the whole desktop page — header included — off the top, and no
amount of scrolling brought it back; only toggling the right sidebar
(which reflows the panel group) restored it.
Root cause: the deep-link landing uses native
scrollIntoView({block:"center"}) on the target comment. Native
scrollIntoView is spec'd to scroll EVERY scrollable ancestor. On a cold
mount where the timeline is still streaming (is-working) and the sidebar
panel starts collapsed, the inner timeline scroller can't center the
target on its own, so the scroll propagates up and scrolls the desktop
shell's overflow:hidden wrapper (desktop-layout.tsx). That wrapper has no
scrollbar and doesn't auto-clamp, so the page stays shoved up until a
resize reflows it. Desktop-only: web sits in a document-scroll context
with a real scrollbar that self-corrects.
Fix: drive the timeline container's scrollTop directly and re-center
across rAF frames until async heights settle, instead of leaning on the
ancestor scroll. The scroll never touches an ancestor, so the header can
no longer be pushed off-screen.
Tests assert the user-facing contract (lands on + highlights the target
comment) rather than the scroll mechanism, which jsdom can't lay out.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex telemetry was never reaching the OTLP collector for tasks run by the
daemon. The per-task config (including the [otel] block) is copied into
CODEX_HOME correctly, but the lifecycle goroutine closed stdin and then
immediately cancelled the run context, which SIGKILLs the app-server. Codex's
OTEL batch exporters only force-flush on a graceful shutdown, so the buffered
spans/metrics/logs were dropped before they could be exported — short tasks
lost everything, long tasks lost the final batch.
Let codex exit on its own after stdin EOF (running its shutdown + flush path)
and only force-cancel after a bounded grace period if it doesn't, so the reader
goroutine still can't block forever. Also set cmd.WaitDelay, matching the other
long-lived backends (claude, copilot, cursor, …).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* MUL-3130: persist a stable attachment download URL in comment markdown
Comment image attachments rendered as broken placeholders ~30 minutes
after upload because the editor was persisting a short-lived
HMAC-signed URL into the comment body. After PR #3903 (MUL-3132)
hardened /uploads/* with auth, `attachmentToResponse` started signing
`attachment.url` as `/uploads/<key>?exp=<unix>&sig=<HMAC>` for
LocalStorage so token-auth clients could keep loading inline images.
The signature has a 30-min TTL by design — but `useFileUpload` was
returning that signed value as `link` and the editor was writing
`` straight into the markdown, so the comment
permanently captured a URL that stopped working as soon as the
signature expired.
The fix is to persist a stable per-attachment URL that the server can
re-sign on every request:
* `useFileUpload` now returns `link = /api/attachments/<id>/download`
(avatar uploads without an id still fall back to `att.url` so the
pre-attachment-row code paths keep working).
* `DownloadAttachment` self-resolves the workspace from the attachment
row instead of reading X-Workspace-Slug / X-Workspace-ID headers,
and the route is registered under the auth-only group so a native
browser <img>/<video> resource load (which cannot attach those
headers) succeeds. Membership is checked inside the handler with
a 404 deny shape so the route does not act as an IDOR oracle.
* A new `GetAttachmentByIDOnly` SQL query supports the workspace-
derivation step.
* `AttachmentDownloadProvider` now extracts the attachment id from
the stable URL when matching markdown refs to attachment records,
with a fallback to the existing url-equality check for legacy
comments (and S3/CloudFront markdown that points straight at the
CDN).
* `contentReferencesAttachment` covers both URL shapes for the
composer / standalone-list dedup paths so an attachment uploaded
before the fix and one uploaded after both deduplicate cleanly.
Tests:
- New unit tests for the URL helpers (16 tests, packages/core).
- Backend regression test: bare `<img src>`-style request without
workspace headers now succeeds for a member (200) and 404s for a
non-member, replacing the previous "400 without workspace context"
contract.
- Existing TestDownload*, TestServeLocalUpload*, TestAttachmentTo
Response* and the 1220 frontend views tests all pass.
Refs: MUL-3130, GitHub issue #3891
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3130: address PR review — split markdown link from upload link, swap render src
Two follow-ups from GPT-Boy's review on PR #3937.
(1) Don't reroute every upload consumer through the workspace-gated
download endpoint.
The previous change made `useFileUpload`'s `link` field unconditionally
return `/api/attachments/<id>/download` whenever the upload had an id.
But `useFileUpload` is also used by avatar / logo pickers
(account-tab, workspace-tab, agents/avatar-picker, squads/squad-detail-page)
that persist `result.link` directly into `avatar_url`. Avatars are
referenced cross-workspace (mention chips, member lists, inbox
items), so binding their URL to a workspace-membership-gated
endpoint would silently break cross-workspace avatar visibility.
The fix splits the URL into two semantically distinct fields:
- `link` — same as `att.url` (legacy contract). Avatar /
logo callers continue to use this and remain
on whatever URL semantics the storage backend
dictates.
- `markdownLink` — the stable per-attachment URL
`/api/attachments/<id>/download`. Only the
editor's markdown-persisting flow consumes
this. Falls back to `link` for the
no-workspace upload branch (where there is
no attachment-row id to address).
`editor/extensions/file-upload.ts` switches `image.src` and
`fileCard.href` to `markdownLink ?? link` so comment markdown gets
the stable shape while avatar callers stay on `link` unchanged.
(2) Make the render-time img src loadable for token-mode clients.
Persisting the stable `/api/attachments/<id>/download` URL fixes the
expiry problem but the path itself sits behind `middleware.Auth`,
which expects either a `multica_auth` cookie or a Bearer token in
`Authorization`. Native `<img>`/`<video>` resource loads from
token-mode clients (Electron's default mode, the mobile app,
legacy-token web sessions) cannot attach the Authorization header,
so the bare URL would 401 immediately rather than 30 minutes later.
`Attachment.normalize` now runs the resolved record through a new
`pickInlineMediaURL` helper that returns:
- `record.download_url` when it's an absolute URL with a
recognised CDN signature query (CloudFront-signed
`Signature` / `Expires` / `Key-Pair-Id`, or
`X-Amz-Signature` for raw S3 presigns) — these load as
native resource src in any client.
- else `record.url`, which on the LocalStorage backend carries
a freshly-minted `/uploads/<key>?exp&sig` query whose
signature IS the auth (token-mode-loadable). On non-CF S3
backends this is the raw stored URL — same behaviour as
today.
- else the original input URL (legacy / unresolved markdown
keeps its existing path).
This gives the same effect for both `kind: "record"` and
`kind: "url"` attachment inputs: once a record is in hand, the
rendered media src is whichever URL the current backend exposes
a working signature on.
Tests:
- New `file-upload.test.ts` regression pinning that `markdownLink`
is what lands in the markdown body when the upload result returns
both a short-lived storage URL and a stable download path.
- Updated `attachment.test.tsx` to reflect the new render-time
swap (the rendered img src now follows the freshly signed URL,
not the raw storage URL) and added a record-mode regression
pinning the LocalStorage default — when `download_url` is the
bare /api/attachments/<id>/download path, the renderer must fall
through to the signed `record.url`.
- Updated `chat-input.test.tsx` makeUpload helper for the new
`markdownLink` UploadResult field.
- 1222 frontend views tests + 507 core tests + typecheck across
@multica/{core,ui,views} all pass.
Refs: MUL-3130, GitHub issue #3891. Builds on a740f7a35.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3130: chat upload map keys on persisted markdownLink, not the short-lived link
GPT-Boy's second-round review on PR #3937 caught a chat-only blocker
left over from the previous fix.
After the previous commit split `UploadResult.link` into `link`
(legacy avatar/logo URL) and `markdownLink` (stable per-attachment
URL persisted into markdown), the comment editor's image src + file
card href correctly switched to `markdownLink ?? link`. But chat
input still kept the upload-map key on the old `link`:
uploadMapRef.current.set(result.link, result.id)
…
if (content.includes(url)) activeIds.push(id)
In the LocalStorage backend `link` is the short-lived
`/uploads/<key>?exp=&sig=` URL. The editor persists the stable
`/api/attachments/<id>/download` URL into the message body, so
`content.includes(url)` never matches and the send call drops
`attachment_ids`. The attachment ends up bound only to the chat
session, not to the message — agents reading message-level metadata
see no attachments.
Fix: key the upload map on the same value the editor actually wrote
into the markdown body (`markdownLink || link`). The
`content.includes(url)` check then matches and the attachment id is
correctly forwarded on send.
Tests:
- Updated the chat-input mock editor to insert `markdownLink || link`
into its value, mirroring the real editor's persisted-URL choice
(uploadAndInsertFile in editor/extensions/file-upload.ts). Without
this the mock would silently paper over the bug.
- Added a regression test where the upload result returns a
short-lived `link = /uploads/...?exp&sig` and a stable
`markdownLink = /api/attachments/<id>/download`. Asserts (a) the
message body carries the stable URL and never the signed query,
and (b) the bound `attachment_ids` includes the attachment id.
All 1223 frontend views tests pass (was 1222, +1 new regression).
Typecheck and 507 core tests still green.
Refs: MUL-3130, PR #3937 review by GPT-Boy. Builds on f66a522d0.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Fail create-issue autopilot runs that hang in issue_created after a Codex
no-progress / semantic-inactivity task failure, so they surface as failed
and count toward the failure-rate auto-pause monitor.
- route failed create-issue issue tasks (no direct autopilot_run_id) into linked run sync
- fail linked runs only for Codex no-progress / semantic-inactivity failures
- wait when an active retry task still exists for the issue
- add classifier coverage + a DB-backed listener regression
VEN-661 / VEN-662 / MUL-3164
Summary:
- Add CLI config schema for OpenClaw backend binary path and state dir overrides.
- Apply those overrides during daemon LoadConfig using the existing env-var based probe/spawn path.
- Cover backward compatibility, precedence, partial overrides, and fail-soft config loading.
Verification:
- go test ./internal/cli ./internal/daemon
- go vet ./internal/cli ./internal/daemon
- GitHub CI passed
* fix(cli): honor MULTICA_SERVER_URL in setup self-host
`multica setup self-host` resolved the backend URL only from the
--server-url flag, falling back to http://localhost:8080 when the flag
was absent. It never consulted MULTICA_SERVER_URL, even though that env
var is documented on the root --server-url flag and in `multica --help`,
and is honored by every other command via resolveServerURL. A self-host
user who set the env var instead of the flag still hit localhost and got
"Server at http://localhost:8080 is not reachable".
Route server-url and app-url through cli.FlagOrEnv so the documented env
vars (MULTICA_SERVER_URL / MULTICA_APP_URL) are honored when the matching
flag is not set, with the flag still taking precedence. userProvided now
reflects flag-or-env, so an env-sourced remote URL still triggers the
explicit app_url prompt. Not platform-specific despite the report.
Fixes GitHub #3912.
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): normalize MULTICA_SERVER_URL in setup self-host
MULTICA_SERVER_URL is documented as a ws:// daemon address
(ws://localhost:8080/ws) and every other command normalizes it via
NormalizeServerBaseURL before use. setup self-host consumed the resolved
value raw and probed <url>/health, so a self-hoster who set the
documented ws:// form would still fail the reachability check.
Run the flag/env value through normalizeAPIBaseURL (ws->http, wss->https,
strip /ws) so the documented form works and the stored server_url stays a
clean http(s) base. Add a normalization test case and a focused test for
the MULTICA_APP_URL env path (review nit).
Co-authored-by: multica-agent <github@multica.ai>
* docs(self-host): note setup self-host honors MULTICA_SERVER_URL / MULTICA_APP_URL
Document that `setup self-host` reads the env vars when the matching flag
is omitted (flag wins), and that MULTICA_SERVER_URL accepts the ws://…/ws
daemon form. Added to en/zh/ja/ko quickstart for parity.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): return 400 (not 500) for invalid project status/priority
CreateProject/UpdateProject passed an unvalidated status/priority straight to
the INSERT, so an unknown value (e.g. --status active) tripped the table's
CHECK constraint and surfaced as a blanket 500 'failed to create project'
with no server-side log to diagnose it (#3925).
Pre-validate both enums against the column CHECK lists and return a 400 with
the allowed values. Back it with isCheckViolation -> 400 for any other
constrained column, and log the underlying error on genuine 500s so transient
DB failures are diagnosable.
MUL-3153
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): validate project --status in create/update
project create and project update forwarded --status to the server without
checking it, while project status already validated. Share a single
validateProjectStatus helper across all three so a typo fails fast with the
valid list instead of a server round-trip.
MUL-3153
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs(cli): add Error Messages conventions + refine sign-in copy (PR3)
Final pass of the CLI error-message work (MUL-3104).
- CLI_AND_DAEMON.md: new "Error Messages" section documenting the user-facing
contract — friendly single-line messages, server validation passthrough,
English default with automatic Chinese on a zh locale, the tiered exit codes
(0/1/2/3/4/5), --debug / MULTICA_DEBUG for the full chain, and
MULTICA_HTTP_TIMEOUT.
- cmd_auth.go: clarify three high-frequency sign-in errors so the message
states what failed and the next step — local login-callback server start
(hints at port/firewall), access-token creation, and token verification
(suggests retrying `multica login` and checking the token is valid/not
expired). All keep %w so exit-code tiering and --debug detail are preserved.
cmd_id_resolver.go is left as-is — its not-found / ambiguous-prefix messages
already point at `list --full-id` and need no change. The user-facing
FormatError layer is unchanged, so its existing PR1/PR2 test coverage still
applies; no test asserted the old verb strings.
Refs MUL-3104. PR3 of 3 (final).
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): make login failure guidance visible via typed user-message wrapper
Addresses 张大彪's PR3 review: the refined sign-in copy was wrapped with %w,
so FormatError returned the centralized *HTTPError/*NetworkError copy and the
new guidance only appeared under --debug.
- Add cli.UserMessageError + cli.WithUserMessage: a typed wrapper carrying a
user-facing message that FormatError surfaces by default, recognized before
the network/http branches. Unwrap() is preserved, so ExitCodeFor still
classifies by the underlying typed error and --debug still prints the full
original chain.
- cmd_auth.go: wrap the OAuth access-token-creation and PAT-verification
failures with WithUserMessage (OAuth copy no longer mentions a passed token,
since that flow has none), and move the token-specific 'valid / not expired'
hint to the real Enter your personal access token: verification site (was the generic
'invalid token: %w').
- Focused tests: under a wrapped *HTTPError(401) the default FormatError shows
the login hint, ExitCodeFor returns ExitAuth, and --debug retains the raw
chain; a wrapped *NetworkError still classifies as ExitNetwork.
- CLI_AND_DAEMON.md: narrow 'every error' to command errors returned to the
top-level handler, noting commands like setup's fast /health probe bypass it.
Refs MUL-3104, PR #3900.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP)
Closes the open hardening items from the SVG XSS disclosure
(security-findings-2026-06-02). The primary chain (PR #3023 / #3050)
is intact; this PR addresses every remaining recommendation from the
disclosure's hardening list except 'serve uploads from a separate
origin' (a structural change beyond this fix).
Changes:
- /uploads/* now requires authentication. The route is wrapped in
middleware.Auth so anonymous internet users can no longer fetch
workspace attachments by guessing the URL. A new ServeLocalUpload
handler then enforces the second layer:
- workspaces/{wsID}/* paths require membership in wsID (uses
MembershipCache for the hot path);
- users/{userID}/* paths allow any authenticated user (avatars
are referenced cross-workspace);
- any other prefix returns 404, so a future feature cannot drop
content under /uploads/<other-prefix>/ and inherit a relaxed
policy by accident.
Non-members see 404 (not 403) so the route does not act as an IDOR
oracle for workspace IDs.
- Directory listing on /uploads/* is rejected at the storage layer:
empty keys, trailing-slash keys, and any key that resolves to a
directory return 404 before http.ServeFile would render an HTML
index. UUID filenames were obscurity, but enumerating them
shouldn't be free.
- Every successful /uploads/* response carries
X-Content-Type-Options: nosniff and a tight per-response CSP
(default-src 'none'; sandbox; frame-ancestors 'none'), overriding
the application-wide CSP. This is belt-and-suspenders if a future
regression weakens the Content-Disposition: attachment path.
- UploadFile rejects HTML-family uploads at the edge (.html, .htm,
.xhtml, .shtml, .xht, .phtml, plus a content-type denylist for
text/html and application/xhtml+xml so renamed payloads cannot
bypass the extension check). SVG and JS remain allowed because
their existing serve-side defenses neutralize them and source-code
attachments preview as text/plain via /api/attachments/{id}/content.
Tests:
- storage: TestLocalStorage_ServeFile_RejectsDirectoryListing,
TestLocalStorage_ServeFile_HardeningHeaders.
- handler: TestIsUploadDenied (pure), TestUploadFile_RejectsHTMLByExtension,
TestUploadFile_RejectsHTMLByContentType, TestUploadFile_AllowsLegitimateImage,
and the full ServeLocalUpload matrix (RequiresAuth, MemberCanRead,
NonMemberDenied, RejectsDirectoryInPath, UnknownPrefixDenied,
UserPrefixAllowsAnyAuthedUser).
- Full server test suite passes.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3132: HMAC-signed query auth for /uploads/* (token-auth client compat)
Addresses J's Request Changes review on PR #3903.
Problem: PR #3903 wrapped /uploads/* in middleware.Auth, but native
<img>/<video>/<iframe> resource loads cannot attach Authorization
headers. Token-auth clients (Desktop default, legacy-token Web
sessions, mobile) were breaking on inline attachment rendering even
though the API itself authenticated fine.
Fix: implement HMAC-signed query parameters for /uploads/*, mirroring
S3 + CloudFront presigned URLs.
- storage.SignLocalUploadURL(rawURL, key, secret, expiry) appends
'?exp=<unix>&sig=<HMAC-SHA256(key|exp)>' query params; signature
is bound to one specific key, has a TTL matching CloudFront mode
(defaultAttachmentDownloadURLTTL = 30 min), constant-time compared
on verify.
- storage.VerifyLocalUploadSignature(key, exp, sig, secret, now)
rejects expired, tampered, wrong-secret, and key-mismatched
signatures.
- ServeLocalUpload now has two auth paths: signed-query (no Auth
middleware needed; signature itself is the authority) and
Bearer/cookie (membership-gated as before). Partial signed-query
fails closed.
- The route in router.go dispatches between the two: if both exp+sig
query params are present, route to inner handler unwrapped; else
wrap in middleware.Auth.
- attachmentToResponse appends signed query to URL when the storage
backend is *LocalStorage. CloudFront-signed download URLs and S3
paths are unchanged.
Tests:
- storage: TestSignAndVerifyLocalUploadURL_RoundTrip,
TestVerifyLocalUploadSignature_RejectsExpired, _RejectsTamperedSig,
_BoundToKey, _RejectsWrongSecret,
TestSignLocalUploadURL_PreservesExistingQuery,
TestLocalUploadSignatureFromQuery_EmptyOnAbsence (7 pure tests).
- handler: TestServeLocalUpload_{SignedQueryBypassesAuth,
SignedQueryRejectsExpired, SignedQueryRejectsTampered,
SignedQueryBoundToOneKey, PartialSignedQueryFailsClosed},
TestAttachmentToResponse_LocalStorageMintsSignedURL.
Full server test suite passes.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code when working in this repository. Keep this file short and authoritative: rules here should be hard to infer from code or easy to get wrong.
## Conventions reference
## Conventions
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
The source of truth for code naming, i18n glossary, and Chinese product voice is:
Read it before editing translations in `packages/views/locales/`, naming routes/packages/files/DB columns/types, or writing Chinese UI/docs copy. Do not rely on `packages/views/locales/glossary.md`; it is only a redirect stub.
- Writing or editing translations (`packages/views/locales/`)
- Naming a new route, package, file, DB column, or TS type
- Writing Chinese product copy (UI strings, error messages, docs)
## Project Shape
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
Multica is an AI-native task management platform for small teams, with agents as first-class assignees that can own issues, comment, and change status.
## Project Context
-`server/`: Go backend, Chi router, sqlc, gorilla/websocket.
-`apps/web/`: Next.js App Router.
-`apps/desktop/`: Electron desktop app.
-`apps/mobile/`: Expo / React Native iOS app. Read `apps/mobile/CLAUDE.md` before touching it.
-`packages/core/`: headless business logic, API client, React Query hooks, Zustand stores.
-`packages/ui/`: atomic UI components only.
-`packages/views/`: shared business pages/components for web and desktop.
-`packages/tsconfig/`: shared TypeScript config.
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
Shared packages export raw `.ts` / `.tsx` and are compiled by consuming apps. Dependency direction is `views -> core + ui`; `core` and `ui` must stay independent.
- Agents can be assigned issues, create issues, comment, and change status
- Supports local (daemon) and cloud agent runtimes
- TanStack Query owns server state: issues, users, workspaces, inbox, agents, members, and anything fetched from the API.
- Zustand owns client state: selected workspace, filters, drafts, modals, tab layout, and navigation history.
- Shared Zustand stores live in `packages/core/`, never in `packages/views/` or app directories.
- React Context is for platform plumbing only, such as `WorkspaceIdProvider` and `NavigationProvider`.
- Only auth/workspace stores may call `api.*` directly. Other server interaction belongs in queries/mutations.
- Workspace-scoped query keys must include `wsId`.
- Mutations should be optimistic by default: patch locally, send request, roll back on failure, invalidate on settle.
- WebSocket events invalidate or patch Query cache; they never write directly to Zustand stores.
- Persist durable preferences/drafts/layout. Do not persist server data or ephemeral UI state.
- Zustand selectors must return stable references. Do not return freshly allocated objects/arrays from selectors without shallow comparison.
- Hooks that need workspace context should accept `wsId`; do not call `useWorkspaceId()` internally unless the hook is guaranteed to run under the provider.
-`server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
-`apps/web/` — Next.js frontend (App Router)
-`apps/desktop/` — Electron desktop app (electron-vite)
-`apps/mobile/` — Expo / React Native iOS app. See `apps/mobile/CLAUDE.md`.
-`packages/core/` — Headless business logic (zero react-dom)
-`packages/ui/` — Atomic UI components (zero business logic)
-`packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
What lives where for sharing purposes is documented in *Sharing Principles* below — read it once.
These are hard constraints:
### Key Architectural Decisions
-`packages/core/`: no `react-dom`, `localStorage` (use `StorageAdapter`), `process.env`, or UI libraries.
-`packages/ui/`: no `@multica/core` imports and no business logic.
-`packages/views/`: no `next/*`, no `react-router-dom`, no stores. Use `NavigationAdapter`, `useNavigation()`, and `<AppLink>`.
-`apps/web/platform/`: only place for Next.js navigation/platform APIs.
-`apps/desktop/src/renderer/src/platform/`: only place for `react-router-dom` navigation wiring.
- Every workspace under `apps/` and `packages/` must declare directly imported external packages in its own `package.json`.
- Shared dependencies use `catalog:` from `pnpm-workspace.yaml`; `apps/mobile/` pins Expo/React Native related versions directly.
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
## Sharing Rules
**Dependency direction:**`views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*`, `react-router-dom`, or app-specific code.
Web and desktop share business logic, hooks, stores, components, and views through `packages/core/`, `packages/ui/`, and `packages/views/`.
**Platform bridge:**`packages/core/platform/` provides `CoreProvider` — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider>` and provides its own `NavigationAdapter` for routing.
If the same logic exists in both web and desktop, extract it unless it depends on platform APIs:
**pnpm catalog** — `pnpm-workspace.yaml` defines `catalog:` for version pinning. All shared deps use `catalog:` references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
1. Next.js, Electron, or router APIs stay in the app/platform layer.
2. Headless logic belongs in `packages/core/`.
3. Shared UI or business views belong in `packages/views/`.
4. Shared primitives belong in `packages/ui/`.
### State Management
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so they're shared.
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
**Hard rules — these are how the architecture stays coherent:**
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
- **Workspace-scoped queries must key on `wsId`.** This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed.
- **Mutations are optimistic by default.** Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
- **Persist what's worth preserving across restarts** (user preferences, drafts, tab layout). **Don't persist ephemeral UI state** (modal open/close, transient selections) or server data.
**Common Zustand footguns to avoid:**
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
## Sharing Principles
The monorepo splits into two share zones:
- **Web and desktop** share business logic, components, hooks, stores, and views through `packages/core/`, `packages/ui/`, and `packages/views/`. Existing model — keep using it.
- **Mobile (`apps/mobile/`) is independent.** It shares only **types and pure functions** from `@multica/core/`, with `import type` for types (zero runtime coupling). UI, state, hooks, providers, i18n, React version, build pipeline, release cadence — all mobile-owned.
Mobile is locked to the React version that Expo SDK / React Native ships (which lags React main by 6-12 months). Coupling mobile to the root `catalog:` React would block mobile from upgrading on its own schedule.
See `apps/mobile/CLAUDE.md` for the mobile rules and tech-stack baseline.
Mobile is independent. It may import types and pure functions from `@multica/core`, with `import type` for types, but owns its UI, state, hooks, providers, i18n, React version, build pipeline, and release cadence.
## Commands
Use the repo scripts as the source of truth. Common commands:
```bash
# One-command dev (auto-setup + start everything)
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
# Explicit setup & run (if you prefer separate steps)
pnpm ui:add badge # Adds component to packages/ui/components/ui/
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
make sqlc # regenerate sqlc code after SQL changes
pnpm install
pnpm dev:web
pnpm dev:desktop
pnpm build
pnpm typecheck
pnpm lint
pnpm test# TS/Vitest tests through Turborepo
pnpm exec playwright test
pnpm ui:add badge # shadcn/Base UI component into packages/ui
```
### CI Requirements
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`.
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
`make dev` auto-detects worktrees and handles everything. For explicit control:
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
make start-worktree # Start using .env.worktree
```
CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.
## Coding Rules
- TypeScript strict mode is enabled; keep types explicit.
- Go code follows standard Go conventions (gofmt, go vet).
-Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Go follows standard conventions: `gofmt`, `go vet`, checked errors.
-Code comments must be English.
- Prefer existing patterns/components over new parallel abstractions.
- Avoid broad refactors unless required by the task.
-New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
-The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
-When you change a CLI command or flag, an API request/response field, or product behavior that a built-in skill documents (`server/internal/service/builtin_skills/*`), update that skill's `SKILL.md`**and** its `references/*-source-map.md`in the same PR. The built-in skills are source-traced contracts shipped to agents — if the code moves and the skill doesn't, it silently teaches stale behavior.
-For internal, non-boundary code, do not add compatibility layers, fallback paths, dual writes, legacy adapters, or temporary shims unless explicitly requested.
-API boundaries are different: installed desktop clients can talk to newer backends, so response parsing must follow the API compatibility rules below.
-If a flow or API is being replaced and the product is not live, prefer removing the old path instead of preserving both.
- New global pre-workspace routes must be a single word (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Do not add hyphenated root routes like `/new-workspace`.
- Reserved slugs live in `server/internal/handler/reserved_slugs.json`. Edit it, run `pnpm generate:reserved-slugs`, and commit the generated `packages/core/paths/reserved-slugs.ts`.
- When changing CLI commands/flags, API fields, or product behavior documented by built-in skills under `server/internal/service/builtin_skills/*`, update the relevant `SKILL.md` and `references/*-source-map.md` in the same PR.
### API Response Compatibility
## API Compatibility
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
Frontend code must survive backend response drift, especially in installed desktop builds.
When writing code that consumes an API response, follow these rules:
- Parse API JSON with `parseWithFallback` in `packages/core/api/schema.ts` and a zod schema. Do not cast network JSON to `T`.
- Endpoint responses consumed by UI logic must pass through a schema before returning.
- Downstream UI should optional-chain and default fields defensively.
- Prefer explicit boolean checks (`=== true`) over truthy/falsy checks on server fields.
- Do not pin critical affordances to one backend boolean; combine signals when possible.
- Server-driven enum switches need a `default` branch.
- When adding or changing an endpoint, add/update the schema and include a malformed-response test.
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
## Backend UUID Rules
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
In `server/internal/handler/`, always know where a UUID came from before using it in write queries.
### Backend Handler UUID Parsing Convention
- Resource path params that may be UUIDs or human-readable IDs must be resolved through loaders such as `loadIssueForUser`, `loadSkillForUser`, `loadAgentForUser`, or `requireDaemonRuntimeAccess`; subsequent writes use the resolved `entity.ID`.
- Pure UUID inputs from request boundaries use `parseUUIDOrBadRequest(w, s, fieldName)` and return immediately on `ok=false`.
- Trusted UUID round-trips from sqlc results or test fixtures use `parseUUID(s)`, which panics on invalid input.
- Outside handlers, `util.ParseUUID(s) (pgtype.UUID, error)` is the safe variant; always check the error.
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
## Web/Desktop Features
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
When adding a shared page or feature for web and desktop:
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
1. Put the page/component in `packages/views/<domain>/`.
2. Add platform wiring in both `apps/web/app/` and the desktop router, unless the desktop flow is a transition overlay.
3. Use `useNavigation().push()` or `<AppLink>` in shared code.
4. Use shared guards/providers such as `DashboardGuard` from `packages/views/layout/`.
5. Keep platform-only UI in the app or inject it through props/slots.
6. Hooks that need workspace context should accept `wsId`.
### Dependency Declaration Rule
CSS for web/desktop is shared from `packages/ui/styles/`. Use semantic tokens such as `bg-background` and `text-muted-foreground`; avoid hardcoded Tailwind colors and duplicated base styles.
Every workspace (`apps/` and `packages/` directories) must explicitly declare all directly imported external packages in its own `package.json`. Relying on pnpm hoist to resolve undeclared imports (phantom deps) is prohibited — it causes production build failures when pnpm creates peer-dep variants.
## Desktop Rules
- Use `"pkg": "catalog:"` to reference the shared version from `pnpm-workspace.yaml`.
- CI enforces this via `eslint-plugin-import-x/no-extraneous-dependencies`.
- Exception: `apps/mobile/` uses pinned versions (not `catalog:`) for packages tied to its own React/Expo version.
Desktop routing has three categories:
### Package Boundary Rules
- Session routes: workspace-scoped tab destinations such as `/:slug/issues`.
- Transition flows: pre-workspace one-shot actions such as create workspace or accept invite. These are `WindowOverlay` state, not routes.
- Error/stale states: stale workspace tabs should auto-heal by dropping stale tab groups, not render desktop error pages.
These are hard constraints. Violating them breaks the cross-platform architecture:
More desktop constraints:
-`packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **Shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
-`packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
-`packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
-`apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
-`apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
-New pre-workspace desktop flows register a `WindowOverlay` type in `stores/window-overlay-store.ts`; do not add them to `routes.tsx`.
-`setCurrentWorkspace(slug, uuid)` from`@multica/core/platform` is the active workspace source of truth.
-Code that leaves workspace context must call `setCurrentWorkspace(null, null)` explicitly.
-Leave/delete workspace flow order: read cached destination, clear current workspace, navigate, then run the mutation.
-Cross-workspace navigation must go through the navigation adapter so it can call `switchWorkspace(slug, targetPath)`.
- Full-window desktop views outside the dashboard shell must mount `<DragStrip />` from `@multica/views/platform` as the first flex child. Interactive controls in the top 48px need `WebkitAppRegion: "no-drag"`.
### The No-Duplication Rule (web + desktop)
## Mobile Rules
**If the same logic exists in both web and desktop, it must be extracted to a shared package.**
Read `apps/mobile/CLAUDE.md` before touching `apps/mobile/`. It contains the mandatory pre-flight process, import limits, parity rules, tech stack, UI rules, data helpers, realtime strategy, and mobile release flow.
This applies to everything between web and desktop: components, hooks, guards, providers, utility functions. The decision process:
Root-level reminders:
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
3. Everything else → belongs in `packages/core/` (headless logic) or `packages/views/` (UI components).
- Mobile shares only `@multica/core` types and pure functions.
- Mobile must match web/desktop product semantics: counts, permissions, enums/transitions, and data identity.
- Mobile may differ in UI/interaction when the phone context requires it.
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
## UI Rules
### Cross-Platform Development Rules (web + desktop)
- Prefer shadcn/Base UI components over custom implementations. Add them with `pnpm ui:add <component>` from the repo root.
- Use design tokens and semantic classes; avoid hardcoded colors.
- Do not introduce extra local state unless the design requires it.
- Handle overflow, long text, scrolling, alignment, and spacing deliberately.
- If a component is identical between web and desktop, it belongs in a shared package.
When adding a new page or feature for web/desktop:
## Testing
1.**New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2.**Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
3.**Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4.**Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5.**Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
6.**New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
Tests follow the code:
### CSS Architecture (web + desktop)
| What is tested | Location |
| --- | --- |
| Shared business logic, stores, queries, hooks | `packages/core/*.test.ts` |
| Platform wiring such as cookies, redirects, search params | `apps/web/*.test.tsx` or `apps/desktop/` |
| End-to-end flows | `e2e/*.spec.ts` |
| Backend | `server/` Go tests |
Web and desktop share the same CSS foundation from `packages/ui/styles/`.
Rules:
-**Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
-**Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
-**`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Mobile-specific Rules
Rules for `apps/mobile/` live in `apps/mobile/CLAUDE.md`. Read it before touching anything in `apps/mobile/` — it covers what may be imported from `@multica/core/`, the React version policy, the build/release pipeline, and the locked tech-stack baseline.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
### Route categories
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.**`WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace context
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
1. Read destination from cached workspace list.
2.`setCurrentWorkspace(null, null)`.
3.`navigation.push(destination)`.
4. THEN `await mutation.mutateAsync(workspaceId)`.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS)
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
- Use shadcn design tokens for styling. Avoid hardcoded color values.
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
## Testing Rules
### Where to write tests
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
| What you're testing | Where the test lives | Why |
|---|---|---|
| Shared business logic (stores, queries, hooks) | `packages/core/*.test.ts` | No DOM needed, pure logic |
| End-to-end user flows | `e2e/*.spec.ts` | Real browser, real backend |
**Never test shared component behavior in an app's test file.** If a test requires mocking `next/navigation` or `react-router-dom` to test a component from `@multica/views`, the test is in the wrong place — move it to `packages/views/` and mock `@multica/core` instead.
### Test infrastructure
-`packages/core/` — Vitest, Node environment (no DOM)
All test deps are in the pnpm catalog for unified versioning.
### Mocking conventions
- Mock `@multica/core` stores with `vi.hoisted()` + `Object.assign(selectorFn, { getState })` pattern (Zustand stores are both callable and have `.getState()`).
-Never test shared component behavior in an app test file.
-`packages/views/` tests must not mock `next/*` or `react-router-dom`.
-Mock `@multica/core` stores with the Zustand callable-store shape (`selectorFn` plus `getState`).
- Mock `@multica/core/api` for API calls.
-In `packages/views/` tests: never mock `next/*` or `react-router-dom` — those don't exist here.
-In `apps/web/` tests: mock framework-specific APIs only for platform-specific behavior.
-E2E tests should use `TestApiClient`for setup/teardown.
-Prefer writing the failing test in the correct package before implementation when the change is behavioral.
### TDD workflow
## Verification
1. Write failing test in the **correct package** first.
2. Write implementation.
3. Run `pnpm test` (Turborepo discovers all packages).
4. Green → done.
For code changes, run the narrowest useful checks while iterating, then run broader verification when risk justifies it or when asked.
### Go tests
Standard `go test`. Tests should create their own fixture data in a test database.
### E2E tests
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
After writing or modifying code, always run the full verification pipeline:
Useful checks:
```bash
pnpm typecheck
pnpm test
make test
pnpm exec playwright test
make check
```
**Workflow:**
- Write code to satisfy the requirement
- Run `make check`
- If any step fails, read the error output, fix the code, and re-run
- Repeat until all checks pass
- Only then consider the task complete
Do not claim verification passed unless you ran it. If you skip checks because the change is docs-only or the user asked not to run them, say so.
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
## Commits and Releases
## CLI Release
- Commits should be atomic and use conventional prefixes: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
- A production deployment requires a CLI release tag on `main`: create `v0.x.x`, push it, and let `release.yml` publish binaries and the Homebrew tap.
- Bump patch by default unless the user specifies a version.
**Prerequisite:** A CLI release must accompany every Production deployment.
## Domain Reminders
1. Create a tag on the `main` branch: `git tag v0.x.x`
2. Push the tag: `git push origin v0.x.x`
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
By default, bump the patch version each release (e.g. `v0.1.12` → `v0.1.13`), unless the user specifies a specific version.
## Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
## Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
- All queries filter by `workspace_id`; membership gates access; `X-Workspace-ID` selects the workspace.
- Issue assignees are polymorphic: `assignee_type` plus`assignee_id` can reference a member or an agent.
`multica skill import --url <url>`의 기본값은 `--on-conflict fail`입니다. 같은 이름의 스킬이 이미 있으면 명령은 구조화된 `conflict` 결과로 종료되며 워크스페이스를 변경하지 않습니다.
기존 스킬을 만든 사용자이고, 스킬 ID와 에이전트 연결은 유지한 채 내용을 바꾸려면 `--on-conflict overwrite`를 사용하세요. 기존 스킬을 그대로 두고 복사본을 가져오려면 `--on-conflict rename`을 사용하면 `-2` 같은 접미사가 자동으로 붙습니다. 같은 이름의 항목을 그냥 건너뛰려면 `--on-conflict skip`을 사용하세요.
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
## 이슈 참조하기
다른 이슈를 링크하려면 `MUL-123`처럼 이슈 키를 입력하세요. Multica는 댓글에서 실제 존재하는 이슈 키를 해석하여 내부적으로 `mention://issue/<uuid>` 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
다른 이슈를 링크하려면 댓글 mention 선택기에서 해당 이슈를 선택하세요. Multica는 이슈 링크를 명시적인 `[MUL-123](mention://issue/<uuid>)` mention 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
보통은 `[MUL-123](mention://issue/<uuid>)`을 직접 손으로 작성할 필요가 없습니다. 그 형식은 Multica가 키를 해석한 뒤에 사용하는 표준 내부 표현입니다.
`MUL-123` 같은 bare 이슈 키를 입력하면 일반 텍스트로 유지됩니다. 따라서 `feature/MUL-123` 같은 댓글 안의 브랜치 이름과 경로도 다시 작성되지 않습니다.
<Callout type="info">
Markdown 강조는 CommonMark 규칙을 따릅니다. 굵은 텍스트가 문장 부호나 닫는 따옴표로 끝나고 그 뒤에 한국어 조사가 바로 이어지면, 닫는 `**`가 인식되지 않을 수 있습니다.
@@ -39,9 +39,9 @@ Mentioning the same person multiple times in one comment still produces **only o
## Referencing issues
To link another issue, type its issue key, such as `MUL-123`. Multica resolves real issue keys in comments and stores them as an internal `mention://issue/<uuid>` link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
To link another issue, choose it from the comment mention picker. Multica stores issue links as an explicit `[MUL-123](mention://issue/<uuid>)` mention link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
You normally do not need to write `[MUL-123](mention://issue/<uuid>)` by hand. That format is the canonical internal representation after Multica has resolved the key.
Typing a bare issue key, such as `MUL-123`, keeps it as plain text. This also keeps branch names and paths, such as `feature/MUL-123`, from being rewritten inside comments.
<Callout type="info">
Markdown emphasis follows CommonMark rules. When bold text ends with punctuation or a closing quote and is immediately followed by a Korean particle, the closing `**` may not be recognized.
더 세분화된 승인 게이트를 갖춘 JSON-RPC 2.0 전송 방식입니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개 코드는 존재하지만 현재 도달할 수 없습니다** — 재개가 필요하다면 Claude Code 또는 ACP 계열 중 하나를 선택하세요.
더 세분화된 승인 게이트를 갖춘 JSON-RPC 2.0 전송 방식입니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개하며, 오래되었거나 없는 thread는 새 thread로 폴백합니다.
| | |
|---|---|
@@ -58,7 +58,7 @@ multica daemon restart
### Cursor (Anysphere)
Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 작동하지 않습니다** — Cursor의 CLI가 세션 id를 반환하지 않으므로 재개 시 전달하는 값은 항상 유효하지 않습니다.
Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent는 stream-json 이벤트에서 `session_id`를 반환하고, Multica는 다음 실행 때 이를 `--resume <id>`로 전달합니다.
@@ -48,7 +48,7 @@ The most complete integration. Session resumption works, MCP works, and it consu
### Codex (OpenAI)
JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written into the per-task `$CODEX_HOME/config.toml`. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written into the per-task `$CODEX_HOME/config.toml`. **Session resumption works** through Codex app-server `thread/resume`; stale or missing threads fall back to a fresh thread.
| | |
|---|---|
@@ -58,7 +58,7 @@ JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written
### Cursor (Anysphere)
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a sessionid, so the value you pass on resume is always invalid.
The CLI counterpart to the Cursor editor. **Session resumption works** with current Cursor Agent releases: Multica reads `session_id` from the stream-json events and passes it back with `--resume <id>`.
@@ -54,23 +54,23 @@ Google が提供し、Gemini 2.5 および 3 シリーズをサポートしま
### Hermes
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作します。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**(`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作し、MCP 構成は ACP `mcpServers` として渡されます。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**(`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
@@ -38,7 +38,7 @@ Anthropic에서 제공합니다. **신규 사용자에게 첫 번째 선택지**
### Codex
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개 코드는 존재하지만 현재 도달할 수 없습니다** — 재개가 필요하다면 Claude Code나 ACP 계열 중 하나를 선택하세요.
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개합니다. 저장된 thread가 없거나 오래된 경우에는 새 thread로 폴백해 작업을 계속 실행합니다.
### Copilot
@@ -46,7 +46,7 @@ GitHub에서 제공합니다. 모델 라우팅은 GitHub 계정 권한을 거칩
### Cursor
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개 코드는 존재하지만 실제로는 동작하지 않습니다** — Cursor CLI 이벤트 스트림이 세션 ID를 반환하지 않으므로, 전달하는 재개 값은 항상 무효입니다. 재개가 필요하다면 다른 것을 선택하세요.
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent의 stream-json 이벤트에는 `session_id`가 포함되며, Multica는 다음 실행 때 이를 `--resume <id>`로 다시 전달합니다. MCP 구성은 작업 워크스페이스의 `.cursor/mcp.json`에 기록되고, Cursor의 프로젝트 approval 파일은 작업별 `CURSOR_DATA_DIR` 아래에 기록되므로, 관리되는 MCP 서버는 사용자의 전역 Cursor approval에 의존하지 않습니다.
| ✅ 실제로 동작 | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
| ⚠️ 코드는 존재하지만 도달 불가 | Codex, Cursor | 코드에 재개 경로가 있지만 실제로는 도달하지 않습니다(Codex는 조용히 폴백하고, Cursor는 세션 id를 반환하지 않습니다) — **미지원으로 간주하세요** |
| ✅ 실제로 동작 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
| ❌ 없음 | Gemini | CLI에 재개 메커니즘이 없습니다 |
**의사결정을 위해**: 워크플로에서 에이전트가 작업 간에 컨텍스트를 유지해야 한다면(실패 재시도, 수동 재실행, 대화형 반복), ✅ 행에 있는 도구만 선택하세요.
## MCP 구성: 도구별 지원
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 7개입니다: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 5개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 8개입니다: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 4개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Hermes/Kimi/Kiro CLI는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Cursor는 `.cursor/mcp.json`과 작업별 `CURSOR_DATA_DIR` 아래의 프로젝트 approval을 기록합니다. Hermes/Kimi/Kiro CLI는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
<Callout type="warning">
에이전트 구성에서 `mcp_config`를 설정했더라도 MCP 열에 ✅가 없는 도구를 선택하면, MCP 서버가 해당 에이전트에 **아무런 효과**도 미치지 않습니다. MCP 연동은 도구별로 구현됩니다.
@@ -38,7 +38,7 @@ From Anthropic. **First choice for new users** — the most complete feature set
### Codex
From OpenAI. Uses JSON-RPC 2.0, has stronger statefulness, and a finer-grained approve mechanism (manual approval for `exec_command` and `patch_apply`). MCP config is materialized into the per-task `$CODEX_HOME/config.toml`. **Session resumption code exists but is currently unreachable** — if you need resume, pick Claude Code or one of the ACP family.
From OpenAI. Uses JSON-RPC 2.0, has stronger statefulness, and a finer-grained approve mechanism (manual approval for `exec_command` and `patch_apply`). MCP config is materialized into the per-task `$CODEX_HOME/config.toml`. **Session resumption works** through Codex app-server `thread/resume`; if the saved thread is missing or stale, Multica falls back to a fresh thread so the task can still run.
### Copilot
@@ -46,7 +46,7 @@ From GitHub. Model routing goes through your GitHub account entitlement — the
### Cursor
From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption code exists but doesn't actually work** — the Cursor CLI event stream doesn't return a session ID, so any resume value you pass is always invalid. If you need resume, pick something else.
From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption works** with current Cursor Agent releases: the stream-json event includes a `session_id`, and Multica passes it back with `--resume <id>` on the next run. MCP config is materialized into the task workspace's `.cursor/mcp.json`, with Cursor's project approval file written under a per-task `CURSOR_DATA_DIR` so managed MCP servers do not depend on the user's global Cursor approvals.
### Gemini
@@ -82,17 +82,16 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
| Status | Tools | Meaning |
|---|---|---|
| ✅ Really works | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
| ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** |
| ✅ Really works | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
| ❌ None | Gemini | The CLI has no resume mechanism |
**For your decision**: if your workflow needs agents to preserve context across tasks (failure retries, manual reruns, conversational iteration), pick only from the ✅ row.
## MCP configuration: provider-specific support
**Of the 12 tools, seven consume `mcp_config`: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other five accept the field but **ignore it** — no error, no warning, the config just has no effect.
**Of the 12 tools, eight consume `mcp_config`: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other four accept the field but **ignore it** — no error, no warning, the config just has no effect.
The runtime paths are provider-specific: Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Hermes, Kimi, and Kiro CLI receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
The runtime paths are provider-specific: Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, and Kiro CLI receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
<Callout type="warning">
If you set `mcp_config` in an agent configuration but pick a tool not marked ✅ in the MCP column, your MCP servers have **no effect** on that agent. MCP integration is provider-specific.
플래그 대신 환경 변수를 선호한다면, 해당 플래그를 생략할 때 `setup self-host`가 `MULTICA_SERVER_URL`과 `MULTICA_APP_URL`을 읽습니다(둘 다 설정하면 플래그가 우선합니다). `MULTICA_SERVER_URL`은 [환경 변수](/environment-variables)에 나오는 `ws://…/ws` 데몬 형식도 허용하며 HTTP 기본 URL로 정규화합니다.
</Callout>
단일 호스트네임에서 프런트엔드와 백엔드를 모두 앞단에 두는(데몬과 웹 앱 모두에 필요한 WebSocket 지원 포함) 최소 Caddyfile은 다음과 같습니다.
Prefer environment variables over flags? `setup self-host` reads `MULTICA_SERVER_URL` and `MULTICA_APP_URL` when the matching flag is omitted — a flag still takes precedence over the env var. `MULTICA_SERVER_URL` also accepts the `ws://…/ws` daemon form from [Environment variables](/environment-variables) and normalizes it to the HTTP base.
</Callout>
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`)。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。デーモンは**2 つのルートを優先順位順に**確認します。まずランタイム自身のスキルディレクトリ(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`)、次にツール横断の汎用ディレクトリ `~/.agents/skills/`(Codex や Gemini CLI などのエコシステムが共有する場所)です。同じスキル名が両方に存在する場合は**プロバイダー専用ディレクトリが優先**されるため、汎用ルートは*追加の*スキルを表示するだけで、既存スキルの解決結果を変えることはありません。
@@ -12,7 +12,7 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica는 두 가지 스킬 소스를 지원합니다.
- **워크스페이스 스킬** — Multica 클라우드에 저장됩니다. 에이전트에 연결되면 작업 실행 시점에 여러분의 데몬으로 동기화됩니다. 이것이 **팀 전체에서 스킬을 공유하는 표준 방식**입니다.
- **로컬 스킬** — 여러분의 기기에 있는 디렉터리에 존재합니다(각 AI 코딩 도구마다 관례적인 기본 경로가 있습니다. 예: Claude Code의 `~/.claude/skills/`). 여러분이 요청하면 [데몬](/daemon-runtimes)이 기기를 스캔하고, 어떤 스킬을 워크스페이스로 가져올지 직접 고릅니다.
- **로컬 스킬** — 여러분의 기기에 있는 디렉터리에 존재합니다. 여러분이 요청하면 [데몬](/daemon-runtimes)이 기기를 스캔하고, 어떤 스킬을 워크스페이스로 가져올지 직접 고릅니다. 데몬은 **두 개의 루트를 우선순위 순서로** 확인합니다. 먼저 런타임 자체의 스킬 디렉터리(각 AI 코딩 도구마다 관례적인 기본 경로가 있습니다. 예: Claude Code의 `~/.claude/skills/`), 그다음 도구 간 공용 디렉터리 `~/.agents/skills/`(Codex, Gemini CLI 등 생태계가 공유하는 위치)입니다. 동일한 스킬 이름이 양쪽에 모두 있으면 **프로바이더 전용 디렉터리가 우선**하므로, 공용 루트는 *추가* 스킬만 노출할 뿐 기존 스킬의 해석 결과를 절대 바꾸지 않습니다.
대부분의 경우 **워크스페이스 스킬**을 원하게 됩니다. 한 번만 가져오면 모든 팀원의 에이전트가 사용할 수 있기 때문입니다. 로컬 스킬은 먼저 로컬에서 테스트하고 싶거나, 콘텐츠에 민감한 로컬 자료가 포함된 경우에 적합합니다.
@@ -54,7 +54,7 @@ GitHub나 ClawHub에서 가져온 스킬에는 스크립트와 실행 가능한
- **스킬** = 구조화된 **지식 팩**(정적 콘텐츠 + 지침). 에이전트는 스킬을 읽어 "문제 X를 만나면 이렇게 생각하고 이렇게 처리하라"를 학습합니다.
- **MCP**(Model Context Protocol) = **도구 채널**. 에이전트는 MCP를 사용해 외부 서비스(데이터베이스, 파일 시스템, 서드파티 API)에 연결하고 이를 **호출**합니다.
이 둘은 상호 보완적입니다. 현재 Multica에서 MCP 지원은 **도구별로 구현됩니다**: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw는 `mcp_config`를 사용하고, 다른 도구들은 이 필드를 받더라도 실제로 사용하지 않습니다. MCP 전용 섹션은 추후 릴리스에서 추가될 예정입니다.
이 둘은 상호 보완적입니다. 현재 Multica에서 MCP 지원은 **도구별로 구현됩니다**: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw는 `mcp_config`를 사용하고, 다른 도구들은 이 필드를 받더라도 실제로 사용하지 않습니다. MCP 전용 섹션은 추후 릴리스에서 추가될 예정입니다.
@@ -12,7 +12,7 @@ A Skill is a **knowledge pack** for an [agent](/agents) — a `SKILL.md` plus op
Multica supports two skill sources:
- **Workspace skill** — stored in Multica's cloud. Once attached to an agent, it's synced down to your daemon at task execution time. This is the **standard way to share skills across a team**.
- **Local skill** — lives in a directory on your machine (each AI coding tool has a conventional default path, e.g. Claude Code's `~/.claude/skills/`). On your request, the [daemon](/daemon-runtimes) scans your machine, and you manually pick which ones to bring into the workspace.
- **Local skill** — lives in a directory on your machine. On your request, the [daemon](/daemon-runtimes) scans your machine, and you manually pick which ones to bring into the workspace. The daemon checks **two roots, in priority order**: first the runtime's own skill directory (each AI coding tool has a conventional default path, e.g. Claude Code's `~/.claude/skills/`), then the cross-tool universal directory `~/.agents/skills/` — a shared location used by ecosystems like Codex and Gemini CLI. When the same skill name exists in both, the **provider-specific directory wins**, so the universal root only ever surfaces *additional* skills and never changes what an existing skill resolves to.
Most of the time you want **workspace skills**: import once, every teammate's agent can use it. Local skills are a fit when you want to test locally first, or when the content involves sensitive local material.
@@ -54,7 +54,7 @@ Both augment what an agent can do, but in different directions:
- **Skill** = a structured **knowledge pack** (static content + instructions). The agent reads a skill to learn "when I see problem X, here's how to think and what to do."
- **MCP** (Model Context Protocol) = a **tool channel**. The agent uses MCP to connect to external services (databases, filesystems, third-party APIs) and **invoke** them.
The two are complementary. In Multica today, MCP support is **provider-specific**: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw consume `mcp_config`; other tools receive the field but don't actually use it. A dedicated MCP section will come in a later release.
The two are complementary. In Multica today, MCP support is **provider-specific**: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw consume `mcp_config`; other tools receive the field but don't actually use it. A dedicated MCP section will come in a later release.
@@ -292,6 +292,190 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes:"Bug Fixes",
},
entries:[
{
version:"0.3.27",
date:"2026-06-19",
title:"Smoother Desktop Sidebars and Cleaner Pins",
changes:[],
improvements:[
"Desktop sidebars now open and close more smoothly while keeping navigation controls easy to reach",
"Pinned Issues in the sidebar are easier to scan because labels focus on the Issue title",
"Contributor guidance is shorter and clearer, making agent work easier to review before release",
],
fixes:[
"Pinned Issues and projects now show a single active highlight instead of lighting up their parent navigation item too",
"Issue and project detail sidebars no longer animate unexpectedly when entering a page or restoring a saved layout",
],
},
{
version:"0.3.25",
date:"2026-06-18",
title:"More Reliable Agent Work Across Skills, Autopilots, and Chat",
changes:[],
features:[
"Local skill libraries on a developer machine can now be picked up automatically for agent runs",
"Autopilots can include default subscribers so the right teammates are included when new Issues are created",
"Chat attachments now stay tied to the current workspace and messages can continue sending without blocking the conversation",
"Failed agent comments can be retried directly from the Issue timeline",
],
improvements:[
"Usage reporting is more accurate when the same model name is available from different providers",
"Older Codex usage records can be filled in for more complete usage history",
"Runtime storage reporting is more complete across multiple workspace locations",
"Background task guidance and release checks are stricter, helping catch risky changes earlier",
],
fixes:[
"Issue mention chips in chat and comments now fit their container and no longer overlap nearby text",
"Workspace links now use the correct deployment host more reliably",
"Autopilot run folders are cleaned up after terminal runs finish",
"Desktop builds now handle commit-based version names correctly",
"Tencent CodeBuddy shows the correct provider logo",
"Daemon claim responses are smaller and faster to transfer",
],
},
{
version:"0.3.24",
date:"2026-06-17",
title:"Custom Runtimes",
changes:[],
features:[
"Teams can create custom runtimes so agents use the right local tools and models",
"CLI agent create and update now supports thinking level",
],
improvements:[
"Runtime profiles sync faster and prefer the best match for the current environment",
"Client error and freeze reports now group duplicates",
"Issue trigger previews are easier to read",
],
fixes:[
"Office 365 email delivery is more reliable",
"GitHub installation context and pending CI display are more reliable",
"Codex runs fail quickly when the app server exits",
"Self-healing runtimes can be deleted again, and incompatible models are cleared on runtime switch",
"Unknown Issue icons and plain filenames are handled safely",
],
},
{
version:"0.3.23",
date:"2026-06-16",
title:"Issue Date Filters and More Stable Agent Runs",
changes:[],
features:[
"Issues can now be filtered by created or updated date, with quick ranges and custom date selections",
"Command line users can now delete runtimes with safer defaults and an explicit option for related data",
"Lark connections can now use network proxies, helping teams in restricted network environments connect reliably",
],
improvements:[
"Web and desktop failures are now easier to investigate with clearer reports for errors, freezes, and crashes",
"Project rows, comment previews, and comment composers are more consistent and easier to use",
],
fixes:[
"Reply and edit previews now show the right agents or squads before a comment is saved",
"Plain Issue IDs in comments now stay as text unless they are intentionally linked",
"Google sign-in from command line login now returns to the command line correctly after browser authentication",
"Chat file uploads wait until an active agent is ready, avoiding failed uploads during loading",
"Transcript actions remain visible on touch devices where hover is unavailable",
"Agent instructions for posting comments now avoid shell formatting problems that could drop assignees, projects, or other fields",
],
},
{
version:"0.3.22",
date:"2026-06-15",
title:"Faster Lists, Easier Runtime Setup, and Safer Issue Editing",
changes:[],
features:[
"Agents, autopilots, projects, runtimes, skills, and squads now use a faster, more consistent list experience with clearer rows, filters, selections, and actions",
"The command line can now manage workspace repositories, so local agents can pick up project repo context more easily",
"Cursor and OpenClaw are easier to set up: Cursor connection settings can be managed for you, and OpenClaw can connect through an existing gateway",
"When editing a comment, you can preview and control which agents or squads will run before saving",
],
improvements:[
"Desktop recovery prompts now include more page context, making stuck-window reports easier to understand",
"Long Issues and inbox views now keep their scroll position and comment anchors more reliably when you navigate away and return",
"Cursor usage and billing details are clearer for Composer, cached inputs, and newer Cursor agent output",
],
fixes:[
"Issue attachments, inline images, and file cards are more reliable across web, desktop, mobile, and shared token links",
"The editor and read-only Issue content now handle dollar amounts and email links more predictably",
"Desktop Cmd+W now closes the active tab first, then the window when no tab can be closed",
"Self-hosted Docker Compose uploads and default settings fail less often, with missing values caught earlier",
"Agent tasks now stop safely when their run credentials are invalid",
],
},
{
version:"0.3.21",
date:"2026-06-12",
title:"CodeBuddy Runtime",
changes:[],
features:[
"CodeBuddy can now run local Multica agents, with its available model and effort choices shown automatically",
"Quick-created Issues now keep uploaded files attached from the first draft through the final Issue",
],
improvements:[
"Skill import conflicts are clearer: locked skills show a person's name instead of an internal ID, and a single overwrite now completes in one click",
"Desktop recovery prompts now explain what happened first and give clearer details to include when reporting a stuck window",
"Views that sort or filter people by signup time can now load faster",
],
fixes:[
"Chat now keeps messages and drafts in sync when sending, stopping, or recovering from a failed send",
"Lark account binding now works reliably for users who are already signed in, and sign-in returns to the binding page",
"Local agent runs no longer announce that work has started before the task folder is ready",
],
},
{
version:"0.3.20",
date:"2026-06-11",
title:"Skill Imports, Cleaner Run History, and Resilient Agents",
changes:[],
features:[
"Skill imports now let you choose what happens when a skill already exists: stop, replace it, save a renamed copy, or skip it",
"Import results now clearly show which skills were added, updated, skipped, blocked by a conflict, or could not be imported",
],
improvements:[
"Execution logs now show the newest past runs first on web and mobile, so recent progress is easier to scan",
"Changelog content was cleaned up so the latest release notes stay grouped under the right release",
],
fixes:[
"Issue thread replies now stay in the order they arrived, even when a slower agent reply lands later",
"Agents can recover when a saved session disappears, starting fresh instead of failing again on every mention",
"Reviving an Issue from a new workspace folder now starts a fresh session instead of retrying one that only existed in the old folder",
],
},
{
version:"0.3.19",
date:"2026-06-10",
title:"Safer Comment Triggers, Reliable Agents, and Attachments",
changes:[],
features:[
"Comment boxes now show which agents or squads will start work before you send, with controls to avoid accidental runs",
"Run transcripts now include timestamps, making agent progress and handoffs easier to review",
"Autopilot detail pages now show who created each autopilot",
"Claude Fable 5 is now available in Multica's supported model and pricing list",
"Issue conversations can now resolve a specific reply, making long threads easier to close while keeping the final answer visible",
"Lark and Feishu conversations now show a typing reaction while Multica is preparing a reply, then clear it before the answer is sent",
"Agent runs now know who started each task, making handoffs, audit trails, and privacy-aware behavior more accurate",
"OpenClaw users can point Multica at a custom app location and data folder from their local configuration",
],
improvements:[
"Comment trigger indicators are quieter, clearer, and less likely to crowd long agent names",
"Desktop now disables daemon start and stop controls when the daemon is managed outside Multica, such as in WSL2",
"The active agent indicator in an Issue header is easier to read, with motion only while work is running and clearer queued wording otherwise",
"The CLI now gives clearer guidance around common errors, sign-in problems, and project setup values",
],
fixes:[
"Inline images and files in Issue descriptions now stay visible across web and desktop after reloads",
"Each Issue discussion thread now keeps only one resolved answer at a time, so replacing the conclusion is consistent for everyone",
"Issue pages refresh their data after realtime reconnects, avoiding stale timelines after a connection drop",
"Agent task initiator history now works more reliably for older task records",
"Sticky Issue comments keep a cleaner visual edge while scrolling",
"Newly posted attachments now use stable private download links, so images and files stay visible after temporary upload links expire",
"Autopilot runs started from newly created Issues now fail cleanly when the assigned task cannot complete, instead of staying stuck",
"Inbox deep links now scroll inside the Issue timeline without pushing the desktop window out of place",
"Cursor and Codex sessions now end more cleanly after terminal results, preserving completion state and final telemetry",
"Self-host setup now respects configured server URLs, and project creation returns clear validation errors instead of a generic failure",
"A previous upload hardening change was rolled back after it conflicted with attachment behavior",
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.