Compare commits

..

157 Commits

Author SHA1 Message Date
Naiyuan Qing
b48f4d0f97 refactor(web): unify react-grab opt-in var to VITE_REACT_GRAB
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>
2026-06-22 09:19:08 +08:00
Naiyuan Qing
dee81503cb feat(desktop): add opt-in react-grab dev element inspector
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>
2026-06-22 09:11:00 +08:00
Naiyuan Qing
ec15501adf feat(web): add opt-in react-grab dev element inspector
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>
2026-06-22 08:55:02 +08:00
YOMXXX
737c976b0d fix(cli): guide remote setup callbacks (#4360)
## 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 ./...`
2026-06-22 00:06:14 +08:00
beast
31d942d010 MUL-3438: fix(projects): require admin for project deletion (#4327)
* 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>
2026-06-21 23:54:58 +08:00
Hzzzzzx
745832b536 MUL-3433: fix(daemon): restore claim slow-log payload observability without gzip
Squash merge PR #4322 after review approval.\n\nMUL-3433
2026-06-21 23:41:05 +08:00
Multica Eve
140a113c38 docs: add 2026-06-19 changelog entry (#4340)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-21 23:28:15 +08:00
YOMXXX
4bbaf5363c fix(codex): handle app-server permission requests (#4346) 2026-06-20 13:42:27 +08:00
Jiayuan Zhang
c0c41fa0b4 fix(views): gate right sidebar motion to toggles (#4335) 2026-06-19 16:40:49 +08:00
Jiayuan Zhang
06ae9b2a0c fix sidebar issue pin labels (#4334) 2026-06-19 16:23:10 +08:00
Jiayuan Zhang
49e1c2a8af docs: simplify agent guidance (#4333) 2026-06-19 16:22:16 +08:00
Jiayuan Zhang
27fcbb015f Polish desktop sidebar motion
Polish desktop chrome/sidebar alignment and add motion-based transitions for left and right sidebars.
2026-06-19 06:26:14 +02:00
Jiayuan Zhang
ba7be23f7c fix pinned sidebar active state
Fix sidebar active state so exact pinned routes do not also highlight their parent Workspace nav item.
2026-06-19 06:25:58 +02:00
LinYushen
e1c6754304 Revert "fix: gzip daemon claim responses (#4259)" (#4307)
This reverts commit 2f398c36ad.
2026-06-18 17:58:43 +08:00
Multica Eve
990e7a6d74 MUL-3421: add 2026-06-18 changelog entry (#4305)
* docs: add 2026-06-18 changelog entry

Co-authored-by: multica-agent <github@multica.ai>

* docs: clarify 0.3.25 changelog title

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-06-18 17:27:14 +08:00
Naiyuan Qing
9eaea31892 fix(realtime): guard WS frames without a string type + drop benign ResizeObserver noise (MUL-3418) (#4304)
* 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>
2026-06-18 17:24:48 +08:00
Multica Eve
8b3b054d17 test(agent): cover Hermes custom model IDs with colons (#4300)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 15:51:13 +08:00
Hzzzzzx
b4c9e4423c test: enable -race detector in Go test pipeline (WOR-61) (#4274)
* 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>
2026-06-18 15:50:24 +08:00
LinYushen
0d0edac32f feat(daemon): discover local skills from ~/.agents/skills (MUL-3333) (#4244)
* 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>
2026-06-18 15:24:41 +08:00
Naiyuan Qing
e4b53eb5c0 fix(editor): stop chat @mention groups from overlapping (#4297)
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>
2026-06-18 14:58:00 +08:00
Bing2030
dd9e7bf19d fix(desktop): coerce commit-hash versions to valid semver (MUL-3314) (#4183)
* 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>
2026-06-18 14:17:50 +08:00
Naiyuan Qing
4c7fefd143 fix(editor): cap issue mention chip at container width, no clickable gap (#4295)
#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>
2026-06-18 14:11:07 +08:00
Bohan Jiang
2d1313c5a4 MUL-3264: add Codex usage cache backfill
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.
2026-06-18 13:34:10 +08:00
Bohan Jiang
e3a829f05e feat(daemon): disk-usage cross-root aggregation + migration FK/readiness fixes (MUL-3404) (#4290)
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.
2026-06-18 13:33:14 +08:00
Bohan Jiang
02c0298c18 MUL-3405: constrain issue mention chips in chat (#4288)
* fix: constrain issue mention chips

Co-authored-by: multica-agent <github@multica.ai>

* docs: update issue chip width comment

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 11:26:05 +08:00
Bohan Jiang
e7daf876bd fix(daemon): reclaim autopilot_run workdir on terminal status (MUL-3403) (#4287)
* 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>
2026-06-18 11:17:59 +08:00
Bohan Jiang
6f29a4c0a6 fix(workspace): derive workspace URL prefix from deployment host (MUL-3400) (#4286)
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>
2026-06-18 11:12:10 +08:00
YYClaw
da4f278330 fix(usage): disambiguate model pricing by provider (MUL-3346)
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
2026-06-18 11:10:06 +08:00
Bohan Jiang
1279f22d1c MUL-3325: add background task safety brief (#4257)
* fix(daemon): add background task safety brief

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): force Claude background tools foreground

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): narrow Claude async launch detection

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 10:45:51 +08:00
Hzzzzzx
e9e97ee6d2 refactor(editor): dedupe upload-node scan and markdown normalization (#4267)
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>
2026-06-18 10:43:26 +08:00
Naiyuan Qing
d74a0cb34c fix(runtimes): give Tencent CodeBuddy its own provider logo (MUL-3394) (#4279)
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>
2026-06-18 10:19:28 +08:00
Naiyuan Qing
b7857a6aa3 feat(chat): workspace-scoped attachment binding + fire-and-forget send (#4249)
* 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>
2026-06-18 09:40:38 +08:00
Jiayuan Zhang
63b9b10df5 MUL-3328: add retry button for failed agent comments (#4217)
* MUL-3328: add retry button for failed agent comments

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3328: cover child-done system comments

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3328: restrict retry affordance to failures

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3328: clean migration whitespace

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:18:44 +02:00
Jiayuan Zhang
2d71872daa feat(autopilot): default subscriber template (MUL-2533) (#3060)
* 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>
2026-06-17 15:18:06 +02:00
Bohan Jiang
2f398c36ad fix: gzip daemon claim responses (#4259)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 18:48:33 +08:00
LinYushen
c5eb778532 Revert "fix: keep runtime provider arbiter during profile rollout (#4251)" (#4258)
This reverts commit a08281a1b2.
2026-06-17 18:23:46 +08:00
Multica Eve
c27d35b7fe style(runtimes): unify header action buttons to match Add a computer (MUL-3368) (#4253)
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>
2026-06-17 17:56:39 +08:00
Multica Eve
5a3324e886 refactor(runtimes): trim redundant copy in custom runtimes dialog (MUL-3367) (#4254)
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>
2026-06-17 17:54:09 +08:00
Multica Eve
3077810049 fix(db): clean pending check suites on workspace delete (#4252)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 17:53:43 +08:00
Multica Eve
a08281a1b2 fix: keep runtime provider arbiter during profile rollout (#4251)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 17:51:47 +08:00
Multica Eve
23eba24076 MUL-3363: add 2026-06-17 changelog entry (#4248)
* docs: add 2026-06-17 changelog entry

Co-authored-by: multica-agent <github@multica.ai>

* docs: shorten 2026-06-17 changelog copy

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 17:46:15 +08:00
Bohan Jiang
0f36c88855 fix(markdown): don't auto-link bare filenames as external URLs (#4245)
* 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>
2026-06-17 17:06:09 +08:00
LinYushen
77ac17ef49 Make custom runtimes appear immediately (#4234)
* Make custom runtimes appear immediately

* Scope daemon profile refresh by authorized runtimes

* Relay runtime profile refresh hints

* Localize runtime profile close label
2026-06-17 16:00:22 +08:00
Multica Eve
af146b6dc7 fix(runtimes): re-enable Delete affordance for owners on self-healing runtimes (MUL-3352) (#4240)
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>
2026-06-17 15:31:14 +08:00
Jiayuan Zhang
41586f1499 fix(github): surface in-flight CI on PR cards (MUL-2392) (#2887)
* 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>
2026-06-17 09:24:15 +02:00
Jiayuan Zhang
59263df748 feat(issues): unify trigger chip copy to will-start phrasing (#4215)
* 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>
2026-06-17 09:12:45 +02:00
Wes
d26cac0008 Guard issue icons against unknown values (#4206) 2026-06-17 14:56:10 +08:00
Matt Voska
6f2e9aa7a8 feat(cli): add --thinking-level to agent create and update (#4170) (#4207)
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>
2026-06-17 14:47:59 +08:00
Naiyuan Qing
acb1c3fb64 MUL-3331: dedupe/throttle client failure telemetry (#4231)
* 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>
2026-06-17 14:42:38 +08:00
Bohan Jiang
114a1ffb8f Fix: fail fast when Codex app-server exits MUL-2840 (#4228)
* fix: fail fast when codex process exits

Co-authored-by: multica-agent <github@multica.ai>

* fix: fail active codex turns on process exit

Co-authored-by: multica-agent <github@multica.ai>

* fix: prefer codex context terminal states

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 14:34:29 +08:00
Jiayuan Zhang
eb6dffdbc6 MUL-3341: clear incompatible model on runtime switch
Closes MUL-3341
2026-06-17 08:23:20 +02:00
Multica Eve
6e010320f8 MUL-3332: prioritize custom runtime profiles (#4229)
* feat: prioritize custom runtime profiles

Co-authored-by: multica-agent <github@multica.ai>

* fix: address runtime profile dialog nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 13:39:48 +08:00
AdamQQQ
3030c803bf fix(scripts): fix version comparison to prevent unnecessary CLI upgrades (#4227)
`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>
2026-06-17 13:27:24 +08:00
Multica Eve
6bb8cac9ea MUL-3332: daemon picks up new custom runtime profiles without restart (#4225)
* 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>
2026-06-17 12:36:30 +08:00
Bohan Jiang
64ce459e30 fix(github): preserve early installation webhook metadata (#4193)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 12:26:25 +08:00
LinYushen
1f5cb51d4e MUL-3284: Web UI + CLI (custom runtime PR3) (#4177)
* 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>
2026-06-17 11:38:17 +08:00
LinYushen
52e76e7b23 MUL-3284: server API + daemon (custom runtime PR2) (#4149)
* 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>
2026-06-17 11:33:09 +08:00
LinYushen
32dac3dd57 MUL-3284: runtime_profile schema (custom runtime PR1) (#4140)
* 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>
2026-06-17 11:32:55 +08:00
taogejiang
1f8f3e8037 Fix Office 365 SMTP auth fallback (#4157)
* Fix Office 365 SMTP auth fallback

* Fix SMTP auth fallback tests

* fix(smtp): address code review feedback for Office 365 auth fallback

- Move defer c.Close() after nil check in sendSMTP to prevent panic
  when openSMTPClient() fails (c can be nil on dial/setup failure).
- Add TLS security guard to loginAuth.Start: refuse credentials on
  unencrypted remote connections (mirroring smtp.PlainAuth behavior),
  validate expected host name, and allow localhost bypass.
- Add isLocalhost() helper for loopback/private-network checks.
- Add comprehensive test coverage: loginAuth.Start security checks
  (unencrypted remote, TLS, localhost, loopback IPs, wrong host),
  sendSMTP no-panic on dial failure, and full sendSMTP flow tests
  with mock SMTP server (PLAIN success, LOGIN fallback reconnect,
  unauthenticated relay).
2026-06-17 11:27:48 +08:00
Naiyuan Qing
f46b929ebc fix(editor): don't wipe in-flight uploads on external content sync (#4196)
* 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>
2026-06-16 17:57:17 +08:00
Multica Eve
89ada0ee81 MUL-3324: add 2026-06-16 changelog entry (#4194)
* docs: add 2026-06-16 changelog entry

Co-authored-by: multica-agent <github@multica.ai>

* docs: adjust 2026-06-16 changelog wording

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 17:39:33 +08:00
Naiyuan Qing
1272311ebe Revert "MUL-3312: gate chat uploads on active agent (#4192)" (#4195)
This reverts commit 097064ed0e.
2026-06-16 17:23:35 +08:00
Multica Eve
18a58e80c0 MUL-3316: fix(execenv): switch agent prompt to --content-file to prevent heredoc flag swallowing (#4182) (#4191)
* 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>
2026-06-16 17:14:25 +08:00
Willow Lopez
2c0f6edca8 MUL-3320: feat(lark): add proxy support for WebSocket connections (#4165)
* 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>
2026-06-16 17:13:40 +08:00
Wes
3aaca155e7 Fix transcript actions on touch devices (#4161) 2026-06-16 17:06:14 +08:00
Wes
4f1797598e MUL-3321: Add runtime delete CLI command
Adds a command-line runtime delete flow with strict default behavior and explicit cascade support.\n\nFixes #3909.
2026-06-16 16:58:10 +08:00
David Zhang
8ba1ef2dce MUL-3319: Update Codex and Cursor resume docs
Update provider documentation to reflect working Codex and Cursor session resumption.
2026-06-16 16:48:18 +08:00
Naiyuan Qing
097064ed0e MUL-3312: gate chat uploads on active agent (#4192)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 16:43:55 +08:00
Willow Lopez
089832d6ec fix(web): preserve CLI callback params across Google OAuth redirect (MUL-3313) (#4167)
* 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>
2026-06-16 16:38:15 +08:00
Naiyuan Qing
c222088262 feat: client failure telemetry (JS errors + freeze/crash) to PostHog (#4187)
* 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>
2026-06-16 16:31:38 +08:00
Naiyuan Qing
79394ee057 MUL-3310: disable bare issue key expansion in comments (#4190)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 16:28:38 +08:00
Bohan Jiang
241a3582cf fix: validate issue status and priority (#4156)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 12:26:44 +08:00
Naiyuan Qing
7c71007e6e refactor(comments): trim trigger preview copy and unify composer buttons (#4174)
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>
2026-06-16 09:44:05 +08:00
DimaS
2f24057bc2 feat(issues): add date filter (#4129)
Co-authored-by: “646826” <“646826@gmail.com”>
2026-06-16 08:38:53 +08:00
Naiyuan Qing
1afa493165 fix(comments): align trigger preview context (#4147)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 08:20:15 +08:00
Naiyuan Qing
f2e72577b2 MUL-3304: align projects compact row navigation (#4155)
* fix(projects): align compact row navigation

Co-authored-by: multica-agent <github@multica.ai>

* docs(projects): clarify row action navigation comment

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 19:02:51 +08:00
Multica Eve
12c2d58e18 MUL-3303: add 2026-06-15 changelog entry (#4150)
* docs: add 0.3.22 changelog entry

Co-authored-by: multica-agent <github@multica.ai>

* docs: clarify 0.3.22 changelog copy

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-06-15 17:55:48 +08:00
Bohan Jiang
7d30ef1c67 fix: preserve openclaw gateway token mask (#4152)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 17:46:35 +08:00
Naiyuan Qing
3ce4cf6f2f fix(lists): navigate rows via onClick, not a nested row anchor (#4146)
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>
2026-06-15 16:56:38 +08:00
Bohan Jiang
93541be975 MUL-3239: include route context in desktop recovery prompts
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:50:54 +08:00
Naiyuan Qing
76c687d39a fix(markdown): allow attachment download file-card hrefs (#4145)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:47:11 +08:00
Bohan Jiang
f9c193e06b fix: fail closed on agent task auth tokens (#4142)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:34:35 +08:00
marovole
0e31a9ca58 fix(agent/runtimes): show Cursor Composer token usage and billing (#4135)
* fix(agent/runtimes): show Cursor Composer token usage and billing

Attribute Cursor stream-json usage to the configured runtime model when
result events omit `model`, and add Composer/Auto pricing so dashboard
cost estimates resolve for composer-2.5 runs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(views): align Cursor Composer pricing

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 15:52:48 +08:00
Bohan Jiang
71eb938a67 fix: preserve inbox comment anchors for MUL-3294 (#4139)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 15:52:18 +08:00
Bohan Jiang
4df6c1468d fix: validate selfhost compose env defaults (#4138)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 15:43:10 +08:00
ant
8ea8048005 MUL-3290: fix selfhost docker compose upload 500
Pass AWS static credential environment variables through the self-host compose backend service.
2026-06-15 15:28:13 +08:00
Naiyuan Qing
ea4f816ce2 fix(comments): support edit trigger suppression (#4136)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 15:12:45 +08:00
Bohan Jiang
7bd99c3c87 fix(desktop): mount Cmd+W handler at app root (#4137)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 15:10:33 +08:00
Fangfei
40b318e3e0 fix(issues): restore issue detail scroll on back (MUL-2841) (#3539)
* Fix issue detail scroll restoration

* Fix highlight scroll restore regression

* Fix saved highlight scroll restoration
2026-06-15 15:00:53 +08:00
LeePepe
90fafab33a MUL-3240: fix(desktop): Cmd+W closes active tab first, then window
Closes #3987

MUL-3240
2026-06-15 14:52:52 +08:00
Kagura
2ab7b5b7af MUL-3280: fix(editor): repair split email links caused by autolink + inclusive:false
Fixes #4091
2026-06-15 14:38:34 +08:00
Naiyuan Qing
63cf0ed308 feat(lists): rebuild all six list surfaces on a shared Linear-style list grid (#4038)
* 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>
2026-06-15 14:12:24 +08:00
Multica Eve
9a7eebb194 fix: re-sign unresolved attachment media urls (#4132)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 14:09:25 +08:00
Willow Lopez
a4fb84d5ac MUL-3273: fix(agent): parse Cursor token usage fields
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.
2026-06-15 14:04:05 +08:00
Bohan Jiang
6c17771cce fix: re-sign inline attachment media for token-mode clients (#4085)
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>
2026-06-15 13:54:36 +08:00
YOMXXX
34d4cd3a28 feat(openclaw): support connecting to existing OpenClaw gateway (#3260) [MUL-3158] (#3664)
* 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.
2026-06-13 15:33:28 +08:00
Bohan Jiang
5b7eb9ad20 fix: normalize codex cached input usage (#4083)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 15:32:01 +08:00
Bohan Jiang
04a0677704 fix(markdown): keep dollar amounts literal in editor (#4084)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 02:14:41 +08:00
Bohan Jiang
f415099c4a MUL-3263: support managed MCP config for Cursor (#4081)
* feat: support managed MCP config for Cursor

Co-authored-by: multica-agent <github@multica.ai>

* fix: address Cursor MCP review feedback

Co-authored-by: multica-agent <github@multica.ai>

* docs: include Cursor in skills MCP support

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 02:07:00 +08:00
Bohan Jiang
ef08d8584c MUL-3254: flush issue description edits on close (#4082)
* 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>
2026-06-13 02:03:13 +08:00
Matt Voska
70b90d287c MUL-3267: fix(markdown): disable single-dollar inline math in web renderer
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>
2026-06-13 01:48:18 +08:00
Bohan Jiang
fa15041864 MUL-3254: fix pasted image draft rendering in desktop (#4066)
* 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>
2026-06-13 01:25:08 +08:00
Bohan Jiang
7db3e507d1 feat(cli): manage workspace repo registry (#4067)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 01:22:45 +08:00
Jiayuan Zhang
7d28b5a040 fix(issues): remove duplicate emoji reaction entry from comment header (#4068)
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>
2026-06-12 12:39:40 +02:00
Multica Eve
be00801acf MUL-3256: add 2026-06-12 changelog entry (#4065)
* docs: add 2026-06-12 changelog entry

Co-authored-by: multica-agent <github@multica.ai>

* docs: refine 2026-06-12 changelog copy

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 17:49:28 +08:00
Bohan Jiang
c8ab73d38d MUL-3244: Bind quick-create attachments to created issues (#4062)
* fix: bind quick-create attachments to created issues

Co-authored-by: multica-agent <github@multica.ai>

* test: use real image markdown in quick-create attachment test

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 16:45:38 +08:00
LinYushen
99afb82c50 Add index on "user".created_at (#4063)
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>
2026-06-12 15:53:55 +08:00
Naiyuan Qing
d2a03b8edc Fix chat stop and send recovery (#4060)
* Fix chat stop and send recovery

Co-authored-by: multica-agent <github@multica.ai>

* Fix chat cancel recovery follow-ups

Co-authored-by: multica-agent <github@multica.ai>

* Guard cancelled chat restore on tx failure

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 15:29:14 +08:00
Liu Guanzhong
4594c776e1 feat(agent): add CodeBuddy as first-class CLI backend (#3186)
* feat(agent): add codebuddyBackend struct and buildCodebuddyArgs

Introduces the codebuddy agent backend skeleton with args builder
that mirrors claudeBackend's protocol flags (stream-json, bypass
permissions, blocked args filtering) for the codebuddy CLI fork.

* feat(agent): implement codebuddyBackend.Execute with stream-json parsing

* feat(agent): wire codebuddy into New() factory and launchHeaders

* feat(agent): add codebuddy dynamic model discovery from --help

* feat(agent): add codebuddy thinking/effort discovery and providerThinkingEnums

* feat(daemon): add codebuddy CLI probe, env vars, and args support

* fix(agent): use len(models)==0 for default model instead of loop index

* fix(agent): increase codebuddy --help timeout to 35s for slow CLI startup

* fix(agent): address codebuddy PR review feedback

- Wire codebuddy into execenv: reuse claude's CLAUDE.md, .claude/skills,
  and ~/.claude/skills paths since CodeBuddy is a Claude Code fork
- Replace hardcoded 20-min timeout with runContext for zero-timeout =
  no-deadline semantics matching all other backends
- Restore runContext regression tests lost in rebase merge
- Mirror claude.go execution model: concurrent stdin write to prevent
  pipe deadlock, sync.Once for stdin closure, keep stdin open for
  control_request auto-approval mid-run
- Add control_request handling with auto-approve behavior
- Add RequestID/Request fields to codebuddySDKMessage
- Add codebuddy to metrics knownRuntimeProviders
- Add codebuddy to provider-logo.tsx (reuses ClaudeLogo)
- Consolidate --help discovery: shared codebuddyHelpOutput cache
  eliminates duplicate cold-start invocations

---------

Co-authored-by: krislliu <krislliu@tencent.com>
2026-06-12 15:22:16 +08:00
Multica Eve
9439a85aa6 MUL-3242: fix daemon workdir provisioning race
Fixes GitHub issue #3999 by moving the daemon StartTask transition behind workdir provisioning and extending the active env-root guard through completion metadata writes.
2026-06-12 15:14:27 +08:00
Bohan Jiang
f37d71a443 fix: apply single skill overwrite immediately (#4057)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 14:57:14 +08:00
Bohan Jiang
9f720a401c fix(desktop): improve renderer recovery prompt (#4056)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 14:21:59 +08:00
Bohan Jiang
c510515da7 fix: suggest daemon profiles for empty disk usage
- 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
2026-06-12 13:37:35 +08:00
Bohan Jiang
21ff178ac0 MUL-2701: hide raw creator UUID in skill import conflict UI (#3498)
* 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>
2026-06-12 13:09:28 +08:00
yuhaowin
5c136f8557 fix(lark): fix auth race and redirect param in LarkBindPage (#4047)
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>
2026-06-12 12:59:54 +08:00
Multica Eve
5957454dd9 docs: add June 11 changelog entry (#4037)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 17:46:36 +08:00
Naiyuan Qing
0985bad9fd fix(issues): render thread replies in chronological order (#3691) (#4033)
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>
2026-06-11 15:45:53 +08:00
Truffle
6acca84c28 fix(agent): clear stale session id when a resumed ACP session is gone [MUL-3216] (#4015)
* 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.
2026-06-11 14:54:56 +08:00
Bohan Jiang
0cbb834f96 docs: merge latest changelog entries (#4030)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 14:35:51 +08:00
Bohan Jiang
8151f60c6c fix(daemon): drop stale resume session when workdir is not reused (#4027)
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.

Fixes multica-ai/multica#3854 (MUL-3221)

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 13:07:44 +08:00
Bohan Jiang
e4ec9dc425 MUL-2802: add skill import conflict strategies (#3997)
* 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>
2026-06-11 13:00:56 +08:00
Antoine GIRARD
5480c69c9e fix: sort execution log past runs by timestamp (newest first) (#4018)
MUL-3217
2026-06-11 12:18:04 +08:00
Multica Eve
7d719cfbbe docs: add June 10 changelog entry (#4004)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 17:46:54 +08:00
Naiyuan Qing
a0b63462d0 fix(issues): keep comment trigger preview fresh against live queue state (#4007)
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>
2026-06-10 17:45:29 +08:00
Naiyuan Qing
d66730ecdb fix(issues): state-specific trigger chip copy (#4006)
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>
2026-06-10 17:29:24 +08:00
LinYushen
2754b7d7d8 fix(attachments): render description images with CDN URL (#4005) 2026-06-10 17:26:13 +08:00
Naiyuan Qing
f2ba3c8f1a fix(editor): wrap tables in tableWrapper so wide tables scroll locally (#4003)
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>
2026-06-10 17:20:48 +08:00
Naiyuan Qing
dc129b1178 fix(issues): polish comment trigger chip presentation (#4002)
- 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>
2026-06-10 17:09:03 +08:00
LinYushen
619c4c4953 fix(attachments): bind description uploads via contentReferencesAttachment (#4001)
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.
2026-06-10 17:04:06 +08:00
Naiyuan Qing
906f70a3e2 Add comment trigger preview suppression (#3792)
* 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>
2026-06-10 16:27:07 +08:00
Multica Eve
abf99eb700 fix(attachments): server-driven markdown_url + legacy compat (MUL-3192) (#3991)
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>
2026-06-10 16:00:40 +08:00
Bohan Jiang
9455310c0c fix(realtime): invalidate per-issue caches on WS reconnect (#3992)
* 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>
2026-06-10 15:36:09 +08:00
Naiyuan Qing
34c68e1e4c fix(comments): enforce single resolution per thread (#3984)
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>
2026-06-10 14:20:39 +08:00
Naiyuan Qing
e15df22e98 feat(autopilots): show creator in autopilot detail properties (MUL-3139) (#3983)
* 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>
2026-06-10 14:20:27 +08:00
Bohan Jiang
b1c8eb5f11 feat: support Claude Fable 5 pricing (#3982)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 12:33:27 +08:00
Bohan Jiang
ac75c97797 fix(desktop): disable auto-start/stop toggles for a daemon the app can't control (WSL2) (#3940)
* 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>
2026-06-10 12:27:41 +08:00
Bohan Jiang
72179d1145 refactor(transcript): reuse payload helper + cover coalesce timestamps (MUL-3174) (#3958)
* 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>
2026-06-10 12:15:50 +08:00
Bohan Jiang
2e0b0bb776 fix(db): drop FK on agent_task_queue.initiator_user_id (MUL-2645) (#3959)
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>
2026-06-09 19:59:20 +08:00
Antoine GIRARD
9f21d0b634 feat(transcript): add timestamps to run transcript entries (MUL-3174) (#3951)
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.
2026-06-09 19:53:30 +08:00
Naiyuan Qing
6d646db577 fix(issues): soften comment sticky mask (#3956) 2026-06-09 18:27:49 +08:00
Multica Eve
d9347f0715 MUL-3175: add June 9 changelog entry (#3954)
* docs(changelog): add June 9 release notes

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): polish zh release copy

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): clarify zh release title

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-06-09 18:17:10 +08:00
Naiyuan Qing
b542c40936 fix(issues): keep sticky comment highlight consistent (#3955)
* fix(issues): keep sticky comment highlight consistent

* fix(issues): remove reply resolve-thread action
2026-06-09 18:02:11 +08:00
Naiyuan Qing
2bdc8344dd fix(issues): align sticky comment header padding (#3952) 2026-06-09 17:17:14 +08:00
shutcode
a2ef95445b MUL-2794 fix(agent): stop Cursor sessions on terminal result (#3165)
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
2026-06-09 16:49:48 +08:00
Naiyuan Qing
c983905d5c feat(issues): per-comment thread resolution with sticky collapse (#3910)
* 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>
2026-06-09 16:32:22 +08:00
LinYushen
70ccbd9bce Revert "MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP) (#…" (#3944)
This reverts commit 8ff68502fc.
2026-06-09 14:50:56 +08:00
Bohan Jiang
998ebe97e4 fix(autopilot): fail create-issue runs on any terminal task failure (#3943)
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
2026-06-09 14:48:20 +08:00
Naiyuan Qing
90b639888d fix(issues): confine inbox deep-link scroll to the timeline container (#3929) (#3942)
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>
2026-06-09 14:46:46 +08:00
elrrrrrrr
254ec945f5 fix(agent/codex): shut down gracefully so OTEL telemetry flushes (#3888)
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>
2026-06-09 14:46:07 +08:00
Multica Eve
13e9485a3b MUL-3130: persist stable /api/attachments/<id>/download URL in comment markdown (#3937)
* 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
`![file](signed-url)` 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>
2026-06-09 14:26:36 +08:00
stevenayl
ee6200de25 fix(autopilot): fail no-progress issue runs (#3927)
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
2026-06-09 14:25:04 +08:00
Chenyu-24601
8b94764c47 feat(daemon): configurable OpenClaw binary path / state dir via CLIConfig.Backends (MUL-3157)
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
2026-06-09 14:05:37 +08:00
Bohan Jiang
42251b42fc fix(cli): honor MULTICA_SERVER_URL in setup self-host (#3912) (#3938)
* 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>
2026-06-09 14:02:06 +08:00
Bohan Jiang
7dc05d28bc fix(projects): validate project status/priority — return 400 instead of 500 (#3925) (#3939)
* 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>
2026-06-09 13:54:53 +08:00
LinYushen
9ff801f926 docs(cli): error-message conventions + sign-in copy (PR3, MUL-3104) (#3900)
* 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>
2026-06-09 13:15:51 +08:00
Multica Eve
8ff68502fc MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP) (#3903)
* 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>
2026-06-09 11:59:00 +08:00
Jiayuan Zhang
072404d912 fix(issues): header chip shows 'is queued' when no agent is running (#3923)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 03:11:10 +08:00
550 changed files with 50694 additions and 6360 deletions

View File

@@ -21,11 +21,16 @@ APP_ENV=
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# Optional aliases for the local/self-host backend port. If one is set, it
# takes precedence over PORT in compose, Makefile, and installer helpers.
# BACKEND_PORT=8080
# Docker Compose consumes flat port values. Set BACKEND_PORT directly to
# override the backend host port.
BACKEND_PORT=8080
# Optional aliases for local/self-host backend port helpers outside compose.
# API_PORT=8080
# SERVER_PORT=8080
FRONTEND_PORT=3000
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
FRONTEND_ORIGIN=http://localhost:${FRONTEND_PORT}
# Prometheus metrics are disabled by default. When enabled, bind to loopback
# unless you protect the listener with private networking, allowlists, or
# proxy auth. Do not expose this endpoint through the public app/API ingress.
@@ -35,9 +40,9 @@ JWT_SECRET=change-me-in-production
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when the daemon reaches the API through a different URL.
# MULTICA_SERVER_URL=ws://localhost:8080/ws
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
# Set explicitly only when the app's public URL differs from local frontend.
# MULTICA_APP_URL=http://localhost:3000
MULTICA_APP_URL=${FRONTEND_ORIGIN}
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers and to show correct daemon setup commands in the web UI. Leave
@@ -112,9 +117,9 @@ SMTP_EHLO_NAME=
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
# Set explicitly only when your OAuth callback URL differs from local frontend.
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
@@ -122,6 +127,8 @@ GOOGLE_CLIENT_SECRET=
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
S3_BUCKET=
S3_REGION=us-west-2
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
# AWS_ENDPOINT_URL — optional S3-compatible endpoint (MinIO, RustFS, R2, etc.).
# For internal Docker/VPC hosts such as http://rustfs:9000, leave
# ATTACHMENT_DOWNLOAD_MODE=auto or set proxy explicitly so browsers/CLI do
@@ -228,10 +235,6 @@ MULTICA_LARK_HTTP_BASE_URL=
MULTICA_LARK_CALLBACK_BASE_URL=
# Frontend
FRONTEND_PORT=3000
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
# FRONTEND_ORIGIN=http://localhost:3000
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
NEXT_PUBLIC_API_URL=

View File

@@ -98,7 +98,7 @@ jobs:
run: cd server && go run ./cmd/migrate up
- name: Test
run: cd server && go test ./...
run: cd server && go test -race ./...
installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy

View File

@@ -52,7 +52,7 @@ jobs:
cache-dependency-path: server/go.sum
- name: Run tests
run: cd server && go test ./...
run: cd server && go test -race ./...
release:
needs: verify

View File

@@ -3,8 +3,10 @@
This file provides guidance to AI agents when working with code in this repository.
> **Single source of truth:** This file is a concise pointer document.
> All authoritative architecture, coding rules, commands, and conventions
> All authoritative architecture, coding rules, and conventions
> live in **CLAUDE.md** at the project root. Read that file first.
> Use `Makefile`, `package.json`, and `pnpm-workspace.yaml` as the
> source of truth for the full command list.
## Quick Reference
@@ -12,27 +14,27 @@ This file provides guidance to AI agents when working with code in this reposito
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
- `server/` Go backend (Chi router, sqlc, gorilla/websocket)
- `apps/web/` Next.js frontend (App Router)
- `apps/desktop/` Electron desktop app
- `packages/core/` Headless business logic (Zustand stores, React Query hooks, API client)
- `packages/ui/` Atomic UI components (shadcn/Base UI, zero business logic)
- `packages/views/` Shared business pages/components
- `packages/tsconfig/` Shared TypeScript config
- `server/` - Go backend (Chi router, sqlc, gorilla/websocket)
- `apps/web/` - Next.js frontend (App Router)
- `apps/desktop/` - Electron desktop app
- `packages/core/` - Headless business logic (Zustand stores, React Query hooks, API client)
- `packages/ui/` - Atomic UI components (shadcn/Base UI, zero business logic)
- `packages/views/` - Shared business pages/components
- `packages/tsconfig/` - Shared TypeScript config
### State Management (critical)
- **React Query** owns all server state (issues, members, agents, inbox, workspace list)
- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals)
- All Zustand stores live in `packages/core/` never in `packages/views/` or app directories
- WS events invalidate React Query never write directly to stores
- All Zustand stores live in `packages/core/` - never in `packages/views/` or app directories
- WS events invalidate React Query - never write directly to stores
### Package Boundaries (hard rules)
- `packages/core/` zero react-dom, zero localStorage, zero process.env
- `packages/ui/` zero `@multica/core` imports
- `packages/views/` zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
- `apps/web/platform/` only place for Next.js APIs
- `packages/core/` - zero react-dom, zero localStorage, zero process.env
- `packages/ui/` - zero `@multica/core` imports
- `packages/views/` - zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
- `apps/web/platform/` - only place for Next.js APIs
### Commands
@@ -44,4 +46,4 @@ make test # Go tests
make check # Full verification pipeline
```
See CLAUDE.md for the complete command reference.
See CLAUDE.md for the authoritative rules and common commands.

507
CLAUDE.md
View File

@@ -1,427 +1,226 @@
# CLAUDE.md
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:
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
- `apps/docs/content/docs/developers/conventions.mdx`
- `apps/docs/content/docs/developers/conventions.zh.mdx`
Read that page before:
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
- Built for 2-10 person AI-native teams
## State Rules
## Architecture
Keep server state and client state separate.
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
- 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)
- `packages/tsconfig/` — Shared TypeScript configuration
## Package Boundaries
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)
make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend (all commands go through Turborepo)
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm dev:desktop # Electron dev (electron-vite, HMR)
pnpm build # Build all frontend apps
pnpm typecheck # TypeScript check (all packages + apps via turbo)
pnpm lint # ESLint
pnpm test # TS tests (Vitest, all packages + apps via turbo)
# Backend (Go)
make server # Run Go server only (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries to server/bin/
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
make dev # auto-setup and start the app
make start # start backend + frontend
make stop # stop app processes for this checkout
make server # run Go server only
make daemon # run local daemon
make test # Go tests
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single TS test (works for any package with a test script)
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Mobile (Expo) — two environments only: dev and staging
pnpm dev:mobile # Metro, dev env (reads apps/mobile/.env.development.local)
pnpm dev:mobile:staging # Metro, staging env (reads apps/mobile/.env.staging)
pnpm ios:mobile # Native build + install dev-client to iOS Simulator, dev env
pnpm ios:mobile:staging # Native build + install dev-client to iOS Simulator, staging env
pnpm ios:mobile:device # Native build + install dev-client to USB iPhone, dev env
pnpm ios:mobile:device:staging # Native build + install dev-client to USB iPhone, staging env
# Daily flow: run `pnpm dev:mobile:staging` (or :dev). Only re-run `ios:mobile*` when
# native code or any expo-*/react-native-* dependency changes (lockfile drift counts).
# Desktop build & package
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
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` |
| Shared UI components, pages, forms, modals | `packages/views/*.test.tsx` |
| 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 |
| Shared UI components (pages, forms, modals) | `packages/views/*.test.tsx` | jsdom, no framework mocks |
| Platform-specific wiring (cookies, redirects, searchParams) | `apps/web/*.test.tsx` or `apps/desktop/` | Needs framework-specific mocks |
| 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)
- `packages/views/` — Vitest, jsdom environment, `@testing-library/react`
- `apps/web/` — Vitest, jsdom environment, framework-specific mocks
- `e2e/` — Playwright
- `server/` — Go standard `go test`
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:
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi();
await loginAsDefault(page);
});
test.afterEach(async () => {
await api.cleanup();
});
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue");
await page.goto(`/issues/${issue.id}`);
});
```
## Commit Rules
- Use atomic commits grouped by logical intent.
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
## Minimum Pre-Push Checks
```bash
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
```
Run verification only when the user explicitly asks for it.
For targeted checks when requested:
```bash
pnpm typecheck # TypeScript type errors only
pnpm test # TS unit tests only (Vitest, all packages)
make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
## AI Agent Verification Loop
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.

View File

@@ -699,3 +699,79 @@ Most commands support `--output` with two formats:
multica issue list --output json
multica daemon status --output json
```
## Error Messages
The CLI funnels command errors returned to the top-level handler through a
single user-facing translation layer (`server/internal/cli/errors.go`) so that
what you see on the terminal is a short, actionable sentence rather than a raw
Go error, an HTTP status line, or an internal `resolve issue: ...` chain. (A
few commands print their own output or run deliberate fast probes — for example
`setup`'s short `/health` reachability check — and don't go through this
layer.) The underlying detail is still available on demand (see `--debug`).
### What you see
- **Friendly, single-line message.** Transport failures (timeout, DNS,
connection refused, TLS) and HTTP status failures (401/403/404/409/400·422/
429/5xx) are each rendered as one clear sentence with a next step — for
example a timeout suggests checking the network or raising
`MULTICA_HTTP_TIMEOUT`, and a 401 tells you to run `multica login`.
- **Server-provided validation messages are preserved.** For a 400/422 that
carries a message from the server, that message is shown verbatim
(`Invalid request: <server message>`); only when there is none do you get the
generic "check your values / run with --help" hint.
- **No leaked internals by default.** Raw URLs, status lines, JSON bodies, and
the internal verb chain are hidden unless you ask for them.
### Language
Messages default to **English**, matching the rest of the CLI's help output.
If a Chinese locale is detected in `LC_ALL`, `LC_MESSAGES`, or `LANG` (in that
precedence order), messages switch to **Chinese**. No flag is needed; set the
locale as usual:
```bash
LANG=zh_CN.UTF-8 multica issue get MUL-9999 # 错误信息显示为中文
```
### Exit codes
The process exit code is tiered so scripts can branch on the failure class:
| Exit code | Meaning |
| --- | --- |
| `0` | success |
| `1` | generic / unclassified error |
| `2` | network error (timeout, DNS, connection refused, TLS, offline) |
| `3` | authentication / authorization (HTTP 401, 403) |
| `4` | not found (HTTP 404) |
| `5` | validation (HTTP 400, 422) |
```bash
multica issue get MUL-9999
if [ $? -eq 4 ]; then echo "no such issue"; fi
```
### Seeing the full detail (`--debug`)
Pass the global `--debug` flag (or set `MULTICA_DEBUG=1`) to print the complete
original error chain — the internal verb chain, the request method/path/status,
and the raw server body — underneath the friendly message. Use it when you need
to file a bug or understand exactly what the server returned:
```bash
multica issue list --debug
MULTICA_DEBUG=1 multica issue update MUL-1234 --title "x"
```
### Request timeout
API requests use a default timeout of 30 seconds. Override it with
`MULTICA_HTTP_TIMEOUT` when you are on a slow network; it accepts a Go duration
(`45s`, `2m`) or a plain number of seconds (`45`). Command-level deadlines are
always at least this value, so raising it takes effect across all commands.
```bash
MULTICA_HTTP_TIMEOUT=60s multica issue list
```

View File

@@ -20,6 +20,7 @@ RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSIO
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_codex_usage_cache ./cmd/backfill_codex_usage_cache
# --- Runtime stage ---
FROM alpine:3.21
@@ -32,6 +33,7 @@ COPY --from=builder /src/server/bin/server .
COPY --from=builder /src/server/bin/multica .
COPY --from=builder /src/server/bin/migrate .
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
COPY --from=builder /src/server/bin/backfill_codex_usage_cache .
COPY server/migrations/ ./migrations/
COPY docker/entrypoint.sh .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh

View File

@@ -296,7 +296,7 @@ test: ## Run Go tests after ensuring the target DB exists and migrations are app
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test ./...
cd server && go test -race ./...
# Database
##@ Database

View File

@@ -49,6 +49,7 @@
"electron-updater": "^6.8.3",
"fix-path": "^5.0.0",
"lucide-react": "catalog:",
"motion": "^12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "^7.6.0",

View File

@@ -98,16 +98,29 @@ export function stripLeadingSeparator(argv) {
* - "v0.1.36" → "0.1.36"
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
* - "f1415e96" (no tag) → "0.0.0-gf1415e96" (fallback)
* - "2f24057b" (no tag, hash begins with a digit) → "0.0.0-g2f24057b"
* - "0123456" (no tag, all-digit hash w/ leading zero) → "0.0.0-g0123456"
*
* Leading `v` is stripped so the result is valid semver for package.json.
* The fallback matters because a bare commit hash is never valid semver —
* even one that happens to start with a digit (e.g. "2f24057b") — and
* electron-updater throws on launch if package.json carries such a version.
* The hash is prefixed with `g` so the pre-release identifier is always
* alphanumeric; a bare all-digit hash with a leading zero (e.g. "0123456")
* would otherwise form `0.0.0-0123456`, which is invalid semver.
*/
export function normalizeGitVersion(raw) {
if (!raw) return null;
const stripped = raw.replace(/^v/, "");
if (!/^\d/.test(stripped)) {
// No reachable tag — `git describe` fell back to just the commit hash.
return `0.0.0-${stripped}`;
// A real version begins with major.minor.patch. The bare commit hash
// that `git describe --always` falls back to (no reachable tag) does not,
// so coerce it to a 0.0.0 prerelease rather than passing it through.
// Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
// shorthand) so a hash like "0123456" yields "0.0.0-g0123456" — a single
// alphanumeric identifier — instead of the invalid "0.0.0-0123456".
if (!/^\d+\.\d+\.\d+/.test(stripped)) {
return `0.0.0-g${stripped}`;
}
return stripped;
}

View File

@@ -38,11 +38,27 @@ describe("normalizeGitVersion", () => {
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
});
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
it("falls back to 0.0.0-g<hash> when no tags are reachable", () => {
// `git describe --tags --always` returns just the short commit hash
// when there are no tags in the history at all.
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
// when there are no tags in the history at all. A hash that begins with
// a digit (e.g. "2f24057b") is still not valid semver and must fall
// through — otherwise electron-updater rejects it on launch. The `g`
// prefix mirrors git describe's own `g<hash>` shorthand and keeps the
// pre-release identifier a single alphanumeric token.
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-gf1415e96");
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-gabc1234");
expect(normalizeGitVersion("2f24057b")).toBe("0.0.0-g2f24057b");
});
it("prefixes an all-digit hash so the pre-release is valid semver", () => {
// A short hash that is all decimal digits with a leading zero would
// produce `0.0.0-0123456` — a numeric pre-release identifier must not
// have a leading zero, so that value is invalid semver and
// electron-updater would throw on the no-tag builds this fallback
// exists to protect. The `g` prefix makes it a single alphanumeric
// identifier, which is always valid.
expect(normalizeGitVersion("0123456")).toBe("0.0.0-g0123456");
expect(normalizeGitVersion("04567")).toBe("0.0.0-g04567");
});
});

View File

@@ -20,6 +20,11 @@ import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
import { daemonStatusAlive } from "../shared/daemon-types";
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
import { decideVersionAction } from "./version-decision";
import {
daemonLifecycleUnreachable,
isDaemonExternallyManaged,
normalizeHostOS,
} from "./daemon-os";
import {
classifyAuthProbe,
isAuthStatusError,
@@ -161,6 +166,8 @@ function sendStatus(status: DaemonStatus): void {
interface HealthPayload {
status?: string;
pid?: number;
/** Daemon's runtime.GOOS. Absent on daemons older than the #3916 fix. */
os?: string;
uptime?: string;
daemon_id?: string;
device_name?: string;
@@ -347,6 +354,16 @@ async function fetchHealth(): Promise<DaemonStatus> {
authExpired = false;
startingSince = null;
// A running daemon whose OS differs from this host's is one we can't drive
// via the native lifecycle CLI (e.g. Linux-in-WSL2 behind a Windows desktop,
// reachable only over localhost forwarding). Surface it so the UI disables
// the auto-start/auto-stop toggles instead of letting them silently no-op,
// and so before-quit skips a stop that would never land. See #3916.
const externallyManaged = isDaemonExternallyManaged(
data.os,
normalizeHostOS(process.platform),
);
// Safety: if we have a target URL and the daemon on our port reports a
// different server_url, it's not "our" daemon — drop it and re-resolve.
if (
@@ -370,6 +387,7 @@ async function fetchHealth(): Promise<DaemonStatus> {
: 0,
profile: active.name,
serverUrl: data.server_url,
externallyManaged,
};
}
@@ -556,6 +574,15 @@ async function ensureRunningDaemonVersionMatches(): Promise<
> {
const active = await ensureActiveProfile();
const running = await fetchHealthAtPort(active.port);
// Don't try to version-match a daemon we can't restart (e.g. WSL2). Treat it
// as up-to-date — restartDaemon would no-op anyway, and skipping here avoids
// a misleading "restarting daemon" log on every auto-start. #3916.
if (isDaemonExternallyManaged(running?.os, normalizeHostOS(process.platform))) {
pendingVersionRestart = false;
return "ok";
}
const bundled = await getCliBinaryVersion();
const action = decideVersionAction(bundled, running);
@@ -837,7 +864,32 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
});
}
/**
* Fresh boundary preflight for stop/restart: read the active profile's CURRENT
* /health and decide whether the daemon runs somewhere the app can't drive
* (WSL2 etc.). Done per call rather than off the poll cache, so a lifecycle op
* never shells out to a CLI that can't reach the daemon's process — even on
* paths that didn't just poll (e.g. restart-on-user-switch in syncToken, which
* calls restartDaemon directly). See #3916.
*/
async function lifecycleBlockedByForeignDaemon(): Promise<boolean> {
const active = await ensureActiveProfile();
return daemonLifecycleUnreachable(
async () => (await fetchHealthAtPort(active.port))?.os,
normalizeHostOS(process.platform),
);
}
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
// Central lifecycle guard: a daemon running in an environment we can't drive
// (e.g. Linux in WSL2 behind a Windows desktop) can't be stopped by the
// native CLI — it would act on the host process namespace and no-op, while
// still flipping our state to "stopped". Bail as a successful no-op so every
// caller (logout, quit, restart, the Runtime card) is covered in one place
// rather than each remembering to check. Preflighted against live /health so
// it holds even when no poll ran first. #3916.
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
const bin = await resolveCliBinary();
if (!bin) return { success: false, error: "multica CLI is not installed" };
@@ -864,6 +916,11 @@ async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
}
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
// Same central, live-preflighted guard as stopDaemon: we can neither stop nor
// start a daemon we don't manage, so don't try (user-switch, reauth,
// first-workspace, and any future restart caller all route through here).
// #3916.
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
const stopResult = await stopDaemon();
if (!stopResult.success) return stopResult;
return startDaemon();
@@ -1111,6 +1168,8 @@ export function setupDaemonManager(
isQuitting = true;
event.preventDefault();
try {
// stopDaemon no-ops for an externally-managed daemon (WSL2 etc.), so
// this is safe and instant in that case — the guard lives there. #3916
await stopDaemon();
} catch {
// Best-effort stop on quit

View File

@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import {
daemonLifecycleUnreachable,
isDaemonExternallyManaged,
normalizeHostOS,
} from "./daemon-os";
describe("normalizeHostOS", () => {
it("maps win32 to the GOOS spelling 'windows'", () => {
expect(normalizeHostOS("win32")).toBe("windows");
});
it("passes darwin and linux through unchanged (already GOOS spellings)", () => {
expect(normalizeHostOS("darwin")).toBe("darwin");
expect(normalizeHostOS("linux")).toBe("linux");
});
});
describe("isDaemonExternallyManaged", () => {
it("flags a Linux (WSL2) daemon behind a Windows desktop — the #3916 case", () => {
expect(isDaemonExternallyManaged("linux", normalizeHostOS("win32"))).toBe(
true,
);
});
// These three are the "不误伤" guarantees: a native daemon on each platform
// must keep its auto-start/auto-stop toggles.
it("does NOT flag a native Windows daemon under a Windows desktop", () => {
expect(isDaemonExternallyManaged("windows", normalizeHostOS("win32"))).toBe(
false,
);
});
it("does NOT flag a native macOS daemon under a macOS desktop", () => {
expect(isDaemonExternallyManaged("darwin", normalizeHostOS("darwin"))).toBe(
false,
);
});
it("does NOT flag a native Linux daemon under a Linux desktop", () => {
expect(isDaemonExternallyManaged("linux", normalizeHostOS("linux"))).toBe(
false,
);
});
// Fail safe: an older daemon that predates the `os` field reports nothing.
// Hiding a toggle on a guess would 误伤, so unknown OS = treat as manageable.
it("fails safe to false when the daemon reports no OS", () => {
expect(isDaemonExternallyManaged(undefined, "windows")).toBe(false);
expect(isDaemonExternallyManaged("", "windows")).toBe(false);
});
});
// The stop/restart lifecycle boundary funnels through this. It must read the
// daemon's LIVE OS (not a cached poll value), so a restart on a path that
// didn't just poll — e.g. user-switch — still can't shell out at a WSL2 daemon.
describe("daemonLifecycleUnreachable", () => {
it("consults the live OS reader and blocks a foreign-OS (WSL2) daemon", async () => {
const readDaemonOS = vi.fn().mockResolvedValue("linux");
expect(await daemonLifecycleUnreachable(readDaemonOS, "windows")).toBe(true);
// Proves the decision came from a fresh read, not a stale cache.
expect(readDaemonOS).toHaveBeenCalledTimes(1);
});
it("allows a native daemon whose live OS matches the host", async () => {
expect(
await daemonLifecycleUnreachable(async () => "windows", "windows"),
).toBe(false);
expect(
await daemonLifecycleUnreachable(async () => "darwin", "darwin"),
).toBe(false);
});
it("fails safe to false when the live OS is unknown (older daemon / none running)", async () => {
expect(
await daemonLifecycleUnreachable(async () => undefined, "windows"),
).toBe(false);
});
});

View File

@@ -0,0 +1,67 @@
/**
* Detecting a daemon the desktop app can't manage.
*
* The app reads daemon liveness over HTTP at 127.0.0.1:{port}/health, but it
* starts/stops the daemon by shelling out to the bundled native CLI, which acts
* on the *host* OS process namespace. On Windows with the daemon running inside
* WSL2, /health is reachable via localhost forwarding (so status looks fine) but
* the daemon's process lives in a separate Linux namespace the Windows CLI can't
* touch — so auto-start / auto-stop silently do nothing (#3916).
*
* The reliable, low-false-positive signal is the daemon's own OS (reported as
* `os` on /health, = runtime.GOOS) vs the desktop host OS. They only disagree
* when the daemon runs in a foreign environment we can't drive. This module is
* the single source of truth for that comparison so it stays unit-tested — the
* cost of a false positive is hiding a working toggle from a native user, so the
* logic must fail safe (treat unknown / matching as manageable).
*/
/**
* Normalize a Node `process.platform` value to the daemon's `runtime.GOOS`
* vocabulary so the two are directly comparable. Only `win32` -> `windows`
* actually differs across the platforms we ship (darwin/linux already match);
* any other value passes through unchanged.
*/
export function normalizeHostOS(platform: NodeJS.Platform): string {
return platform === "win32" ? "windows" : platform;
}
/**
* Whether a running daemon is in an environment the desktop app can't control.
*
* Returns true ONLY when the daemon reports a concrete OS that differs from the
* host's. Fails safe to false when:
* - `daemonOS` is missing/empty (older daemon that predates the `os` field, or
* a malformed response) — we can't prove it's foreign, so keep toggles live.
* - the OSes match — a normally-managed native daemon.
*
* Callers must only invoke this for a daemon that is actually running; a stopped
* daemon has no OS to compare and its toggles must stay enabled.
*/
export function isDaemonExternallyManaged(
daemonOS: string | undefined,
hostOS: string,
): boolean {
if (typeof daemonOS !== "string" || daemonOS.length === 0) return false;
return daemonOS !== hostOS;
}
/**
* Boundary preflight for daemon lifecycle ops (stop / restart): resolve the
* daemon's CURRENT OS via `readDaemonOS` and return true when it's running
* somewhere the app can't drive.
*
* `readDaemonOS` is a live `/health` read performed at the call site — never a
* cached poll value. That is the whole point: a stale "manageable" cache would
* let a lifecycle op shell out to a native CLI that can't reach a WSL2 daemon
* (the PID lives in another namespace), which is exactly the bug. Taking the
* reader as a parameter keeps this unit-testable without the electron-coupled
* daemon-manager module, and lets the test prove the live value — not a cache —
* drives the decision. See #3916.
*/
export async function daemonLifecycleUnreachable(
readDaemonOS: () => Promise<string | undefined>,
hostOS: string,
): Promise<boolean> {
return isDaemonExternallyManaged(await readDaemonOS(), hostOS);
}

View File

@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
writeFreezeBreadcrumb,
readAndClearFreezeBreadcrumb,
clearFreezeBreadcrumb,
type FreezeBreadcrumb,
} from "./freeze-breadcrumb";
// Each test gets its own temp dir so the on-disk breadcrumb is isolated.
const dirs: string[] = [];
function tempFile(): string {
const dir = mkdtempSync(join(tmpdir(), "freeze-breadcrumb-"));
dirs.push(dir);
return join(dir, "last-client-failure.json");
}
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
const sample: FreezeBreadcrumb = {
kind: "unresponsive",
context: { desktopRoute: { path: "/acme/issues" } },
ts: 1_700_000_000_000,
version: "0.3.1",
};
describe("freeze breadcrumb round-trip", () => {
it("writes then reads back the breadcrumb", () => {
const file = tempFile();
writeFreezeBreadcrumb(file, sample);
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
});
it("read clears the file so a failure reports exactly once", () => {
const file = tempFile();
writeFreezeBreadcrumb(file, sample);
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
expect(existsSync(file)).toBe(false);
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("clearFreezeBreadcrumb removes a pending breadcrumb (hang recovered)", () => {
const file = tempFile();
writeFreezeBreadcrumb(file, sample);
clearFreezeBreadcrumb(file);
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
});
// The breadcrumb crosses a process boundary (main writes, renderer flushes via
// IPC) and lives across app versions — a future write shape or a corrupt file
// must never throw into boot. CLAUDE.md "API Response Compatibility".
describe("freeze breadcrumb defends against malformed input", () => {
it("returns null when no file exists", () => {
expect(readAndClearFreezeBreadcrumb(tempFile())).toBeNull();
});
it("returns null on corrupt JSON", () => {
const file = tempFile();
writeFileSync(file, "{ not valid json", "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("returns null when `kind` is missing", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify({ ts: 1, version: "x" }), "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("returns null when `kind` is the wrong type", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify({ kind: 42, context: {} }), "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("returns null on a JSON null payload", () => {
const file = tempFile();
writeFileSync(file, "null", "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("clearing a non-existent file is a no-op, never throws", () => {
expect(() => clearFreezeBreadcrumb(tempFile())).not.toThrow();
});
});

View File

@@ -0,0 +1,76 @@
import { writeFileSync, readFileSync, rmSync } from "node:fs";
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
// When the renderer truly hangs or its process dies, it can't send telemetry
// itself — the thread is blocked or gone. The main process (always alive) is
// the only watcher that can react, but during the hang it can't reach the
// renderer's posthog-js either. So it writes a breadcrumb to disk; the next
// time a renderer boots, it reads + clears the file and reports the event.
// This survives even a force-quit, which is the whole point.
export type { FreezeBreadcrumb };
/**
* Best-effort write. A breadcrumb we can't persist is lost, never fatal.
*
* Known limitation: this is a single slot — last write wins. Multiple failures
* within one session collapse to the last one, so per-session failure counts
* are undercounted. Acceptable for now: telemetry aggregates presence and
* frequency across users, not exhaustive per-session sequences. Upgrade to an
* append/ring buffer if per-session failure chains become a question.
*/
export function writeFreezeBreadcrumb(filePath: string, breadcrumb: FreezeBreadcrumb): void {
try {
writeFileSync(filePath, JSON.stringify(breadcrumb), "utf8");
} catch {
// Disk full / permissions — drop silently.
}
}
/**
* Delete a persisted breadcrumb. Called when the renderer recovers from a hang
* (a `responsive` event after `unresponsive`): the breadcrumb was written
* pre-emptively while the thread was stuck, but since it came back, the
* in-thread long-task watchdog already reports it — keeping the breadcrumb
* would double-count it AND mislabel a recovered window as `recovered: false`.
* Best-effort; a stale breadcrumb only costs one duplicate report.
*/
export function clearFreezeBreadcrumb(filePath: string): void {
try {
rmSync(filePath, { force: true });
} catch {
// Nothing to clear / permissions — ignore.
}
}
/**
* Read the breadcrumb and delete it in the same call, so a failure is reported
* exactly once. Returns null when there's no breadcrumb (the normal case) or
* when the file is unreadable / corrupt.
*/
export function readAndClearFreezeBreadcrumb(filePath: string): FreezeBreadcrumb | null {
let raw: string;
try {
raw = readFileSync(filePath, "utf8");
} catch {
return null;
}
try {
rmSync(filePath, { force: true });
} catch {
// If we can't delete it we'd re-report next launch; acceptable over throwing.
}
try {
const parsed: unknown = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as FreezeBreadcrumb).kind === "string"
) {
return parsed as FreezeBreadcrumb;
}
} catch {
// Corrupt JSON — drop.
}
return null;
}

View File

@@ -13,11 +13,21 @@ import { installNavigationGestures } from "./navigation-gestures";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import {
RENDERER_ROUTE_CONTEXT_CHANNEL,
sanitizeRendererRouteContext,
type RendererRouteContext,
} from "../shared/renderer-route-context";
import {
createElectronReloadPrompt,
installRendererRecoveryHandlers,
type RendererRecoveryWindow,
} from "./renderer-recovery";
import {
writeFreezeBreadcrumb,
readAndClearFreezeBreadcrumb,
clearFreezeBreadcrumb,
} from "./freeze-breadcrumb";
// Bundled icon used for dock/taskbar branding. macOS/Windows production
// builds let the OS pick up the icon from the .app bundle / .exe resources,
@@ -61,7 +71,15 @@ if (process.platform !== "win32") {
const PROTOCOL = "multica";
// Where the main process parks a freeze/crash breadcrumb until the next
// renderer boot flushes it to telemetry. Lives in userData so it survives a
// force-quit. Resolved lazily — app.getPath is only valid after `ready`.
function freezeBreadcrumbPath(): string {
return join(app.getPath("userData"), "last-client-failure.json");
}
let mainWindow: BrowserWindow | null = null;
let latestRendererRouteContext: RendererRouteContext | null = null;
let runtimeConfigResult: RuntimeConfigResult = {
ok: false,
error: { message: "Runtime config has not loaded yet" },
@@ -126,7 +144,7 @@ function createWindow(): void {
minWidth: 900,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 13 },
trafficLightPosition: { x: 16, y: 17 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option.
@@ -166,9 +184,13 @@ function createWindow(): void {
},
});
const window = mainWindow;
latestRendererRouteContext = null;
window.on("closed", () => {
if (mainWindow === window) mainWindow = null;
if (mainWindow === window) {
mainWindow = null;
latestRendererRouteContext = null;
}
});
// Strip Origin header from WebSocket upgrade requests so the server's
@@ -204,10 +226,14 @@ function createWindow(): void {
// Window-level keyboard shortcuts. Calling preventDefault here prevents
// both the renderer keydown AND the application menu accelerator, so
// anything we own here (reload-block, zoom) is the sole handler for
// that combination — no double-fire with the macOS default View menu.
// anything we own here (reload-block, zoom, tab-close) is the sole handler
// for that combination — no double-fire with the macOS default View menu.
window.webContents.on("before-input-event", (event, input) => {
if (handleAppShortcut(input, window.webContents)) {
const result = handleAppShortcut(input, window.webContents);
if (result === "close-tab") {
event.preventDefault();
window.webContents.send("tab:close-active");
} else if (result) {
event.preventDefault();
}
});
@@ -255,6 +281,27 @@ function createWindow(): void {
showReloadPrompt: createElectronReloadPrompt((options) =>
dialog.showMessageBox(window, options),
),
getDiagnosticContext: () => ({
windowUrl: window.webContents.getURL(),
...(latestRendererRouteContext
? { desktopRoute: latestRendererRouteContext }
: {}),
}),
// Only persist in production: a true hang/crash can't report itself, so we
// write a breadcrumb and the next renderer boot flushes it to PostHog. Dev
// is excluded to keep field telemetry clean.
persistBreadcrumb: is.dev
? undefined
: (payload) =>
writeFreezeBreadcrumb(freezeBreadcrumbPath(), {
kind: payload.kind,
context: payload.context,
ts: Date.now(),
version: getAppVersion(),
}),
clearBreadcrumb: is.dev
? undefined
: () => clearFreezeBreadcrumb(freezeBreadcrumbPath()),
});
installContextMenu(window.webContents);
@@ -370,6 +417,11 @@ if (!gotTheLock) {
return openExternalSafely(url);
});
// Renderer requests window close (e.g. Cmd+W on last tab).
ipcMain.on("window:close", () => {
mainWindow?.close();
});
ipcMain.handle("file:download-url", (_event, url: string) => {
if (!mainWindow) {
console.warn("[download] ignored file:download-url — mainWindow torn down");
@@ -388,6 +440,14 @@ if (!gotTheLock) {
event.returnValue = { version: getAppVersion(), os };
});
// Sync IPC: read + clear any freeze/crash breadcrumb left by a previous
// session. The renderer flushes it to telemetry on boot (it couldn't be
// reported when it happened — the renderer was hung or gone). Read-and-
// clear so a failure reports exactly once.
ipcMain.on("freeze:get-last", (event) => {
event.returnValue = readAndClearFreezeBreadcrumb(freezeBreadcrumbPath());
});
// Sync IPC: preload exposes the validated runtime config before renderer
// boot. If desktop.json exists but is invalid, renderer receives the
// blocking error and must not silently fall back to the cloud defaults.
@@ -395,6 +455,13 @@ if (!gotTheLock) {
event.returnValue = runtimeConfigResult;
});
ipcMain.on(RENDERER_ROUTE_CONTEXT_CHANNEL, (event, context: unknown) => {
if (!mainWindow || event.sender !== mainWindow.webContents) return;
const sanitized = sanitizeRendererRouteContext(context);
if (!sanitized) return;
latestRendererRouteContext = sanitized;
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (e.g. create-workspace) can place UI in the top-left corner
// without fighting the native window controls' hit-test.

View File

@@ -14,13 +14,14 @@ function makeWc(initialLevel = 0) {
function key(
k: string,
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
mods: Partial<Pick<ShortcutInput, "control" | "meta" | "shift">> = {},
): ShortcutInput {
return {
type: "keyDown",
key: k,
control: false,
meta: false,
shift: false,
...mods,
};
}
@@ -150,3 +151,36 @@ describe("handleAppShortcut — unrelated keys pass through", () => {
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
});
});
describe("handleAppShortcut — close tab (Cmd/Ctrl+W)", () => {
it('returns "close-tab" on Cmd+W (macOS)', () => {
const wc = makeWc();
expect(handleAppShortcut(key("w", { meta: true }), wc, "darwin")).toBe("close-tab");
});
it('returns "close-tab" on Cmd+W uppercase', () => {
const wc = makeWc();
expect(handleAppShortcut(key("W", { meta: true }), wc, "darwin")).toBe("close-tab");
});
it('returns "close-tab" on Ctrl+W (Linux/Windows)', () => {
const wc = makeWc();
expect(handleAppShortcut(key("w", { control: true }), wc, "linux")).toBe("close-tab");
expect(handleAppShortcut(key("w", { control: true }), wc, "win32")).toBe("close-tab");
});
it("does not trigger without Cmd/Ctrl modifier", () => {
const wc = makeWc();
expect(handleAppShortcut(key("w"), wc, "darwin")).toBe(false);
});
it("does not trigger on Cmd+Shift+W (reserved for close-window)", () => {
const wc = makeWc();
expect(handleAppShortcut(key("W", { meta: true, shift: true }), wc, "darwin")).toBe(false);
});
it("does not trigger on Ctrl+Shift+W (reserved for close-window)", () => {
const wc = makeWc();
expect(handleAppShortcut(key("W", { control: true, shift: true }), wc, "linux")).toBe(false);
});
});

View File

@@ -8,6 +8,7 @@ export type ShortcutInput = {
key: string;
control: boolean;
meta: boolean;
shift: boolean;
};
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
@@ -34,11 +35,19 @@ const ZOOM_MAX = 4.5;
* Handling the shortcuts here gives identical behavior on every platform
* and every layout.
*/
/**
* Result of handleAppShortcut:
* - `false`: not handled, let Electron continue
* - `true`: handled (preventDefault), no further action
* - `"close-tab"`: Cmd/Ctrl+W intercepted — caller should send IPC to renderer
*/
export type ShortcutResult = boolean | "close-tab";
export function handleAppShortcut(
input: ShortcutInput,
webContents: ZoomTarget,
platform: NodeJS.Platform = process.platform,
): boolean {
): ShortcutResult {
if (input.type !== "keyDown") return false;
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
@@ -70,5 +79,12 @@ export function handleAppShortcut(
return true;
}
// Cmd/Ctrl + W → close active tab (or window if last tab).
// Cmd/Ctrl + Shift + W is reserved for "close window" — do not intercept.
// Return a signal so the caller can send IPC to the renderer.
if (input.key.toLowerCase() === "w" && !input.shift) {
return "close-tab";
}
return false;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { installRendererRecoveryHandlers } from "./renderer-recovery";
import { createElectronReloadPrompt, installRendererRecoveryHandlers } from "./renderer-recovery";
type Handler = (...args: unknown[]) => void;
@@ -83,10 +83,50 @@ describe("installRendererRecoveryHandlers", () => {
vi.useFakeTimers();
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
const desktopRoute = {
surface: "tab",
path: "/acme/issues/MUL-3239",
workspaceSlug: "acme",
tabId: "tab-1",
reportedAt: "2026-06-15T00:00:00.000Z",
};
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt,
getDiagnosticContext: () => ({
windowUrl:
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
desktopRoute,
}),
unresponsivePromptDelayMs: 100,
});
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(showReloadPrompt).toHaveBeenCalledWith({
kind: "unresponsive",
context: {
windowUrl:
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
desktopRoute,
},
});
expect(fixture.reload).not.toHaveBeenCalled();
});
it("keeps prompting when diagnostic context collection fails", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt,
getDiagnosticContext: () => {
throw new Error("diagnostics unavailable");
},
unresponsivePromptDelayMs: 100,
});
@@ -94,7 +134,6 @@ describe("installRendererRecoveryHandlers", () => {
await vi.advanceTimersByTimeAsync(100);
expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} });
expect(fixture.reload).not.toHaveBeenCalled();
});
it("keeps dev diagnostics non-prompting", async () => {
@@ -109,4 +148,124 @@ describe("installRendererRecoveryHandlers", () => {
expect(showReloadPrompt).not.toHaveBeenCalled();
expect(fixture.reload).not.toHaveBeenCalled();
});
it("shows actionable recovery guidance before diagnostic details", async () => {
let detail = "";
const showMessageBox = vi.fn(
async (options: { title: string; message: string; detail: string }) => {
detail = options.detail;
return { response: 1 };
},
);
const showReloadPrompt = createElectronReloadPrompt(showMessageBox);
await showReloadPrompt({ kind: "unresponsive", context: {} });
expect(showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
title: "Multica needs to reload",
message: "The desktop window has been stuck for a few seconds.",
detail: expect.stringContaining(
"Click Reload to refresh this window and keep using Multica.",
),
}),
);
expect(detail).toContain("what you were doing right before this message appeared");
expect(detail).toContain("Activity Monitor sample");
expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}");
});
});
describe("freeze/crash breadcrumb state machine", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.useRealTimers());
function install(fixture: ReturnType<typeof makeWindow>) {
const persistBreadcrumb = vi.fn();
const clearBreadcrumb = vi.fn();
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt: vi.fn(async () => "dismiss" as const),
persistBreadcrumb,
clearBreadcrumb,
unresponsivePromptDelayMs: 100,
});
return { persistBreadcrumb, clearBreadcrumb };
}
it("a sustained hang writes exactly one unresponsive breadcrumb", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
expect(persistBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({ kind: "unresponsive" }),
);
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("recovering after a written breadcrumb clears it (no double-count, no false recovered:false)", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
fixture.windowHandlers.get("responsive")?.();
expect(clearBreadcrumb).toHaveBeenCalledTimes(1);
});
it("recovering before the delay never writes a breadcrumb, so nothing to clear", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
fixture.windowHandlers.get("responsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(persistBreadcrumb).not.toHaveBeenCalled();
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("a hang that never recovers (force-quit) keeps its breadcrumb for next-boot reporting", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
// No "responsive" ever fires — the breadcrumb must survive uncleared.
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("a recoverable crash writes a breadcrumb and never clears it (a dead process never recovers)", () => {
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
expect(persistBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({ kind: "render-process-gone" }),
);
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("a clean (non-crash) renderer exit writes no breadcrumb", () => {
const fixture = makeWindow();
const { persistBreadcrumb } = install(fixture);
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
expect(persistBreadcrumb).not.toHaveBeenCalled();
});
});

View File

@@ -17,6 +17,22 @@ type ReloadPromptResult = "reload" | "dismiss";
type RendererRecoveryOptions = {
isDev: boolean;
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
getDiagnosticContext?: () => Record<string, unknown>;
/**
* Persist a freeze/crash breadcrumb to disk. The renderer can't report a
* true hang or process death itself (blocked / gone), so the main process
* writes it here and the next renderer boot flushes it to telemetry. Omit
* in dev to keep field telemetry clean.
*/
persistBreadcrumb?: (payload: ReloadPromptPayload) => void;
/**
* Delete a previously-persisted unresponsive breadcrumb. Called when the
* renderer recovers (`responsive` after `unresponsive`): the window came
* back, so the in-thread watchdog reports the freeze and the breadcrumb
* would only double-count it. Crash breadcrumbs are never cleared — a dead
* process never recovers.
*/
clearBreadcrumb?: () => void;
log?: (tag: string, ...args: unknown[]) => void;
unresponsivePromptDelayMs?: number;
};
@@ -26,11 +42,21 @@ export function installRendererRecoveryHandlers(
{
isDev,
showReloadPrompt,
getDiagnosticContext,
persistBreadcrumb,
clearBreadcrumb,
log = defaultDevLog,
unresponsivePromptDelayMs = 1500,
}: RendererRecoveryOptions,
) {
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
// True once a breadcrumb has been written for the current hang. A later
// `responsive` clears it; only a hang that never returns survives to report.
let unresponsiveBreadcrumbWritten = false;
const mergeDiagnosticContext = (context: Record<string, unknown>) => ({
...readDiagnosticContext(getDiagnosticContext),
...context,
});
const maybePromptReload = (payload: ReloadPromptPayload) => {
if (isDev) return;
void showReloadPrompt(payload).then((result) => {
@@ -43,14 +69,23 @@ export function installRendererRecoveryHandlers(
window.webContents.on("render-process-gone", (_event, details) => {
if (isDev) log("process-gone", JSON.stringify(details));
if (!isRecoverableRendererExit(details)) return;
maybePromptReload({ kind: "render-process-gone", context: { details } });
const payload: ReloadPromptPayload = {
kind: "render-process-gone",
context: mergeDiagnosticContext({ details }),
};
persistBreadcrumb?.(payload);
maybePromptReload(payload);
});
// preload-error intentionally does NOT persist a breadcrumb: it's a startup
// failure of the preload script itself, and the breadcrumb-flush path depends
// on that same preload exposing `getLastFreeze` — if preload is broken, the
// next boot couldn't read it back anyway. We only prompt for reload here.
window.webContents.on("preload-error", (_event, preloadPath, error) => {
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
maybePromptReload({
kind: "preload-error",
context: { preloadPath, error: formatError(error) },
context: mergeDiagnosticContext({ preloadPath, error: formatError(error) }),
});
});
@@ -58,14 +93,27 @@ export function installRendererRecoveryHandlers(
if (isDev || unresponsivePromptTimer) return;
unresponsivePromptTimer = setTimeout(() => {
unresponsivePromptTimer = null;
maybePromptReload({ kind: "unresponsive", context: {} });
const payload: ReloadPromptPayload = {
kind: "unresponsive",
context: mergeDiagnosticContext({}),
};
persistBreadcrumb?.(payload);
unresponsiveBreadcrumbWritten = true;
maybePromptReload(payload);
}, unresponsivePromptDelayMs);
});
window.on("responsive", () => {
if (!unresponsivePromptTimer) return;
clearTimeout(unresponsivePromptTimer);
unresponsivePromptTimer = null;
if (unresponsivePromptTimer) {
clearTimeout(unresponsivePromptTimer);
unresponsivePromptTimer = null;
}
// The window came back: drop any breadcrumb written during this hang so it
// isn't re-reported (and mislabeled `recovered: false`) on next boot.
if (unresponsiveBreadcrumbWritten) {
clearBreadcrumb?.();
unresponsiveBreadcrumbWritten = false;
}
});
}
@@ -109,18 +157,30 @@ function isRecoverableRendererExit(details: unknown) {
function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) {
switch (kind) {
case "render-process-gone":
return "The desktop renderer process stopped responding or crashed.";
return "The desktop window stopped unexpectedly.";
case "preload-error":
return "The desktop preload script failed before the app could start.";
return "The desktop window could not finish starting.";
case "unresponsive":
return "The desktop window is not responding.";
return "The desktop window has been stuck for a few seconds.";
}
}
function rendererRecoveryDetail(payload: ReloadPromptPayload) {
const guidance = [
"Click Reload to refresh this window and keep using Multica.",
"If this keeps happening, please tell us what you were doing right before this message appeared and whether Reload recovered the window.",
];
if (payload.kind === "unresponsive") {
guidance.push(
"For macOS reports, an Activity Monitor sample of the Multica Helper (Renderer) process helps us find what blocked the app.",
);
}
return [
"Reloading is the safest recovery path for this window.",
...guidance,
"",
"Diagnostic details:",
`kind: ${payload.kind}`,
`context: ${JSON.stringify(payload.context)}`,
].join("\n");
@@ -130,6 +190,17 @@ function defaultDevLog(tag: string, ...args: unknown[]) {
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
}
function readDiagnosticContext(
getDiagnosticContext: (() => Record<string, unknown>) | undefined,
) {
if (!getDiagnosticContext) return {};
try {
return getDiagnosticContext();
} catch {
return {};
}
}
function formatError(error: unknown) {
return error instanceof Error ? (error.stack ?? error.message) : String(error);
}
}

View File

@@ -1,6 +1,8 @@
import { ElectronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { NavigationGesture } from "../shared/navigation-gestures";
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
@@ -14,6 +16,9 @@ interface DesktopAPI {
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig: RuntimeConfigResult;
/** Read + clear any freeze/crash breadcrumb from a previous session, so the
* renderer can flush it to telemetry on boot. Null when nothing's pending. */
getLastFreeze: () => FreezeBreadcrumb | null;
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
@@ -45,6 +50,8 @@ interface DesktopAPI {
) => () => void;
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
/** Report the renderer's memory-router path for recovery diagnostics. */
setRendererRouteContext: (context: RendererRouteContextInput) => void;
/** Open the OS folder picker and return the chosen absolute path.
* Used by the Project settings "Add local directory" flow. */
pickDirectory: (
@@ -71,6 +78,11 @@ interface DesktopAPI {
| "error";
error?: string;
}>;
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
* Returns an unsubscribe function. */
onCloseActiveTab: (callback: () => void) => () => void;
/** Ask the main process to close the window. */
closeWindow: () => void;
}
interface DaemonStatus {

View File

@@ -1,6 +1,11 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
import {
RENDERER_ROUTE_CONTEXT_CHANNEL,
type RendererRouteContextInput,
} from "../shared/renderer-route-context";
import {
isNavigationGesture,
NAVIGATION_GESTURE_CHANNEL,
@@ -74,6 +79,16 @@ const desktopAPI = {
},
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig,
/** Read + clear any freeze/crash breadcrumb left by a previous session, so
* the renderer can flush it to telemetry on boot. Returns null when there's
* nothing pending (the normal case). */
getLastFreeze: (): FreezeBreadcrumb | null => {
try {
return ipcRenderer.sendSync("freeze:get-last") as FreezeBreadcrumb | null;
} catch {
return null;
}
},
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
@@ -156,12 +171,27 @@ const desktopAPI = {
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
};
},
/** Report the renderer's memory-router path for recovery diagnostics. */
setRendererRouteContext: (context: RendererRouteContextInput) =>
ipcRenderer.send(RENDERER_ROUTE_CONTEXT_CHANNEL, context),
/** Open the OS folder picker and return the chosen absolute path. */
pickDirectory: (defaultPath?: string) =>
ipcRenderer.invoke("local-directory:pick", defaultPath),
/** Validate that a path is an existing readable+writable directory. */
validateLocalDirectory: (path: string) =>
ipcRenderer.invoke("local-directory:validate", path),
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
* The renderer should close the active tab; if it was the last tab,
* call `closeWindow()` to dismiss the window. Returns an unsubscribe fn. */
onCloseActiveTab: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("tab:close-active", handler);
return () => {
ipcRenderer.removeListener("tab:close-active", handler);
};
},
/** Ask the main process to close the window (used after closing the last tab). */
closeWindow: () => ipcRenderer.send("window:close"),
};
interface DaemonStatus {

View File

@@ -19,6 +19,7 @@ import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
import { captureEvent } from "@multica/core/analytics";
import { RESOURCES } from "@multica/views/locales";
// BCP-47 region tags for the <html lang> attribute, mirroring
@@ -34,10 +35,42 @@ const HTML_LANG: Record<SupportedLocale, string> = {
};
/**
* Cmd/Ctrl+W: close the active tab. When the last real tab is closed
* (or no tabs/workspace exist — e.g. login page), close the window.
*
* Mounted at the App root so every renderer state — including login,
* loading, onboarding, and runtime-config errors — has a working Cmd+W
* handler. Without this, states outside the tab shell would swallow the
* shortcut and do nothing.
*/
function useCmdWCloseTab() {
useEffect(() => {
return window.desktopAPI.onCloseActiveTab(() => {
const store = useTabStore.getState();
const { activeWorkspaceSlug, byWorkspace } = store;
if (!activeWorkspaceSlug) {
// No workspace — nothing to close, dismiss the window.
window.desktopAPI.closeWindow();
return;
}
const group = byWorkspace[activeWorkspaceSlug];
if (!group || group.tabs.length <= 1) {
// Last tab (or no tabs) — close the window.
window.desktopAPI.closeWindow();
return;
}
// Multiple tabs — close the active one.
store.closeActiveTab();
});
}, []);
}
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const qc = useQueryClient();
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
// setQueryData sequentially. loginWithToken sets user+isLoading=false
// as soon as getMe resolves, which would cause DesktopShell to mount
@@ -298,6 +331,28 @@ export default function App() {
const { version, os } = window.desktopAPI.appInfo;
const systemLocale = window.desktopAPI.systemLocale;
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
useCmdWCloseTab();
// Flush a freeze/crash breadcrumb the main process parked from a previous
// session. A true hang or process death can't report itself when it happens
// (the renderer is blocked or gone), so the main process persists it and we
// emit it here on the next boot. The in-thread, recoverable freeze tier is
// handled separately by the shared watchdog in CoreProvider.
useEffect(() => {
const last = window.desktopAPI.getLastFreeze();
if (!last) return;
const crashed = last.kind === "render-process-gone";
captureEvent(crashed ? "client_crash" : "client_unresponsive", {
// Spread context FIRST so our explicit fields below always win — a
// future context key (e.g. its own `source`) must not silently override.
...last.context,
source: crashed ? "render-process-gone" : "main-unresponsive",
recovered: false,
breadcrumb_ts: last.ts,
crashed_version: last.version,
});
}, []);
// Stable identity reference so downstream effects (WS reconnect) don't
// tear down on every parent render.
const identity = useMemo(

View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { DaemonStatus } from "../../../shared/daemon-types";
// The component only needs these to render; stub them so the test focuses on
// the externally-managed branching, not data fetching.
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: [] }),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/runtimes", () => ({
runtimeListOptions: () => ({ queryKey: ["runtimes"] }),
}));
vi.mock("@multica/core/agents", () => ({
agentTaskSnapshotOptions: () => ({ queryKey: ["snapshot"] }),
}));
vi.mock("./daemon-panel", () => ({ DaemonPanel: () => null }));
vi.mock("../platform/daemon-reauth", () => ({
reauthenticateDaemon: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
import { DaemonRuntimeActions } from "./daemon-runtime-card";
function stubDaemonAPI(status: DaemonStatus) {
Object.defineProperty(window, "daemonAPI", {
configurable: true,
value: {
getStatus: vi.fn().mockResolvedValue(status),
onStatusChange: vi.fn(() => () => {}),
},
});
}
describe("DaemonRuntimeActions — externally managed daemon (#3916)", () => {
it("hides Stop/Restart and shows the managed-outside hint for a daemon the app can't control", async () => {
stubDaemonAPI({ state: "running", daemonId: "d1", externallyManaged: true });
render(<DaemonRuntimeActions />);
// View logs still renders, confirming the running branch mounted.
expect(await screen.findByText("View logs")).toBeInTheDocument();
expect(screen.getByText("Managed outside the app")).toBeInTheDocument();
expect(screen.queryByText("Restart")).not.toBeInTheDocument();
expect(screen.queryByText("Stop")).not.toBeInTheDocument();
});
it("shows Stop/Restart for a normally-managed running daemon (no 误伤)", async () => {
stubDaemonAPI({
state: "running",
daemonId: "d1",
externallyManaged: false,
});
render(<DaemonRuntimeActions />);
expect(await screen.findByText("Restart")).toBeInTheDocument();
expect(screen.getByText("Stop")).toBeInTheDocument();
expect(
screen.queryByText("Managed outside the app"),
).not.toBeInTheDocument();
});
});

View File

@@ -7,6 +7,7 @@ import {
Activity,
ScrollText,
LogIn,
Info,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -126,6 +127,12 @@ export function DaemonRuntimeActions() {
}, []);
const isRunning = status.state === "running";
// The daemon runs somewhere the app can't drive (e.g. inside WSL2): the
// lifecycle CLI acts on the host process namespace and can't reach it. Hide
// Stop/Restart so they don't silently no-op, mirroring the Settings tab. The
// real guard is in the main process (stopDaemon/restartDaemon); this is the
// matching UX. See #3916.
const externallyManaged = status.externallyManaged === true;
const isStopped = status.state === "stopped";
const isCliMissing = status.state === "cli_not_found";
const isAuthExpired = status.state === "auth_expired";
@@ -142,24 +149,33 @@ export function DaemonRuntimeActions() {
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
{externallyManaged ? (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Info className="size-3.5 shrink-0" />
Managed outside the app
</span>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
</>
)}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { AlertCircle, LogIn } from "lucide-react";
import { AlertCircle, Info, LogIn } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
@@ -88,6 +88,12 @@ export function DaemonSettingsTab() {
[],
);
// The daemon runs somewhere the app can't drive (e.g. inside WSL2 behind a
// Windows desktop): /health is reachable but the lifecycle CLI can't reach
// its process. Auto-start/auto-stop can't work, so disable them and say why
// rather than letting the toggles silently no-op. See #3916.
const externallyManaged = status.externallyManaged === true;
return (
<div>
<h2 className="text-lg font-semibold">Daemon</h2>
@@ -119,6 +125,19 @@ export function DaemonSettingsTab() {
</div>
)}
{externallyManaged && (
<div className="mt-4 flex items-start gap-3 rounded-lg border bg-muted/30 px-4 py-3">
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<p className="min-w-0 text-sm text-muted-foreground">
This device&apos;s daemon runs outside the app for example inside
WSL2 so the app can&apos;t start or stop it. Start or stop it from
that environment with{" "}
<code className="font-mono text-xs">multica daemon start</code> /{" "}
<code className="font-mono text-xs">multica daemon stop</code>.
</p>
</div>
)}
<div className="mt-6 divide-y">
<SettingRow
label="Auto-start on launch"
@@ -127,7 +146,7 @@ export function DaemonSettingsTab() {
<Switch
checked={prefs.autoStart}
onCheckedChange={(checked) => updatePref("autoStart", checked)}
disabled={saving}
disabled={saving || externallyManaged}
/>
</SettingRow>
@@ -138,7 +157,7 @@ export function DaemonSettingsTab() {
<Switch
checked={prefs.autoStop}
onCheckedChange={(checked) => updatePref("autoStop", checked)}
disabled={saving}
disabled={saving || externallyManaged}
/>
</SettingRow>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
@@ -22,41 +23,69 @@ import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const TOP_BAR_HEIGHT_CLASS = "h-12";
const WINDOW_TOOLBAR_CLEARANCE = 184;
const toolbarMotion = {
type: "spring",
stiffness: 420,
damping: 38,
mass: 0.8,
} as const;
function WindowToolbar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
const navButtonClassName =
"flex size-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-30";
return (
<div
className="h-12 shrink-0 flex items-center justify-end px-2"
className={cn(
"fixed left-0 top-0 z-30 flex w-[184px] shrink-0 items-center px-3",
TOP_BAR_HEIGHT_CLASS,
)}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
className="flex items-center gap-0.5"
className="flex items-center gap-1 pl-[70px]"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
</button>
<SidebarTrigger
className="size-7 text-muted-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
title="Go back"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
title="Go forward"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
</div>
);
}
function SidebarTopSpacer() {
return <div className={cn("shrink-0", TOP_BAR_HEIGHT_CLASS)} />;
}
function useNativeNavigationGestures() {
const { goBack, goForward } = useTabHistory();
@@ -72,30 +101,31 @@ function useNativeNavigationGestures() {
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
// left side so tabs don't land under the macOS traffic lights (which live at
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
// the sidebar can be brought back without keyboard shortcut.
// is not occupying main-flow width, leave room for the fixed window toolbar
// so tabs do not land beneath the traffic lights / navigation controls.
function MainTopBar() {
const { state, isMobile } = useSidebar();
const sidebarHidden = state === "collapsed" || isMobile;
return (
<header
className={cn(
"h-12 shrink-0 flex items-center gap-2",
sidebarHidden && "pl-20",
)}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
<motion.header
animate={{ paddingLeft: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className={cn("relative shrink-0 flex items-center gap-2", TOP_BAR_HEIGHT_CLASS)}
initial={false}
transition={toolbarMotion}
>
{sidebarHidden && (
<SidebarTrigger
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
)}
<TabBar />
</header>
<motion.div
aria-hidden
animate={{ left: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className="absolute inset-y-0 right-0"
initial={false}
transition={toolbarMotion}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div className="relative z-10 flex h-full items-center">
<TabBar />
</div>
</motion.header>
);
}
@@ -183,9 +213,10 @@ export function DesktopShell() {
<DesktopInboxBridge />
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
{slug && <WindowToolbar />}
{slug && <AppSidebar topSlot={<SidebarTopSpacer />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<motion.div layout transition={toolbarMotion} className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
@@ -193,7 +224,7 @@ export function DesktopShell() {
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</div>
</motion.div>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}

View File

@@ -7,6 +7,7 @@ import {
useTabStore,
} from "@/stores/tab-store";
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
import type { RendererRouteContextInput } from "../../../shared/renderer-route-context";
/**
* Fires a PostHog $pageview whenever the user's visible surface changes,
@@ -90,6 +91,16 @@ export function PageviewTracker() {
const last = lastSurfaceRef.current;
const next = { kind, key, path };
const routeContext: RendererRouteContextInput = {
surface: kind,
path,
};
if (kind === "tab") {
routeContext.workspaceSlug = activeWorkspaceSlug ?? undefined;
routeContext.tabId = activeTabId ?? undefined;
}
reportRendererRouteContext(routeContext);
if (kind === "tab" && key !== null) {
const knownPath = observed.get(key);
const isReactivation =
@@ -112,6 +123,13 @@ export function PageviewTracker() {
return null;
}
function reportRendererRouteContext(context: RendererRouteContextInput) {
const desktopAPI = window.desktopAPI as
| { setRendererRouteContext?: (context: RendererRouteContextInput) => void }
| undefined;
desktopAPI?.setRendererRouteContext?.(context);
}
function overlayPath(overlay: WindowOverlay): string {
switch (overlay.type) {
case "new-workspace":

View File

@@ -13,4 +13,18 @@ import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";
// react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click any
// element to copy its source path + line + component stack for pasting to an AI.
// Opt-in per developer: only loads when VITE_REACT_GRAB is set in a local,
// gitignored apps/desktop/.env.development.local — it never activates for anyone
// else, and the whole branch is tree-shaken out of production builds. The web app
// wires the same tool via next/script in apps/web/app/layout.tsx.
// See https://www.react-grab.com/
if (import.meta.env.DEV && import.meta.env.VITE_REACT_GRAB) {
const grab = document.createElement("script");
grab.src = "//unpkg.com/react-grab/dist/index.global.js";
grab.crossOrigin = "anonymous";
document.head.appendChild(grab);
}
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -22,6 +22,16 @@ export interface DaemonStatus {
profile?: string;
/** Backend URL the daemon connects to. */
serverUrl?: string;
/**
* True when a daemon is running but in an environment the app can't control
* — its reported OS differs from the desktop host's (e.g. a Linux daemon
* inside WSL2 behind a Windows desktop, reachable only via localhost
* forwarding). The app's start/stop CLI acts on the host process namespace,
* so auto-start/auto-stop can't reach it; the UI disables those toggles
* instead of silently no-op'ing. Only ever set on a running daemon, so it
* never disables the toggles for a normally-managed native daemon. See #3916.
*/
externallyManaged?: boolean;
}
export interface DaemonPrefs {

View File

@@ -0,0 +1,16 @@
/**
* A freeze/crash breadcrumb persisted by the main process and flushed to
* telemetry by the next renderer boot. Shared across main, preload, and
* renderer because all three touch it. See main/freeze-breadcrumb.ts for the
* read/write logic and the rationale.
*/
export interface FreezeBreadcrumb {
/** "unresponsive" (hang) or "render-process-gone" (crash). */
kind: string;
/** Diagnostic context captured at failure time (route, window url, …). */
context: Record<string, unknown>;
/** Epoch ms when the failure was recorded. */
ts: number;
/** App version at failure time. */
version: string;
}

View File

@@ -0,0 +1,51 @@
export const RENDERER_ROUTE_CONTEXT_CHANNEL = "renderer:route-context";
export type RendererRouteSurface = "login" | "overlay" | "tab";
export type RendererRouteContextInput = {
surface: RendererRouteSurface;
path: string;
workspaceSlug?: string;
tabId?: string;
};
export type RendererRouteContext = RendererRouteContextInput & {
reportedAt: string;
};
const MAX_ROUTE_CONTEXT_STRING_LENGTH = 512;
export function sanitizeRendererRouteContext(
value: unknown,
reportedAt = new Date(),
): RendererRouteContext | null {
if (!value || typeof value !== "object") return null;
const input = value as Record<string, unknown>;
if (!isRendererRouteSurface(input.surface)) return null;
const path = sanitizeString(input.path);
if (!path) return null;
const workspaceSlug = sanitizeString(input.workspaceSlug);
const tabId = sanitizeString(input.tabId);
return {
surface: input.surface,
path,
...(workspaceSlug ? { workspaceSlug } : {}),
...(tabId ? { tabId } : {}),
reportedAt: reportedAt.toISOString(),
};
}
function isRendererRouteSurface(value: unknown): value is RendererRouteSurface {
return value === "login" || value === "overlay" || value === "tab";
}
function sanitizeString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
return trimmed.slice(0, MAX_ROUTE_CONTEXT_STRING_LENGTH);
}

View File

@@ -79,6 +79,19 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
| `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート |
| `multica skill files ...` | ネスト: スキルのファイルを管理 |
### スキルインポートの競合
`multica skill import --url <url>` の既定値は `--on-conflict fail` です。同じ名前のスキルがすでに存在する場合、コマンドは構造化された `conflict` 結果で終了し、ワークスペースは変更されません。
既存スキルの作成者で、スキル ID とエージェントの紐付けを維持したまま内容を置き換える場合は `--on-conflict overwrite` を使います。既存スキルを残してコピーを取り込む場合は `--on-conflict rename` を使うと、`-2` のような接尾辞が自動で付きます。同名の項目を単に飛ばす場合は `--on-conflict skip` を使います。
```bash
multica skill import --url https://skills.sh/acme/repo/review-helper
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
```
## スクワッド
| コマンド | 用途 |

View File

@@ -79,6 +79,19 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
| `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 |
| `multica skill files ...` | 중첩: 스킬의 파일 관리 |
### 스킬 가져오기 충돌
`multica skill import --url <url>`의 기본값은 `--on-conflict fail`입니다. 같은 이름의 스킬이 이미 있으면 명령은 구조화된 `conflict` 결과로 종료되며 워크스페이스를 변경하지 않습니다.
기존 스킬을 만든 사용자이고, 스킬 ID와 에이전트 연결은 유지한 채 내용을 바꾸려면 `--on-conflict overwrite`를 사용하세요. 기존 스킬을 그대로 두고 복사본을 가져오려면 `--on-conflict rename`을 사용하면 `-2` 같은 접미사가 자동으로 붙습니다. 같은 이름의 항목을 그냥 건너뛰려면 `--on-conflict skip`을 사용하세요.
```bash
multica skill import --url https://skills.sh/acme/repo/review-helper
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
```
## 스쿼드
| 명령어 | 용도 |

View File

@@ -79,6 +79,25 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
| `multica skill files ...` | Nested: manage a skill's files |
### Skill import conflicts
`multica skill import --url <url>` defaults to `--on-conflict fail`. If a skill
with the same name already exists, the command exits with a structured
`conflict` result and does not change the workspace.
Use `--on-conflict overwrite` when you created the existing skill and want to
replace its content while preserving its ID and agent bindings. Use
`--on-conflict rename` to import a copy with an automatic suffix such as `-2`.
Use `--on-conflict skip` to leave the existing skill untouched and report
`skipped`.
```bash
multica skill import --url https://skills.sh/acme/repo/review-helper
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
```
## Squads
| Command | Purpose |

View File

@@ -79,6 +79,19 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
### Skill 导入冲突
`multica skill import --url <url>` 默认等同于 `--on-conflict fail`。如果工作区里已经有同名 Skill命令会返回结构化 `conflict` 结果并退出,不会修改工作区。
如果你是已有 Skill 的 creator并且想用新导入内容覆盖它同时保留原 Skill 的 ID 和 agent 绑定,用 `--on-conflict overwrite`。如果想保留已有 Skill、另存一份用 `--on-conflict rename`,系统会自动加 `-2` 这类后缀。如果只是批量导入时遇到同名项就跳过,用 `--on-conflict skip`。
```bash
multica skill import --url https://skills.sh/acme/repo/review-helper
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
```
## 小队
| 命令 | 用途 |

View File

@@ -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` のような裸のイシューキーを入力しても、通常のテキストのまま残ります。そのため、`feature/MUL-123` のようなコメント内のブランチ名やパスも書き換えられません
<Callout type="info">
Markdown の強調は CommonMark のルールに従います。太字テキストが句読点や閉じ引用符で終わり、その直後に韓国語の助詞が続く場合、閉じの `**` が認識されないことがあります。

View File

@@ -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 규칙을 따릅니다. 굵은 텍스트가 문장 부호나 닫는 따옴표로 끝나고 그 뒤에 한국어 조사가 바로 이어지면, 닫는 `**`가 인식되지 않을 수 있습니다.

View File

@@ -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.

View File

@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
## 引用 issue
要链接另一个 issue直接输入它的 issue key例如 `MUL-123`。Multica 会在评论中解析真实存在的 issue key并把它存成内部的 `mention://issue/<uuid>` 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
要链接另一个 issue请在评论的 mention 选择器里选择它。Multica 会把 issue 链接存成显式的 `[MUL-123](mention://issue/<uuid>)` mention 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
通常不需要手写 `[MUL-123](mention://issue/<uuid>)`。这是 Multica 解析 key 之后使用的内部规范格式
直接输入裸 issue key例如 `MUL-123`,会保持为普通文本。这样评论里的分支名和路径,例如 `feature/MUL-123`,也不会被改写
<Callout type="info">
Markdown 加粗遵循 CommonMark 规则。当加粗文本以标点或闭引号结尾,并且后面紧跟韩语助词时,结尾的 `**` 可能不会被识别。

View File

@@ -48,7 +48,7 @@ multica daemon restart
### Codex (OpenAI)
よりきめ細かい承認ゲートを備えた JSON-RPC 2.0 のトランスポートです。**セッション再開のコードは存在しますが、現在は到達できません** — 再開が必要な場合は Claude Code か ACP 系列のいずれかを選んでください
よりきめ細かい承認ゲートを備えた JSON-RPC 2.0 のトランスポートです。**セッション再開は動作します** — 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>` でそれを渡します。
| | |
|---|---|

View File

@@ -48,7 +48,7 @@ multica daemon restart
### Codex (OpenAI)
더 세분화된 승인 게이트를 갖춘 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>`로 전달합니다.
| | |
|---|---|

View File

@@ -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 session id, 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>`.
| | |
|---|---|

View File

@@ -48,7 +48,7 @@ multica daemon restart
### CodexOpenAI
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 @@ JSON-RPC 2.0 传输审批粒度更细。MCP 配置会写入单次任务的 `$
### CursorAnysphere
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id,你传过去的续接 id 永远无效
Cursor 编辑器的 CLI 对应物。**会话续接可用**——当前 Cursor Agent 会在 stream-json 事件里返回 `session_id`Multica 会在下一次运行时用 `--resume <id>` 传回去
| | |
|---|---|

View File

@@ -14,16 +14,16 @@ Multica は **12 個の AI コーディングツール**を標準でサポート
| ツール | ベンダー | セッション再開 | MCP | スキル注入パス | モデル選択 |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 動的探索(`agy models` |
| **Claude Code** | Anthropic | ✅ | **✅(実際に使用する唯一のツール)** | `.claude/skills/` | 静的 + flag |
| **Codex** | OpenAI | ⚠️ コードは存在するが到達不可 | | `$CODEX_HOME/skills/` | 静的 |
| **Claude Code** | Anthropic | ✅ | | `.claude/skills/` | 静的 + flag |
| **Codex** | OpenAI | | | `$CODEX_HOME/skills/` | 静的 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静的(アカウントの権限で決定) |
| **Cursor** | Anysphere | ⚠️ コードは存在するが使用不可 | | `.cursor/skills/` | 動的探索 |
| **Cursor** | Anysphere | | | `.cursor/skills/` | 動的探索 |
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静的 |
| **Hermes** | Nous Research | ✅ | | `.agent_context/skills/`(フォールバック) | 動的探索 |
| **Kimi** | Moonshot | ✅ | | `.kimi/skills/` | 動的探索 |
| **Kiro CLI** | Amazon | ✅ | | `.kiro/skills/` | 動的探索 |
| **OpenCode** | SST | ✅ | | `.opencode/skills/` | 動的探索 |
| **OpenClaw** | オープンソース | ✅ | | `.agent_context/skills/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
| **Hermes** | Nous Research | ✅ | | `.agent_context/skills/`(フォールバック) | 動的探索 |
| **Kimi** | Moonshot | ✅ | | `.kimi/skills/` | 動的探索 |
| **Kiro CLI** | Amazon | ✅ | | `.kiro/skills/` | 動的探索 |
| **OpenCode** | SST | ✅ | | `.opencode/skills/` | 動的探索 + variant |
| **OpenClaw** | オープンソース | ✅ | | `.agent_context/skills/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
| **Pi** | Inflection AI | ✅(セッションがファイルパス) | ❌ | `.pi/skills/` | 動的探索 |
## 各ツールの用途
@@ -34,11 +34,11 @@ Google が提供します。CLI バイナリ名は `agy` です。Google の Ant
### Claude Code
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、**11 個の中で MCP 構成を本当に読み取る唯一のツール**であり、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、MCP 構成を読み取り、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
### Codex
OpenAI が提供します。JSON-RPC 2.0 を使用し、ステートフルな能力がより強く、よりきめ細かい承認メカニズム(`exec_command` および `patch_apply` に対する手動承認)を備えています。**セッション再開のコードは存在しますが、現在は到達できません** — 再開が必要なら、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 server はユーザーのグローバル Cursor approvals に依存しません
### Gemini
@@ -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 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
### Kimi
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有しますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有し、MCP 構成も ACP `mcpServers` として渡されますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
### Kiro CLI
Amazon が提供します。`kiro-cli acp` を通じて stdio 上で ACP を使用します。セッション再開は ACP `session/load` で動作し、モデル選択は `session/set_model` で動作し、スキルはプロジェクトレベルのネイティブ探索のために `.kiro/skills/` にコピーされます。
Amazon が提供します。`kiro-cli acp` を通じて stdio 上で ACP を使用します。セッション再開は ACP `session/load` で動作し、MCP 構成は ACP `mcpServers` として渡され、モデル選択は `session/set_model` で動作し、スキルはプロジェクトレベルのネイティブ探索のために `.kiro/skills/` にコピーされます。
### OpenCode
SST が提供するオープンソースです。利用可能なモデルを動的に探索しますCLI の構成ファイルをスキャン)。セッション再開が動作します。**自分のモデルカタログをカスタマイズしたい、いじるのが好きなユーザーに適しています。**
SST が提供するオープンソースです。利用可能なモデルと model variant を動的に探索しますCLI の構成ファイルをスキャン)。セッション再開が動作し、エージェントの `mcp_config` フィールドを消費します。Multica は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン注入するため、エージェントの MCP server はタスク workdir の `opencode.json`(エージェントまたはユーザーが所有するファイル)を書き換えずに OpenCode に届きます。モデルが variant を公開している場合、Multica はそれをエージェントの thinking selector として表示し、選択値を `opencode run --variant` で OpenCode に渡します。**自分のモデルカタログをカスタマイズしたい、いじるのが好きなユーザーに適しています。**
### OpenClaw
オープンソースプロジェクトであり、CLI エージェントオーケストレーターです。**モデルはエージェント層にバインドされます**`openclaw agents add --model` — タスクごとに上書きできません。構成は厳格に制御されます: ユーザーは `--model` や `--system-prompt` を渡せず、エージェント登録時の構成が決定します。
オープンソースプロジェクトであり、CLI エージェントオーケストレーターです。MCP 構成は Multica のタスクごとの config wrapper 経由で書き込まれます。**モデルはエージェント層にバインドされます**`openclaw agents add --model` — タスクごとに上書きできません。構成は厳格に制御されます: ユーザーは `--model` や `--system-prompt` を渡せず、エージェント登録時の構成が決定します。
### Pi
@@ -82,18 +82,19 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
| 状態 | ツール | 意味 |
|---|---|---|
| ✅ 実際に動作 | 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 構成: Claude Code だけが実際に読み取る
## MCP 構成: ツールごとの対応
**12 個のツールのうち、`mcp_config` を実際に消費するのは Claude Code だけです**。残りの 11 個はこのフィールドを受け取りますが、**完全に無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
**12 個のツールのうち、`mcp_config` を実際に消費するのは 8 個です: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。残りの 4 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
接続方式はツールごとに異なります: Claude Code は `--mcp-config` と `--strict-mcp-config` で受け取り、Codex は daemon 管理の `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` を設定しても、Claude Code 以外のツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。現在、MCP 連携は Claude Code のみをカバーしています。
エージェント構成で `mcp_config` を設定しても、MCP 列に ✅ がないツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。MCP 連携はツールごとに実装されています。
</Callout>
## スキルファイルが置かれる場所

View File

@@ -15,9 +15,9 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 동적 탐색(`agy models`) |
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 정적 + flag |
| **Codex** | OpenAI | ⚠️ 코드는 존재하지만 도달 불가 | ✅ | `$CODEX_HOME/skills/` | 정적 |
| **Codex** | OpenAI | | ✅ | `$CODEX_HOME/skills/` | 정적 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 정적 (계정 권한으로 결정) |
| **Cursor** | Anysphere | ⚠️ 코드는 존재하지만 사용 불가 | | `.cursor/skills/` | 동적 탐색 |
| **Cursor** | Anysphere | | | `.cursor/skills/` | 동적 탐색 |
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 정적 |
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | 동적 탐색 |
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 동적 탐색 |
@@ -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에 의존하지 않습니다.
### Gemini
@@ -82,17 +82,16 @@ Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이
| 상태 | 도구 | 의미 |
|---|---|---|
| ✅ 실제로 동작 | 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 연동은 도구별로 구현됩니다.

View File

@@ -15,9 +15,9 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Dynamic discovery (`agy models`) |
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | Static + flag |
| **Codex** | OpenAI | ⚠️ Code exists but unreachable | ✅ | `$CODEX_HOME/skills/` | Static |
| **Codex** | OpenAI | | ✅ | `$CODEX_HOME/skills/` | Static |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
| **Cursor** | Anysphere | ⚠️ Code exists but unusable | | `.cursor/skills/` | Dynamic discovery |
| **Cursor** | Anysphere | | | `.cursor/skills/` | Dynamic discovery |
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | Static |
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | Dynamic discovery |
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | Dynamic discovery |
@@ -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.

View File

@@ -15,9 +15,9 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅(`--conversation <id>`| ❌ | `.agents/skills/` | 动态发现(`agy models`|
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静态 + flag |
| **Codex** | OpenAI | ⚠️ 代码存在但不可达 | ✅ | `$CODEX_HOME/skills/` | 静态 |
| **Codex** | OpenAI | | ✅ | `$CODEX_HOME/skills/` | 静态 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
| **Cursor** | Anysphere | ⚠️ 代码存在但不可用 | | `.cursor/skills/` | 动态发现 |
| **Cursor** | Anysphere | | | `.cursor/skills/` | 动态发现 |
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静态 |
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` fallback| 动态发现 |
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 动态发现 |
@@ -38,7 +38,7 @@ Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用
### Codex
OpenAI 出品。使用 JSON-RPC 2.0 协议状态化更强approve 机制更细(手动批准 `exec_command` 和 `patch_apply`。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话恢复代码存在但当前不可达**——如果你需要 resume选 Claude Code 或 ACP 系列
OpenAI 出品。使用 JSON-RPC 2.0 协议状态化更强approve 机制更细(手动批准 `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 的事件流里不回传 session ID所以你传的 resume 值永远无效。如果要 resume选别的
Anysphere 出品Cursor 编辑器的 CLI 对应物。**会话恢复可用**——当前 Cursor Agent 的 stream-json 事件会返回 `session_id`Multica 会在下一次运行时通过 `--resume <id>` 传回去。MCP 配置会写入任务工作区的 `.cursor/mcp.json`Cursor 的项目 approval 文件写在单次任务的 `CURSOR_DATA_DIR` 下,因此托管的 MCP server 不依赖用户全局 Cursor approvals
### Gemini
@@ -82,17 +82,16 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| 状态 | 工具 | 含义 |
|---|---|---|
| ✅ 真用 | Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id会从上次上下文接着继续 |
| ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到Codex 静默回落、Cursor session id 不回传)—— **当作不支持** |
| ✅ 真用 | Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id会从上次上下文接着继续 |
| ❌ 无 | Gemini | CLI 无 resume 机制 |
**对你的决策**:如果工作流需要智能体在多次任务之间保持上下文(失败重试、手动重跑、对话式迭代),只选 ✅ 那一行的工具。
## MCP 配置:按工具不同
**12 款工具里有 7 款实际消费 `mcp_config`Claude Code、Codex、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 5 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
**12 款工具里有 8 款实际消费 `mcp_config`Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 4 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
各工具的接入方式不同Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`Hermes、Kimi、Kiro CLI 通过 ACP `mcpServers` 接收OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
各工具的接入方式不同Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`Cursor 会写入 `.cursor/mcp.json`,并把项目 approval 写到单次任务的 `CURSOR_DATA_DIR`Hermes、Kimi、Kiro CLI 通过 ACP `mcpServers` 接收OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
<Callout type="warning">
如果你在智能体配置里设置了 `mcp_config`,但选了矩阵 MCP 列没有标 ✅ 的工具,你的 MCP server 对这个智能体**没有效果**。MCP 集成是按工具实现的。

View File

@@ -162,6 +162,10 @@ multica setup self-host \
--app-url https://<your-domain>
```
<Callout type="info">
フラグより環境変数を使いたい場合は、対応するフラグを省略すると `setup self-host` が `MULTICA_SERVER_URL` と `MULTICA_APP_URL` を読み取ります(両方設定した場合はフラグが優先されます)。`MULTICA_SERVER_URL` は[環境変数](/environment-variables)で示される `ws://…/ws` というデーモン形式も受け付け、HTTP ベース URL に正規化します。
</Callout>
単一のホスト名でフロントエンドとバックエンドの両方を前段に置く(デーモンと Web アプリの両方に必要な WebSocket サポートを含む)最小限の Caddyfile は次のとおりです。
```nginx

View File

@@ -162,6 +162,10 @@ multica setup self-host \
--app-url https://<your-domain>
```
<Callout type="info">
플래그 대신 환경 변수를 선호한다면, 해당 플래그를 생략할 때 `setup self-host`가 `MULTICA_SERVER_URL`과 `MULTICA_APP_URL`을 읽습니다(둘 다 설정하면 플래그가 우선합니다). `MULTICA_SERVER_URL`은 [환경 변수](/environment-variables)에 나오는 `ws://…/ws` 데몬 형식도 허용하며 HTTP 기본 URL로 정규화합니다.
</Callout>
단일 호스트네임에서 프런트엔드와 백엔드를 모두 앞단에 두는(데몬과 웹 앱 모두에 필요한 WebSocket 지원 포함) 최소 Caddyfile은 다음과 같습니다.
```nginx

View File

@@ -163,6 +163,10 @@ multica setup self-host \
--app-url https://<your-domain>
```
<Callout type="info">
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:
```nginx

View File

@@ -162,6 +162,10 @@ multica setup self-host \
--app-url https://<你的域名>
```
<Callout type="info">
更习惯用环境变量?省略对应 flag 时,`setup self-host` 会读取 `MULTICA_SERVER_URL` 和 `MULTICA_APP_URL`(同时设置时 flag 优先)。`MULTICA_SERVER_URL` 也接受[环境变量](/environment-variables)里那种 `ws://…/ws` 的 daemon 写法,并自动归一化为 HTTP 地址。
</Callout>
最小可用的 Caddyfile单域名同时挂前后端带 WebSocket 转发daemon 和网页端都依赖):
```nginx

View File

@@ -12,7 +12,7 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica は 2 つのスキルソースに対応しています。
- **ワークスペーススキル** — Multica のクラウドに保存されます。エージェントに取り付けると、タスク実行時にあなたのデーモンへ同期されます。これが**チーム全体でスキルを共有する標準的な方法**です。
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。デーモンは**2 つのルートを優先順位順に**確認します。まずランタイム自身のスキルディレクトリ(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`)、次にツール横断の汎用ディレクトリ `~/.agents/skills/`Codex や Gemini CLI などのエコシステムが共有する場所)です。同じスキル名が両方に存在する場合は**プロバイダー専用ディレクトリが優先**されるため、汎用ルートは*追加の*スキルを表示するだけで、既存スキルの解決結果を変えることはありません
ほとんどの場合は**ワークスペーススキル**が望ましいでしょう。一度インポートすれば、すべてのチームメイトのエージェントが使えるからです。ローカルスキルは、まずローカルでテストしたい場合や、内容に機密性の高いローカル資料が含まれる場合に適しています。
@@ -54,7 +54,7 @@ GitHub や ClawHub からインポートしたスキルには、スクリプト
- **スキル** = 構造化された**ナレッジパック**(静的なコンテンツ + 指示)。エージェントはスキルを読んで「問題 X を見たら、こう考えてこう行動する」を学びます。
- **MCP**Model Context Protocol= **ツールチャネル**。エージェントは MCP を使って外部サービス(データベース、ファイルシステム、サードパーティ APIに接続し、それらを**呼び出します**。
この 2 つは相互補完的です。現在の Multica では、MCP サポート**実際に使うのは Claude Code だけ**です — 他のツールは MCP 設定を受け取りはしますが、実際には使いません。MCP 専用のセクションは今後のリリースで追加される予定です。
この 2 つは相互補完的です。現在の Multica では、MCP サポート**ツールごとに実装されています**: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw は `mcp_config` を使用し、他のツールはこのフィールドを受け取っても実際には使いません。MCP 専用のセクションは今後のリリースで追加される予定です。
---

View File

@@ -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 전용 섹션은 추후 릴리스에서 추가될 예정입니다.
---

View File

@@ -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.
---

View File

@@ -12,7 +12,7 @@ Skill 是给 [智能体](/agents) 的**专业知识包**——一个 `SKILL.md`
Multica 支持两种 Skill 来源:
- **工作区 Skillworkspace skill** —— 存在 Multica 云端。挂到智能体后,任务执行时自动同步到你本机的守护进程。这是**团队共享 Skill 的标准方式**。
- **本机 Skilllocal skill** —— 直接存在你本机的某个目录里(每款 AI 编程工具有约定的默认路径,比如 Claude Code 的 `~/.claude/skills/`。[守护进程](/daemon-runtimes) 按你的请求扫描本机,发现后由你手动选入工作区。
- **本机 Skilllocal skill** —— 直接存在你本机的某个目录里。[守护进程](/daemon-runtimes) 按你的请求扫描本机,发现后由你手动选入工作区。守护进程会**按优先级扫描两个根目录**:先扫该运行时自己的 skill 目录(每款 AI 编程工具有约定的默认路径,比如 Claude Code 的 `~/.claude/skills/`),再扫跨工具通用目录 `~/.agents/skills/`——这是 Codex、Gemini CLI 等生态共用的位置。当同名 skill 在两处都存在时,**provider 专用目录优先**,所以通用根目录只会*额外*带出新的 skill绝不会改变已有 skill 的解析结果
大多数情况用**工作区 Skill**:导入一次,团队所有成员的智能体都能用。本机 Skill 适合先在本地测试、或涉及敏感本地内容的场景。
@@ -54,7 +54,7 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
- **Skill** = 结构化的**知识包**(静态内容 + 指令)。智能体读 Skill 来学"遇到 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 的专题会在后续版本展开。
---

View File

@@ -105,8 +105,7 @@ Multica はタスク中にセッション ID を**2 回**固定します: 開始
ただし、**実際にどの AI コーディングツールがこれをサポートするか**は大きく異なります。
- ✅ **実際にサポート** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ⚠️ **コードはあるが使用不可** — Codex, Cursor
- ✅ **実際にサポート** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ❌ **サポートなし** — Gemini
[プロバイダー対応表 → セッション再開](/providers#session-resumption-who-really-supports-it)を参照してください。

View File

@@ -105,8 +105,7 @@ Multica는 작업 중에 세션 ID를 **두 번** 고정합니다: 시작 시
하지만 **실제로 어떤 AI 코딩 도구가 이를 지원하는지**는 크게 다릅니다:
- ✅ **실제 지원** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ⚠️ **코드는 있지만 사용 불가** — Codex, Cursor
- ✅ **실제 지원** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ❌ **지원 안 함** — Gemini
[제공자 매트릭스 → 세션 재개](/providers#session-resumption-who-really-supports-it)를 참고하세요.

View File

@@ -105,8 +105,7 @@ Multica pins the session ID **twice** during a task: once at the start (when the
But **which AI coding tools actually support this** varies a lot:
- ✅ **Real support** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ⚠️ **Code exists but unusable** — Codex, Cursor
- ✅ **Real support** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ❌ **No support** — Gemini
See [Providers Matrix → Session resumption](/providers#session-resumption-who-really-supports-it).

View File

@@ -107,8 +107,7 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始AI
但**哪些 AI 编程工具真的支持**差别很大:
- ✅ **真支持**——Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
- ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor
- ✅ **真支持**——Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
- ❌ **不支持**——Gemini
详见 [Providers Matrix → 会话恢复](/providers#会话恢复谁真的支持)。

View File

@@ -1,7 +1,7 @@
/**
* Agent Runs sheet — presented as a formSheet by the parent Stack. Two
* sections: Active (queued/dispatched/running, created_at desc) and Past
* (failed → cancelled → completed, completed_at desc within each). Empty
* (completed_at desc, status rank as tiebreaker). Empty
* sections hide entirely.
*
* Both entry points (the in-card AgentActivityRow and the Stack-header
@@ -58,9 +58,9 @@ export default function IssueRunsRoute() {
t.status === "cancelled",
);
return filtered.sort((a, b) => {
const ord = PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
if (ord !== 0) return ord;
return (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
const timeDiff = (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
if (timeDiff !== 0) return timeDiff;
return PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
});
}, [allTasks]);

View File

@@ -47,6 +47,7 @@ const PRIORITY_LABEL: Record<IssuePriority, string> = {
// Mirrors useTypeLabels in packages/views/inbox/components/inbox-detail-label.tsx
const TYPE_LABEL: Record<InboxItemType, string> = {
issue_assigned: "Assigned",
issue_subscribed: "Subscribed",
unassigned: "Unassigned",
assignee_changed: "Reassigned",
status_changed: "Status changed",

View File

@@ -366,5 +366,6 @@ export function commentToTimelineEntry(comment: Comment): TimelineEntry {
resolved_at: comment.resolved_at,
resolved_by_type: comment.resolved_by_type,
resolved_by_id: comment.resolved_by_id,
source_task_id: comment.source_task_id,
};
}

View File

@@ -58,6 +58,7 @@ export const AttachmentSchema: z.ZodType<Attachment> = z.object({
filename: z.string(),
url: z.string(),
download_url: z.string().default(""),
markdown_url: z.string().default(""),
content_type: z.string().default(""),
size_bytes: z.number().default(0),
created_at: z.string().default(""),
@@ -88,6 +89,7 @@ export const CommentSchema = z.object({
resolved_at: z.string().nullable().default(null),
resolved_by_type: z.string().nullable().default(null),
resolved_by_id: z.string().nullable().default(null),
source_task_id: z.string().nullable().optional(),
}).loose() as unknown as z.ZodType<Comment>;
export const EMPTY_COMMENT: Comment = {
@@ -306,6 +308,7 @@ export const TaskMessagePayloadSchema: z.ZodType<TaskMessagePayload> = z.object(
content: z.string().optional(),
input: z.record(z.string(), z.unknown()).optional(),
output: z.string().optional(),
created_at: z.string().optional(),
}).loose();
export const TaskMessageListSchema = z.array(TaskMessagePayloadSchema).default([]);

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { InboxItem } from "@multica/core/types";
import { deduplicateInboxItems } from "./inbox-display";
function item(overrides: Partial<InboxItem>): InboxItem {
return {
id: "inbox-1",
workspace_id: "workspace-1",
recipient_type: "member",
recipient_id: "member-1",
actor_type: "agent",
actor_id: "agent-1",
type: "new_comment",
severity: "info",
issue_id: "issue-1",
title: "Issue title",
body: null,
issue_status: null,
read: false,
archived: false,
created_at: "2026-06-15T08:00:00Z",
details: null,
...overrides,
};
}
describe("deduplicateInboxItems", () => {
it("keeps the newest issue row while preserving an older comment anchor", () => {
const merged = deduplicateInboxItems([
item({
id: "comment-notification",
created_at: "2026-06-15T08:00:00Z",
details: { comment_id: "comment-1" },
}),
item({
id: "status-notification",
type: "status_changed",
created_at: "2026-06-15T08:01:00Z",
details: { from: "in_progress", to: "in_review" },
}),
]);
expect(merged).toHaveLength(1);
expect(merged[0]).toMatchObject({
id: "status-notification",
type: "status_changed",
details: {
from: "in_progress",
to: "in_review",
comment_id: "comment-1",
},
});
});
});

View File

@@ -62,7 +62,9 @@ export function getInboxDisplayTitle(item: InboxItem): string {
* 2. Group by `issue_id` (fall back to `id` for items with no issue
* attached — e.g. quick_create_failed).
* 3. In each group, keep the newest by `created_at`.
* 4. Sort the result newest-first.
* 4. Preserve the newest grouped `comment_id` anchor when the newest row
* is a later status/metadata event for the same issue.
* 5. Sort the result newest-first.
*/
export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
const active = items.filter((i) => !i.archived);
@@ -79,7 +81,22 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
if (group[0]) merged.push(group[0]);
const newest = group[0];
if (!newest) continue;
const commentId =
newest.details?.comment_id ??
group.find((item) => item.details?.comment_id)?.details?.comment_id;
if (commentId && newest.details?.comment_id !== commentId) {
merged.push({
...newest,
details: { ...(newest.details ?? {}), comment_id: commentId },
});
continue;
}
merged.push(newest);
}
return merged.sort(
(a, b) =>

View File

@@ -125,11 +125,18 @@ function LoginPageContent() {
router.push(await resolveLoggedInDestination(qc, onboarded, list));
};
// Build Google OAuth state: encode platform + next URL so the callback
// can redirect to the right place after login.
// Build Google OAuth state: encode platform, next URL, and CLI callback
// params so the callback can redirect to the right place after login.
// CLI callback/state must survive the Google OAuth round-trip so the
// post-login callback page can redirect the JWT back to the CLI's local
// HTTP listener (critical for headless / WSL2 environments).
const googleState = [
platform === "desktop" ? "platform:desktop" : "",
nextUrl ? `next:${nextUrl}` : "",
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
? `cli_callback:${encodeURIComponent(cliCallbackRaw)}`
: "",
cliState ? `cli_state:${encodeURIComponent(cliState)}` : "",
]
.filter(Boolean)
.join(",") || undefined;

View File

@@ -197,6 +197,104 @@ describe("CallbackPage", () => {
});
});
it("redirects to CLI callback with token when state contains valid cli_callback", async () => {
const { api: mockedApi } = await import("@multica/core/api");
const mockGoogleLogin = mockedApi.googleLogin as ReturnType<typeof vi.fn>;
const hrefSetter = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
writable: true,
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
});
try {
mockSearchParams.set(
"state",
"cli_callback:http://127.0.0.1:46233/callback,cli_state:abc123",
);
mockGoogleLogin.mockResolvedValue({ token: "cli-jwt-token" });
render(<CallbackPage />);
await waitFor(() => {
expect(mockGoogleLogin).toHaveBeenCalledWith(
"test-code",
expect.stringContaining("/auth/callback"),
);
});
await waitFor(() => {
expect(hrefSetter).toHaveBeenCalledWith(
"http://127.0.0.1:46233/callback?token=cli-jwt-token&state=abc123",
);
});
} finally {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
}
});
it("falls through to normal web flow when state contains invalid cli_callback", async () => {
mockSearchParams.set("state", "cli_callback:https://evil.com/callback");
mockLoginWithGoogle.mockResolvedValue(makeUser());
mockListWorkspaces.mockResolvedValue([]);
mockListMyInvitations.mockResolvedValue([]);
render(<CallbackPage />);
await waitFor(() => {
// Normal web flow: loginWithGoogle is called (not googleLogin)
expect(mockLoginWithGoogle).toHaveBeenCalled();
});
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
});
it("redirects to CLI callback even when state also contains platform:desktop", async () => {
// cli_callback takes precedence over platform:desktop — the CLI flow
// is a specific user intent that should not be derailed by desktop flag.
const { api: mockedApi } = await import("@multica/core/api");
const mockGoogleLogin = mockedApi.googleLogin as ReturnType<typeof vi.fn>;
const hrefSetter = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
writable: true,
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
});
try {
mockSearchParams.set(
"state",
"platform:desktop,cli_callback:http://localhost:12345/callback,cli_state:mystate",
);
mockGoogleLogin.mockResolvedValue({ token: "mixed-jwt" });
render(<CallbackPage />);
await waitFor(() => {
expect(mockGoogleLogin).toHaveBeenCalled();
});
await waitFor(() => {
expect(hrefSetter).toHaveBeenCalledWith(
"http://localhost:12345/callback?token=mixed-jwt&state=mystate",
);
});
} finally {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
}
});
it("onboarded users with missing source land in the workspace; the source-backfill modal is mounted there", async () => {
// Source attribution backfill is now an in-workspace modal — see
// `<SourceBackfillModal />` mounted inside `DashboardLayout`. The

View File

@@ -7,6 +7,7 @@ import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths, resolvePostAuthDestination } from "@multica/core/paths";
import { api } from "@multica/core/api";
import { validateCliCallback, redirectToCliCallback } from "@multica/views/auth";
import {
Card,
CardHeader,
@@ -46,9 +47,39 @@ function CallbackContent() {
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
// CLI callback params — carried across the Google OAuth round-trip so
// headless/WSL2 `multica login` can receive the JWT after browser-based
// Google auth completes.
const cliCallbackPart = stateParts.find((p) => p.startsWith("cli_callback:"));
const cliStatePart = stateParts.find((p) => p.startsWith("cli_state:"));
const cliCallbackRaw = cliCallbackPart
? decodeURIComponent(cliCallbackPart.slice("cli_callback:".length))
: null;
const cliState = cliStatePart
? decodeURIComponent(cliStatePart.slice("cli_state:".length))
: "";
const redirectUri = `${window.location.origin}/auth/callback`;
if (isDesktop) {
// Validate the CLI callback URL before redirecting — the state parameter
// passes through Google OAuth and must be treated as attacker-controlled.
const cliCallback =
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
? cliCallbackRaw
: null;
if (cliCallback) {
// CLI login flow: exchange the Google code for a JWT, then redirect the
// token back to the CLI's local HTTP listener (e.g. WSL2 host).
api
.googleLogin(code, redirectUri)
.then(({ token }) => {
redirectToCliCallback(cliCallback, token, cliState);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
} else if (isDesktop) {
// Desktop flow: exchange code for token, then redirect via deep link
api
.googleLogin(code, redirectUri)

View File

@@ -0,0 +1,58 @@
"use client";
import { useEffect } from "react";
import { captureException } from "@multica/core/analytics";
/**
* Route-level error boundary for the web app. Next.js renders this (replacing
* the root layout) when an error escapes everything below it — the full-page
* white-screen case. React catches these before they reach window.onerror, so
* posthog-js's automatic exception capture never sees them; we report them
* explicitly here. Section-level failures are handled in place by
* `@multica/ui` ErrorBoundary and don't reach this far.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
captureException(error, { source: "global-error", digest: error.digest });
}, [error]);
return (
<html>
<body
style={{
display: "flex",
minHeight: "100vh",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
}}
>
<div style={{ maxWidth: 420, textAlign: "center" }}>
<h1 style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</h1>
<p style={{ marginTop: 8, color: "#666" }}>
The page hit an unexpected error. Try reloading.
</p>
<button
type="button"
onClick={reset}
style={{
marginTop: 16,
padding: "8px 16px",
borderRadius: 6,
border: "1px solid #ccc",
cursor: "pointer",
}}
>
Reload
</button>
</div>
</body>
</html>
);
}

View File

@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
@@ -116,6 +117,24 @@ export default async function RootLayout({
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
>
<body className="h-full overflow-hidden">
{/*
react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click
any element to copy its source path + line + component stack for pasting
to an AI. Opt-in per developer: only loads when VITE_REACT_GRAB is set in
a local, gitignored apps/web/.env.local — it never activates for anyone
else. Both guards are read server-side, so the <Script> is omitted from
the HTML entirely unless you opted in. The VITE_ prefix is shared with the
desktop renderer (apps/desktop/src/renderer/src/main.tsx), where Vite only
exposes VITE_-prefixed vars to client code, so one var name covers both
apps. See https://www.react-grab.com/
*/}
{process.env.NODE_ENV === "development" && process.env.VITE_REACT_GRAB && (
<Script
src="//unpkg.com/react-grab/dist/index.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
)}
<ThemeProvider>
<WebProviders locale={locale} resources={resources}>
{children}

View File

@@ -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",
],
},
{
version: "0.3.18",
date: "2026-06-08",

View File

@@ -268,6 +268,190 @@ export function createJaDict(allowSignup: boolean): LandingDict {
fixes: "バグ修正",
},
entries: [
{
version: "0.3.27",
date: "2026-06-19",
title: "デスクトップのサイドバーとピン表示を改善",
changes: [],
improvements: [
"デスクトップのサイドバーがよりなめらかに開閉し、ナビゲーション操作もしやすくなりました。",
"サイドバーに固定した Issue はタイトル中心の表示になり、見分けやすくなりました。",
"コントリビューターとエージェント向けの作業ガイドが短く分かりやすくなり、リリース前の確認がしやすくなりました。",
],
fixes: [
"固定した Issue やプロジェクトでは、親ナビゲーション項目まで同時に選択表示されなくなりました。",
"Issue やプロジェクト詳細を開くとき、または保存済みレイアウトを復元するときに、詳細サイドバーが意図せず動かなくなりました。",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "スキル、オートパイロット、チャットでのエージェント作業をより信頼性高く",
changes: [],
features: [
"開発者のマシンにあるローカルスキルライブラリを自動で見つけ、エージェント実行で使いやすくなりました。",
"オートパイロットに既定の購読者を設定でき、新しい Issue の確認に必要なチームメイトを含めやすくなりました。",
"チャット添付ファイルは現在のワークスペースに結び付き、送信中も会話を続けやすくなりました。",
"失敗したエージェントコメントを Issue タイムラインから直接再試行できます。",
],
improvements: [
"同じモデル名が複数のプロバイダーから提供される場合も、利用状況がより正確に集計されます。",
"過去の Codex 利用状況を補完でき、利用履歴がより完全になります。",
"複数のワークスペース場所にまたがるランタイムのストレージ使用量が分かりやすくなりました。",
"バックグラウンドタスクの案内とリリース前チェックがより厳密になり、リスクのある変更を早く見つけやすくなりました。",
],
fixes: [
"チャットとコメント内の Issue メンションチップが枠内に収まり、周辺テキストと重なりません。",
"ワークスペースリンクが正しいデプロイ先ホストをより安定して使います。",
"オートパイロット実行フォルダーは、ターミナル実行が終わると片付けられます。",
"デスクトップビルドはコミット由来のバージョン名を正しく扱います。",
"Tencent CodeBuddy に正しいプロバイダーロゴが表示されます。",
"デーモンのタスク受け取り応答が小さくなり、転送が速くなりました。",
],
},
{
version: "0.3.24",
date: "2026-06-17",
title: "カスタムランタイム",
changes: [],
features: [
"チームはカスタムランタイムで、エージェントに合うローカルツールとモデルを使えます。",
"コマンドラインでエージェントを作成または更新するときに思考レベルを選べます。",
],
improvements: [
"ランタイムプロファイルの同期が速くなり、現在の環境に合うものが優先されます。",
"クライアントのエラーやフリーズ報告の重複が減りました。",
"Issue コメントのトリガープレビューが読みやすくなりました。",
],
fixes: [
"Office 365 メールの代替送信がより安定しました。",
"GitHub のインストール情報と CI 待機表示がより安定しました。",
"Codex サービスが終了したときはすばやく失敗します。",
"自己修復ランタイムを再び削除でき、合わないモデル選択は整理されます。",
"不明な Issue アイコンと通常のファイル名リンクを安全に扱います。",
],
},
{
version: "0.3.23",
date: "2026-06-16",
title: "Issue の日付フィルターとエージェント実行の安定性向上",
changes: [],
features: [
"Issue を作成日または更新日で絞り込めるようになり、クイック期間やカスタム日付を選べます。",
"コマンドラインからランタイムを削除できるようになり、既定の動作はより安全で、関連データの扱いも明示的に選べます。",
"Lark 接続がネットワークプロキシを利用できるようになり、制限のあるネットワーク環境でも接続しやすくなりました。",
],
improvements: [
"Web とデスクトップのエラー、フリーズ、クラッシュ報告が分かりやすくなり、原因調査がしやすくなりました。",
"プロジェクト行、コメントのプレビュー、コメント作成まわりの操作がより一貫して使いやすくなりました。",
],
fixes: [
"返信やコメント編集を保存する前に、どのエージェントやスクワッドが動き始めるかをより正確に確認できます。",
"コメント内の通常の Issue 番号は、明示的にリンクしない限り通常のテキストのまま残ります。",
"コマンドラインログインで Google ログインを使った場合も、ブラウザー認証後に正しくコマンドラインへ戻ります。",
"チャットのファイルアップロードはアクティブなエージェントの準備ができてから有効になり、読み込み中の失敗を防ぎます。",
"ホバーできないタッチ端末でも、実行履歴の操作ボタンが表示されます。",
"エージェントがコメントを投稿するとき、担当者、プロジェクト、その他の項目がコマンド形式の問題で抜ける可能性が減りました。",
],
},
{
version: "0.3.22",
date: "2026-06-15",
title: "より速いリスト体験、使いやすい実行設定、安全な Issue 編集",
changes: [],
features: [
"エージェント、オートパイロット、プロジェクト、ランタイム、スキル、スクワッドのリストがより速く一貫した体験になり、行表示、絞り込み、選択、操作が分かりやすくなりました。",
"コマンドラインからワークスペースのリポジトリを管理できるようになり、ローカルエージェントがプロジェクトのリポジトリ情報を受け取りやすくなりました。",
"Cursor と OpenClaw の設定が簡単になりました。Cursor の接続設定は Multica に任せられ、OpenClaw は既存のゲートウェイにも接続できます。",
"コメントを編集するとき、保存前にどのエージェントやスクワッドが動き始めるかをプレビューして制御できます。",
],
improvements: [
"デスクトップの復旧案内にページの文脈が増え、固まったウィンドウを報告するときに状況を伝えやすくなりました。",
"長い Issue と受信箱ビューでは、別の場所へ移動して戻ったときにスクロール位置やコメント位置がより安定して保たれます。",
"Cursor の Composer、キャッシュ入力、新しい Cursor エージェント出力で、使用量と請求情報がより分かりやすく表示されます。",
],
fixes: [
"Issue の添付ファイル、本文内画像、ファイルカードは、Web、デスクトップ、モバイル、トークン付き共有リンクでより安定して開けるようになりました。",
"エディターと読み取り専用の Issue 内容で、ドル金額とメールリンクがより安定して扱われます。",
"デスクトップの Cmd+W は、まず現在のタブを閉じ、閉じられるタブがない場合にウィンドウを閉じます。",
"セルフホストの Docker Compose アップロードと既定設定は失敗しにくくなり、足りない設定値も早めに見つかります。",
"実行に必要な認証情報が無効な場合、エージェントタスクは安全に停止するようになりました。",
],
},
{
version: "0.3.21",
date: "2026-06-12",
title: "CodeBuddy Runtime",
changes: [],
features: [
"CodeBuddy でローカルの Multica エージェントを動かせるようになり、利用できるモデルと実行の強さが自動で表示されます。",
"クイック作成した Issue では、下書きでアップロードしたファイルが最終的な Issue まで保持されます。",
],
improvements: [
"スキル取り込みの競合が分かりやすくなり、ロックされたスキルには内部 ID ではなくメンバー名が表示され、単体の上書きも 1 クリックで完了します。",
"デスクトップの復旧案内は、まず何が起きたかを説明し、固まったウィンドウを報告するときに含める情報も分かりやすくなりました。",
"登録日時でメンバーを並べ替えたり絞り込んだりする画面が、より速く読み込まれるようになりました。",
],
fixes: [
"チャットの送信、停止、送信失敗からの復旧時に、メッセージと下書きがより安定して同期されます。",
"Lark アカウント連携は、すでにサインイン済みのユーザーでも安定して完了し、サインイン後も連携ページに戻ります。",
"ローカルエージェントの実行は、タスクフォルダの準備が終わる前に開始済みとして表示されなくなりました。",
],
},
{
version: "0.3.20",
date: "2026-06-11",
title: "スキルのインポート、実行履歴、より安定したエージェント",
changes: [],
features: [
"スキルのインポート時に同じスキルがすでにある場合、停止、置き換え、別名で保存、スキップを選べるようになりました。",
"インポート結果では、追加、更新、スキップ、競合、失敗したスキルがわかりやすく表示されます。",
],
improvements: [
"Web とモバイルの実行履歴は新しい過去実行を先に表示するため、最近の進捗を追いやすくなりました。",
"変更履歴の内容を整理し、最新のリリースノートが正しいバージョンにまとまるようにしました。",
],
fixes: [
"イシューの返信は到着した順番のまま表示され、遅れて届いたエージェント返信が途中に割り込まなくなりました。",
"保存済みセッションが失われた場合でも、エージェントは新しく開始して復旧でき、以後のメンションで失敗し続けません。",
"新しい作業フォルダーからイシューを再開すると、古いフォルダーにだけ存在したセッションではなく新しいセッションで始まります。",
],
},
{
version: "0.3.19",
date: "2026-06-10",
title: "より安全なコメントトリガー、安定したエージェントと添付ファイル",
changes: [],
features: [
"コメント入力欄では、送信前にどのエージェントやスクワッドが動き始めるかを確認でき、誤って実行することを避けられます。",
"実行記録に時刻が表示されるようになり、エージェントの進捗や引き継ぎを振り返りやすくなりました。",
"オートパイロット詳細ページで、誰が作成したかを確認できるようになりました。",
"Claude Fable 5 が Multica の対応モデルと料金一覧に加わりました。",
"イシューの会話で特定の返信を解決として残せるようになり、長いスレッドを閉じても結論を確認しやすくなりました。",
"Lark と Feishu の会話では、Multica が返信を準備している間に入力中のリアクションを表示し、返信前に自動で消します。",
"エージェント実行は、誰がそのタスクを始めたかを把握できるようになり、引き継ぎ、監査、プライバシーに配慮した動作がより正確になります。",
"OpenClaw ユーザーは、ローカル設定から独自のアプリ場所とデータフォルダーを指定できます。",
],
improvements: [
"コメントトリガーの表示はより控えめで読みやすく、長いエージェント名でも混み合いにくくなりました。",
"WSL2 など Multica の外でデーモンが管理されている場合、デスクトップは開始と停止の操作を無効にします。",
"イシュー上部のアクティブなエージェント表示は、実行中だけ動き、待機中は待機状態を明確に示すため、読み取りやすくなりました。",
"CLI は、よくあるエラー、サインインの問題、プロジェクト設定の値について、よりわかりやすく案内します。",
],
fixes: [
"イシュー説明内の画像とファイルは、Web とデスクトップのどちらでも再読み込み後に表示され続けます。",
"各イシュー会話スレッドは解決済みの回答を 1 つだけ保持するため、結論を置き換えたときの表示が全員でそろいます。",
"リアルタイム接続が復帰したあと、イシュー画面はデータを更新し、古いタイムラインが残りにくくなりました。",
"エージェントタスクの開始者履歴が、古いタスク記録でもより信頼できるようになりました。",
"スクロール中の固定イシューコメントの境界がよりきれいに表示されます。",
"新しく投稿された添付ファイルは安定した非公開ダウンロードリンクを使うため、一時的なアップロードリンクが期限切れになっても画像やファイルを表示できます。",
"新規イシューから始まったオートパイロット実行は、割り当てられたタスクが完了できない場合に正しく失敗し、進行中のまま残りません。",
"受信箱からコメントリンクを開いたとき、デスクトップ画面全体ではなくイシューのタイムラインだけがスクロールします。",
"Cursor と Codex のセッションは最終結果後によりきれいに終了し、完了状態と最後のテレメトリーを保ちます。",
"セルフホスト設定は指定済みのサーバー URL を尊重し、プロジェクト作成では汎用エラーではなく明確な検証エラーを返します。",
"前回のアップロード強化は添付ファイル体験と衝突したためロールバックし、添付ファイルへのアクセスを安定させました。",
],
},
{
version: "0.3.18",
date: "2026-06-08",

View File

@@ -267,6 +267,190 @@ export function createKoDict(allowSignup: boolean): LandingDict {
fixes: "버그 수정",
},
entries: [
{
version: "0.3.27",
date: "2026-06-19",
title: "더 부드러운 데스크톱 사이드바와 깔끔한 고정 항목",
changes: [],
improvements: [
"데스크톱 사이드바가 더 부드럽게 열리고 닫히며, 자주 쓰는 탐색 컨트롤도 계속 쉽게 사용할 수 있습니다.",
"사이드바에 고정한 Issue는 제목 중심으로 표시되어 더 빠르게 구분할 수 있습니다.",
"기여자와 에이전트 작업 안내가 더 짧고 명확해져 릴리스 전 확인이 쉬워졌습니다.",
],
fixes: [
"고정한 Issue와 프로젝트에서 부모 탐색 항목까지 함께 선택 표시되는 문제가 사라졌습니다.",
"Issue 또는 프로젝트 상세 화면에 들어가거나 저장된 레이아웃을 복원할 때 상세 사이드바가 의도치 않게 움직이지 않습니다.",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "스킬, 오토파일럿, 채팅 전반의 에이전트 작업 안정성 강화",
changes: [],
features: [
"개발자 기기의 로컬 스킬 라이브러리를 자동으로 찾아 에이전트 실행에서 더 쉽게 사용할 수 있습니다.",
"오토파일럿에 기본 구독자를 설정할 수 있어 새 Issue를 만들 때 필요한 팀원을 함께 포함하기 쉽습니다.",
"채팅 첨부 파일이 현재 워크스페이스에 연결되고, 메시지를 보내는 동안에도 대화를 계속하기 쉬워졌습니다.",
"실패한 에이전트 댓글을 Issue 타임라인에서 바로 다시 시도할 수 있습니다.",
],
improvements: [
"같은 모델 이름이 여러 제공자에서 제공될 때도 사용량 보고가 더 정확합니다.",
"이전 Codex 사용 기록을 보완해 사용량 이력을 더 완전하게 만들 수 있습니다.",
"여러 워크스페이스 위치에 걸친 런타임 저장 공간 사용량을 더 명확하게 보여 줍니다.",
"백그라운드 작업 안내와 릴리스 전 검사가 더 엄격해져 위험한 변경을 더 일찍 찾을 수 있습니다.",
],
fixes: [
"채팅과 댓글의 Issue 멘션 칩이 영역 안에 맞게 표시되고 주변 텍스트와 겹치지 않습니다.",
"워크스페이스 링크가 올바른 배포 호스트를 더 안정적으로 사용합니다.",
"오토파일럿 실행 폴더는 터미널 실행이 끝난 뒤 정리됩니다.",
"데스크톱 빌드는 커밋 기반 버전 이름을 올바르게 처리합니다.",
"Tencent CodeBuddy에 올바른 제공자 로고가 표시됩니다.",
"데몬의 작업 수신 응답이 더 작아져 전송이 빨라졌습니다.",
],
},
{
version: "0.3.24",
date: "2026-06-17",
title: "사용자 지정 런타임",
changes: [],
features: [
"팀은 사용자 지정 런타임으로 에이전트에 맞는 로컬 도구와 모델을 사용할 수 있습니다.",
"명령줄에서 에이전트를 만들거나 업데이트할 때 사고 수준을 선택할 수 있습니다.",
],
improvements: [
"런타임 프로필이 더 빠르게 동기화되고 현재 환경에 맞게 우선 적용됩니다.",
"클라이언트 오류와 멈춤 보고의 중복이 줄었습니다.",
"Issue 댓글 트리거 미리보기가 더 읽기 쉬워졌습니다.",
],
fixes: [
"Office 365 메일의 대체 전송이 더 안정적입니다.",
"GitHub 설치 맥락과 CI 대기 상태 표시가 더 안정적입니다.",
"Codex 서비스가 종료되면 빠르게 실패합니다.",
"셀프 힐링 런타임을 다시 삭제할 수 있고, 맞지 않는 모델 선택은 정리됩니다.",
"알 수 없는 Issue 아이콘과 일반 파일 이름 링크 처리가 더 안전해졌습니다.",
],
},
{
version: "0.3.23",
date: "2026-06-16",
title: "Issue 날짜 필터와 더 안정적인 에이전트 실행",
changes: [],
features: [
"Issue를 생성일 또는 수정일 기준으로 필터링할 수 있으며, 빠른 기간과 사용자 지정 날짜를 선택할 수 있습니다.",
"명령줄에서 런타임을 삭제할 수 있고, 기본 동작은 더 안전하며 관련 데이터 처리도 명시적으로 선택할 수 있습니다.",
"Lark 연결이 네트워크 프록시를 사용할 수 있어 제한된 네트워크 환경에서도 더 안정적으로 연결됩니다.",
],
improvements: [
"웹과 데스크톱의 오류, 멈춤, 충돌 보고가 더 명확해져 문제를 조사하기 쉬워졌습니다.",
"프로젝트 행, 댓글 미리보기, 댓글 작성기가 더 일관되고 사용하기 쉬워졌습니다.",
],
fixes: [
"답글과 댓글 편집을 저장하기 전에 어떤 에이전트나 스쿼드가 실행될지 더 정확하게 미리 보여줍니다.",
"댓글의 일반 Issue 번호는 명시적으로 링크하지 않는 한 일반 텍스트로 유지됩니다.",
"명령줄 로그인에서 Google 로그인을 사용해도 브라우저 인증 후 명령줄로 올바르게 돌아옵니다.",
"채팅 파일 업로드는 활성 에이전트가 준비된 뒤에만 열려 로딩 중 실패를 줄입니다.",
"호버가 없는 터치 기기에서도 실행 기록 작업 버튼이 계속 보입니다.",
"에이전트가 댓글을 게시할 때 담당자, 프로젝트, 기타 필드가 명령 형식 문제로 누락될 가능성이 줄었습니다.",
],
},
{
version: "0.3.22",
date: "2026-06-15",
title: "더 빠른 목록 경험, 쉬운 실행 설정, 안전한 Issue 편집",
changes: [],
features: [
"에이전트, 오토파일럿, 프로젝트, 런타임, 스킬, 스쿼드의 목록이 더 빠르고 일관된 경험으로 바뀌어 행, 필터, 선택, 작업이 더 명확해졌습니다.",
"명령줄에서 워크스페이스 저장소를 관리할 수 있어 로컬 에이전트가 프로젝트 저장소 정보를 더 쉽게 가져올 수 있습니다.",
"Cursor와 OpenClaw 설정이 더 쉬워졌습니다. Cursor 연결 설정은 Multica가 관리할 수 있고, OpenClaw는 기존 게이트웨이에 연결할 수 있습니다.",
"댓글을 편집할 때 저장하기 전에 어떤 에이전트나 스쿼드가 실행될지 미리 보고 제어할 수 있습니다.",
],
improvements: [
"데스크톱 복구 안내에 페이지 맥락이 더 많이 포함되어 멈춘 창의 상황을 설명하기 쉬워졌습니다.",
"긴 Issue와 받은함 보기에서 다른 곳으로 이동했다가 돌아와도 스크롤 위치와 댓글 위치가 더 안정적으로 유지됩니다.",
"Cursor의 Composer, 캐시 입력, 새로운 Cursor 에이전트 출력에서 사용량과 청구 정보가 더 명확하게 표시됩니다.",
],
fixes: [
"Issue 첨부 파일, 본문 이미지, 파일 카드가 웹, 데스크톱, 모바일, 토큰 공유 링크에서 더 안정적으로 열립니다.",
"편집기와 읽기 전용 Issue 내용에서 달러 금액과 이메일 링크가 더 안정적으로 처리됩니다.",
"데스크톱 Cmd+W는 먼저 활성 탭을 닫고, 닫을 탭이 없을 때 창을 닫습니다.",
"셀프 호스트 Docker Compose 업로드와 기본 설정이 덜 실패하고, 빠진 설정값을 더 일찍 확인합니다.",
"실행에 필요한 인증 정보가 유효하지 않으면 에이전트 작업이 안전하게 중단됩니다.",
],
},
{
version: "0.3.21",
date: "2026-06-12",
title: "CodeBuddy Runtime",
changes: [],
features: [
"CodeBuddy로 로컬 Multica 에이전트를 실행할 수 있으며, 사용할 수 있는 모델과 실행 강도 선택지가 자동으로 표시됩니다.",
"빠르게 만든 Issue에서도 초안에서 올린 파일이 최종 Issue까지 함께 유지됩니다.",
],
improvements: [
"스킬 가져오기 충돌이 더 이해하기 쉬워졌습니다. 잠긴 스킬은 내부 ID 대신 멤버 이름을 보여주고, 단일 덮어쓰기도 한 번의 클릭으로 끝납니다.",
"데스크톱 복구 안내가 먼저 무슨 일이 있었는지 설명하고, 멈춘 창을 신고할 때 포함할 정보를 더 명확하게 보여줍니다.",
"가입 시간으로 멤버를 정렬하거나 필터링하는 화면이 더 빠르게 로드될 수 있습니다.",
],
fixes: [
"채팅을 보내거나 중지하거나 전송 실패에서 복구할 때 메시지와 초안이 더 안정적으로 동기화됩니다.",
"Lark 계정 연결은 이미 로그인한 사용자에게도 안정적으로 완료되며, 로그인 후에도 연결 페이지로 돌아옵니다.",
"로컬 에이전트 실행은 작업 폴더가 준비되기 전에 시작된 것으로 표시되지 않습니다.",
],
},
{
version: "0.3.20",
date: "2026-06-11",
title: "스킬 가져오기, 실행 기록, 더 안정적인 에이전트",
changes: [],
features: [
"스킬을 가져올 때 같은 스킬이 이미 있으면 중단, 교체, 이름을 바꿔 저장, 건너뛰기 중에서 선택할 수 있습니다.",
"가져오기 결과에서 추가, 업데이트, 건너뜀, 충돌, 실패한 스킬을 더 명확하게 확인할 수 있습니다.",
],
improvements: [
"웹과 모바일 실행 기록은 최신 과거 실행을 먼저 보여 주어 최근 진행 상황을 더 쉽게 확인할 수 있습니다.",
"변경 로그 콘텐츠를 정리해 최신 릴리스 노트가 올바른 버전에 묶이도록 했습니다.",
],
fixes: [
"이슈 스레드 답글은 도착한 순서대로 표시되어, 늦게 도착한 에이전트 답글이 중간에 끼어들지 않습니다.",
"저장된 세션이 사라져도 에이전트가 새로 시작해 복구할 수 있어, 이후 멘션마다 계속 실패하지 않습니다.",
"새 작업 폴더에서 이슈를 다시 시작할 때 이전 폴더에만 있던 세션을 재시도하지 않고 새 세션으로 시작합니다.",
],
},
{
version: "0.3.19",
date: "2026-06-10",
title: "더 안전한 댓글 트리거, 안정적인 에이전트와 첨부 파일",
changes: [],
features: [
"댓글 입력창에서 보내기 전에 어떤 에이전트나 스쿼드가 작업을 시작할지 확인하고, 실수로 실행되는 일을 줄일 수 있습니다.",
"실행 기록에 시간이 표시되어 에이전트 진행 상황과 인계를 더 쉽게 검토할 수 있습니다.",
"오토파일럿 상세 페이지에서 누가 만들었는지 확인할 수 있습니다.",
"Claude Fable 5가 Multica의 지원 모델과 가격 목록에 추가되었습니다.",
"이슈 대화에서 특정 답글을 해결 답변으로 남길 수 있어, 긴 스레드를 접어도 결론을 더 쉽게 확인할 수 있습니다.",
"Lark와 Feishu 대화는 Multica가 답변을 준비하는 동안 입력 중 반응을 표시하고, 답변을 보내기 전에 자동으로 지웁니다.",
"에이전트 실행은 각 작업을 누가 시작했는지 알 수 있어 인계, 감사, 개인정보를 고려한 동작이 더 정확해집니다.",
"OpenClaw 사용자는 로컬 설정에서 사용자 지정 앱 위치와 데이터 폴더를 지정할 수 있습니다.",
],
improvements: [
"댓글 트리거 표시가 더 조용하고 명확해졌으며, 긴 에이전트 이름도 덜 비좁게 보입니다.",
"WSL2처럼 Multica 밖에서 데몬을 관리하는 경우 데스크톱은 시작과 중지 조작을 비활성화합니다.",
"이슈 헤더의 활성 에이전트 표시가 더 읽기 쉬워졌으며, 실제 실행 중일 때만 움직이고 대기 중일 때는 대기 상태를 명확히 보여 줍니다.",
"CLI는 흔한 오류, 로그인 문제, 프로젝트 설정 값에 대해 더 명확하게 안내합니다.",
],
fixes: [
"이슈 설명의 이미지와 파일은 웹과 데스크톱에서 다시 열어도 계속 표시됩니다.",
"각 이슈 대화 스레드는 해결 답변을 하나만 유지해 결론을 바꿀 때 모두에게 일관되게 보입니다.",
"실시간 연결이 복구된 뒤 이슈 화면이 데이터를 새로고침해 오래된 타임라인이 남지 않습니다.",
"에이전트 작업을 시작한 사람의 기록이 오래된 작업에서도 더 안정적으로 유지됩니다.",
"스크롤 중 고정된 이슈 댓글의 가장자리가 더 깔끔하게 보입니다.",
"새로 올린 첨부 파일은 안정적인 비공개 다운로드 링크를 사용해 임시 업로드 링크가 만료된 뒤에도 이미지와 파일이 계속 표시됩니다.",
"새 이슈에서 시작된 오토파일럿 실행은 배정된 작업이 완료되지 못하면 올바르게 실패 처리되어 진행 중에 멈춰 있지 않습니다.",
"받은함에서 댓글 링크를 열 때 데스크톱 화면 전체가 밀리지 않고 이슈 타임라인 안에서만 스크롤됩니다.",
"Cursor와 Codex 세션은 최종 결과 후 더 깔끔하게 종료되어 완료 상태와 마지막 텔레메트리를 보존합니다.",
"셀프호스트 설정은 지정된 서버 URL을 따르며, 프로젝트 생성은 일반 실패 대신 명확한 검증 오류를 반환합니다.",
"이전 업로드 강화 변경은 첨부 파일 동작과 충돌해 롤백했으며, 첨부 파일 접근을 안정적으로 유지했습니다.",
],
},
{
version: "0.3.18",
date: "2026-06-08",

View File

@@ -292,6 +292,190 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.27",
date: "2026-06-19",
title: "桌面侧边栏更顺滑,固定项更清爽",
changes: [],
improvements: [
"桌面端侧边栏展开和收起更顺滑,同时保留常用导航控制,操作更连贯",
"侧边栏固定的 Issue 现在突出显示 Issue 标题,更容易快速识别",
"贡献者和智能体工作指引更简洁,发版前更容易审查和确认",
],
fixes: [
"固定的 Issue 和项目现在只显示一个选中状态,不会同时点亮父级导航项",
"进入 Issue 或项目详情页、恢复已保存布局时,详情侧边栏不会再意外播放动画",
],
},
{
version: "0.3.25",
date: "2026-06-18",
title: "让技能、自动任务和聊天中的智能体工作更可靠",
changes: [],
features: [
"开发者机器上的本地技能库现在可以被自动识别,智能体运行时更容易复用团队能力",
"自动任务可以配置默认订阅人,新建 Issue 时更容易把相关队友带入确认",
"聊天附件会绑定到当前工作空间,发送消息时也不会阻塞后续对话",
"智能体评论发送失败后,可以直接在 Issue 时间线里重试",
],
improvements: [
"同名模型来自不同服务商时,使用量统计会更准确",
"历史 Codex 使用量可以补齐,用量记录更完整",
"运行时存储统计会覆盖更多工作目录,空间占用更清楚",
"后台任务指引和发版检查更严格,可以更早发现高风险改动",
],
fixes: [
"聊天和评论里的 Issue 提及标签会适配容器宽度,不再和周围文字重叠",
"工作空间链接会更稳定地使用正确的部署域名",
"自动任务运行结束后,会清理对应的运行目录",
"桌面端可以正确处理基于提交版本的版本号",
"Tencent CodeBuddy 会显示正确的服务商图标",
"守护进程领取任务的响应更小,传输更快",
],
},
{
version: "0.3.24",
date: "2026-06-17",
title: "自定义运行时",
changes: [],
features: [
"团队可以创建自定义运行时,让智能体按环境使用合适的本地工具和模型",
"命令行创建和更新智能体时可以选择思考强度",
],
improvements: [
"运行时配置会更快同步到应用,并优先匹配当前环境",
"客户端错误和卡顿反馈会合并重复信息",
"Issue 评论触发预览文案更清楚",
],
fixes: [
"Office 365 邮件的备用发送方式更稳定",
"GitHub 安装上下文和 CI 等待状态显示更可靠",
"Codex 服务退出时会快速失败",
"自修复运行时可再次删除,切换运行时时会清理不兼容模型",
"未知 Issue 图标和普通文件名链接识别更安全",
],
},
{
version: "0.3.23",
date: "2026-06-16",
title: "Issue 日期筛选和提高智能体运行稳定性",
changes: [],
features: [
"Issue 现在可以按创建时间或更新时间筛选,支持快捷时间范围和自定义日期",
"命令行现在可以删除运行环境,默认行为更安全,也可以明确选择是否连带处理相关数据",
"Lark 连接现在可以使用网络代理,受限网络环境下的团队也能更稳定地连接",
],
improvements: [
"网页端和桌面端的错误、卡顿和崩溃现在更容易定位,问题反馈会带上更清楚的信息",
"项目列表行、评论预览和评论编辑器体验更一致,导航和附件操作更顺手",
],
fixes: [
"回复和编辑评论前,现在会更准确地预览哪些智能体或小队会开始运行",
"评论里的普通 Issue 编号会保持为普通文字,只有明确插入链接时才会变成链接",
"通过命令行登录并选择 Google 登录时,浏览器认证完成后现在会正确回到命令行",
"聊天上传文件会等到当前智能体准备好后再开放,避免加载过程中上传失败",
"触屏设备上不需要悬停也能看到运行记录里的操作按钮",
"智能体发布评论的指令更稳,不容易因为命令格式问题漏掉指派人、项目或其他字段",
],
},
{
version: "0.3.22",
date: "2026-06-15",
title: "更快的列表体验、更顺手的运行配置和更安全的 Issue 编辑",
changes: [],
features: [
"智能体、自动任务、项目、运行环境、技能和小队的列表体验更快也更一致,行内容、筛选、选择和操作都更清楚",
"命令行现在可以管理工作区仓库,本地智能体更容易拿到项目仓库上下文",
"Cursor 和 OpenClaw 更容易配置Cursor 连接设置可以由 Multica 托管OpenClaw 也可以连接已有网关",
"编辑评论时,可以在保存前预览并控制哪些智能体或小队会开始运行",
],
improvements: [
"桌面端恢复提示会带上更多页面上下文,反馈卡住窗口时更容易说清发生位置",
"长 Issue 和收件箱视图在离开后返回时,会更稳定地保留滚动位置和评论锚点",
"Cursor 的 Composer、缓存输入和新版 Cursor 智能体输出会展示更清楚的用量和计费信息",
],
fixes: [
"Issue 附件、正文图片和文件卡片在网页端、桌面端、移动端以及令牌分享链接里更稳定可用",
"编辑器和只读 Issue 内容会更稳定地处理美元金额和邮箱链接",
"桌面端 Cmd+W 现在会先关闭当前标签页,无法关闭标签页时再关闭窗口",
"自托管 Docker Compose 上传和默认配置更少失败,缺失的配置值也会更早暴露",
"智能体任务遇到无效运行凭证时,会安全停止而不是继续执行",
],
},
{
version: "0.3.21",
date: "2026-06-12",
title: "CodeBuddy Runtime",
changes: [],
features: [
"CodeBuddy 现在可以驱动本地 Multica 智能体,并会自动显示可用的模型和投入强度选项",
"快速创建 Issue 时上传的文件现在会从草稿一直带到最终创建的 Issue 里",
],
improvements: [
"技能导入冲突更容易理解:锁定的技能会显示成员名称,不再显示内部 ID单个覆盖也可以一键完成",
"桌面端恢复提示会先说明发生了什么,并给出更清楚的窗口卡住反馈信息",
"按注册时间排序或筛选成员的页面现在加载更快",
],
fixes: [
"聊天在发送、停止或发送失败恢复时,会更稳定地同步消息和草稿",
"Lark 账号绑定现在对已登录用户也能稳定完成,登录后也会回到绑定页面",
"本地智能体运行不会再在任务文件夹准备好之前就显示已经开始",
],
},
{
version: "0.3.20",
date: "2026-06-11",
title: "技能导入、运行记录和更稳定的智能体",
changes: [],
features: [
"导入技能时,如果同名技能已存在,现在可以选择停止、替换、另存为新名称或跳过",
"导入结果会清楚显示哪些技能已新增、已更新、已跳过、发生冲突或导入失败",
],
improvements: [
"网页端和移动端的执行记录现在会优先显示最新的历史运行,更容易看清最近进展",
"更新日志内容已整理,最新发布内容会归在正确的版本下",
],
fixes: [
"Issue 讨论里的回复现在会按到达顺序显示,即使较慢的智能体回复稍后才出现,也不会插到前面",
"当已保存的会话失效时,智能体可以自动重新开始,不会在后续每次提及时反复失败",
"从新的工作目录重新唤起 Issue 时,现在会开始新会话,不会继续尝试只存在于旧目录里的会话",
],
},
{
version: "0.3.19",
date: "2026-06-10",
title: "更安全的评论触发、更稳定的智能体和附件",
changes: [],
features: [
"评论输入框现在会在发送前显示哪些智能体或小队会开始工作,也可以避免误触发运行",
"智能体运行记录现在会显示时间点,回看进度和交接信息更清楚",
"自动任务详情页现在会显示创建人",
"Claude Fable 5 现在已加入 Multica 支持的模型和价格列表",
"Issue 讨论可以把某一条回复设为解决结论,长讨论收起后也能直接看到最终答案",
"在 Lark 和飞书里和 Multica 对话时,会显示等待中的输入状态,回复发出后自动清除",
"每次智能体任务都会带上真实发起人信息,交接、审计和权限判断更准确",
"OpenClaw 可以从本地配置中读取自定义程序位置和数据目录",
],
improvements: [
"评论触发提示更安静、更清楚,遇到较长的智能体名称时也不容易拥挤",
"桌面端在守护进程由 Multica 之外的环境管理时,会禁用启动和停止控制,例如 WSL2 场景",
"Issue 顶部的智能体状态更容易区分:运行中才显示动效,等待中会明确显示排队状态",
"命令行会直接说明常见错误、登录问题和项目配置问题的处理方式",
],
fixes: [
"Issue 描述里的图片和文件在网页端和桌面端重新打开后都会保持可见",
"每个 Issue 讨论线程现在只会保留一个解决结论,替换结论时所有人看到的状态更一致",
"实时连接断开并恢复后Issue 页面会刷新数据,避免时间线停留在旧状态",
"智能体任务的发起人历史在较早任务记录上也会更可靠",
"滚动时置顶的 Issue 评论边缘显示更干净",
"新上传的附件会使用稳定的私有下载链接,临时上传链接过期后图片和文件仍能正常显示",
"自动任务通过新建 Issue 启动后,如果对应的智能体任务失败,会同步标记为失败,不会一直卡在进行中",
"从收件箱打开评论链接时,只会滚动 Issue 时间线,不会把桌面窗口内容顶出可见区域",
"Cursor 和 Codex 会话在收到最终结果后会正常收尾,并保留完成状态和最后的遥测信息",
"自托管设置会遵循已配置的服务地址,创建项目时也会返回清楚的校验错误,而不是笼统失败",
"上一轮上传加固改动因影响附件体验已回滚,附件访问保持稳定",
],
},
{
version: "0.3.18",
date: "2026-06-08",

View File

@@ -14,7 +14,7 @@
# docker compose -f docker-compose.selfhost.yml up -d
#
# Frontend: http://localhost:${FRONTEND_PORT:-3000}
# Backend: http://localhost:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}
# Backend: http://localhost:${BACKEND_PORT:-8080}
name: multica
@@ -44,7 +44,7 @@ services:
postgres:
condition: service_healthy
ports:
- "127.0.0.1:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}:8080"
- "127.0.0.1:${BACKEND_PORT:-8080}:8080"
volumes:
- backend_uploads:/app/data/uploads
environment:
@@ -52,7 +52,7 @@ services:
PORT: "8080"
METRICS_ADDR: ${METRICS_ADDR:-}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT:-3000}}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
@@ -65,10 +65,12 @@ services:
SMTP_EHLO_NAME: ${SMTP_EHLO_NAME:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:${FRONTEND_PORT:-3000}/auth/callback}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
S3_BUCKET: ${S3_BUCKET:-}
S3_REGION: ${S3_REGION:-us-west-2}
AWS_ENDPOINT_URL: ${AWS_ENDPOINT_URL:-}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
ATTACHMENT_DOWNLOAD_MODE: ${ATTACHMENT_DOWNLOAD_MODE:-auto}
ATTACHMENT_DOWNLOAD_URL_TTL: ${ATTACHMENT_DOWNLOAD_URL_TTL:-30m}
CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-}
@@ -77,7 +79,7 @@ services:
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:${FRONTEND_PORT:-3000}}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}

View File

@@ -0,0 +1,91 @@
# Codex Usage Cache Backfill
This runbook describes the one-time hosted data repair for Codex usage rows
created before cached input was normalized at ingestion time.
Do not run this as an automatic database migration. The write step needs an
operator-selected cutoff, dry-run review, and an explicit execute command.
## When To Run
Run this only after the backend image containing `backfill_codex_usage_cache`
has been deployed, and only for databases that need historical Codex usage
correction.
Use the actual hosted deployment time of PR #4083 as `--cutoff`. Do not use the
PR merge time unless it is also the real production cutover time.
## Execution Model
Run the command from the released backend image as a one-time operator job, such
as a Kubernetes Job with the normal backend database secret and network access.
Override the command to execute `./backfill_codex_usage_cache`.
The command defaults to dry-run. It mutates data only when `--execute` is passed.
## Dry Run
First run:
```bash
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME>
```
Optionally limit scope while validating:
```bash
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME> --workspace-id <workspace-uuid>
```
Review the per-workspace/date output:
- `rows`
- `input_before`
- `input_after`
- `input_tokens_removed`
- `clamped_rows`
Proceed only if the totals match the expected overcount shape.
## Execute
After dry-run review:
```bash
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME> --execute
```
For large datasets, throttle writes:
```bash
./backfill_codex_usage_cache \
--cutoff <RFC3339_DEPLOY_TIME> \
--execute \
--batch-size 500 \
--sleep-between-batches 1s
```
By default, execution rebuilds affected hourly rollups by calling
`rollup_task_usage_hourly_window(...)` for the database update window. Leave
`--rebuild-rollup=true` unless an operator intentionally plans a separate rollup
rebuild.
## Verification
After execution, run the dry-run command again with the same cutoff and scope.
Eligible rows should be zero.
Then verify Usage / Runtime dashboard periods that were previously inflated.
## Safety Boundaries
The command updates only rows that match all of these conditions:
- `provider = 'codex'`
- `cache_read_tokens > 0`
- `input_tokens > 0`
- `COALESCE(updated_at, created_at) < --cutoff`
- optional `--workspace-id` match
Rows without persisted `cache_read_tokens` are intentionally ignored because the
current database cannot accurately reconstruct cached input for them.

View File

@@ -534,10 +534,8 @@ multica issue assign <issue-id> --agent <agent-slug>
| Provider | 厂商 | Session Resume | MCP | Skill 注入路径 | custom_args | 备注 |
- 每个 provider 一小段80-150 字):核心定位 + 用户画像 + 官网链接 + Multica 兼容性
- **Session resume 精确现状**:
- ✅ 真用:Claude / Hermes / Kimi / OpenCode / Copilot
- ⚠️ Codex代码有 thread/resume 但 unreachablefuture feature
- ❌ 不支持Pi / Gemini / OpenClaw
- ❓ 未审Cursor
- ✅ 真用:Antigravity / Claude / Codex / Copilot / Cursor / Hermes / Kimi / Kiro CLI / OpenCode / OpenClaw / Pi
- ❌ 不支持Gemini
- **不写**: provider 官方使用文档外链、MCP 协议本身
- **写前要验证**:
- 认领者**必须逐个打开 `server/pkg/agent/*.go`** 确认
@@ -546,7 +544,7 @@ multica issue assign <issue-id> --agent <agent-slug>
- **⚠️ 动笔前必读**:
- ⚠️ 这是最容易过时的一页provider 代码频繁变动
- 精确到 "代码里这个 flag 传给这个 CLI" 级别,不模糊说"支持"
- Codex "unreachable" 状态必须明确(不是承诺)
- Codex fallback 语义必须明确:`thread/resume` 可用,但 stale / missing thread 会回退到 fresh thread
- **Owner**:
---

View File

@@ -1,36 +1,34 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
import { createTestApi, loginAsDefault, openWorkspaceMenu, waitForPageText } from "./helpers";
test.describe("Authentication", () => {
test("login page renders correctly", async ({ page }) => {
await page.goto("/login");
await page.goto("/login", { waitUntil: "domcontentloaded" });
await waitForPageText(page, "Sign in to Multica");
await expect(page.locator("h1")).toContainText("Multica");
await expect(page.locator('input[placeholder="Email"]')).toBeVisible();
await expect(page.locator('input[placeholder="Name"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toContainText(
"Sign in",
);
await expect(page.getByText("Sign in to Multica")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible();
await expect(page.getByPlaceholder("you@example.com")).toBeVisible();
await expect(page.getByRole("button", { name: "Continue" })).toBeDisabled();
});
test("login and redirect to /issues", async ({ page }) => {
await loginAsDefault(page);
const workspaceSlug = await loginAsDefault(page);
await expect(page).toHaveURL(/\/issues/);
await expect(page.locator("text=All Issues")).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/issues$`));
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
});
test("unauthenticated user is redirected to /login", async ({ page }) => {
await page.goto("/login");
await page.evaluate(() => {
localStorage.removeItem("multica_token");
});
const api = await createTestApi();
const [workspace] = await api.getWorkspaces();
if (!workspace) {
throw new Error("E2E workspace was not created");
}
// Visit a workspace-scoped route; DashboardGuard should redirect to /login.
// The slug here need not exist — the guard runs before workspace resolution
// for unauthenticated users.
await page.goto("/e2e-workspace/issues");
await page.waitForURL("**/login", { timeout: 10000 });
await page.goto(`/${workspace.slug}/issues`, { waitUntil: "domcontentloaded" });
await page.waitForURL("**/login", { timeout: 10000, waitUntil: "domcontentloaded" });
await waitForPageText(page, "Sign in to Multica");
});
test("logout redirects to /login", async ({ page }) => {
@@ -39,10 +37,10 @@ test.describe("Authentication", () => {
// Open the workspace dropdown menu
await openWorkspaceMenu(page);
// Click Sign out
await page.locator("text=Sign out").click();
await page.getByRole("menuitem", { name: "Log out" }).click();
await page.waitForURL("**/login", { timeout: 10000 });
await page.waitForURL("**/login", { timeout: 10000, waitUntil: "domcontentloaded" });
await waitForPageText(page, "Sign in to Multica");
await expect(page).toHaveURL(/\/login/);
});
});

View File

@@ -85,7 +85,7 @@ test.describe("Chat attachments", () => {
const userRow = await pgc.query(
`SELECT id FROM "user" WHERE email = $1 LIMIT 1`,
["e2e@multica.ai"],
[api.getEmail()],
);
if (userRow.rows.length === 0) throw new Error("e2e user missing");
const userId = userRow.rows[0].id as string;

View File

@@ -1,40 +1,44 @@
import { test, expect } from "@playwright/test";
import { createTestApi, loginAsDefault } from "./helpers";
import { createTestApi, loginAsDefault, waitForPageText } from "./helpers";
import type { TestApiClient } from "./fixtures";
test.describe("Comments", () => {
let api: TestApiClient;
let issueId: string;
let issueTitle: string;
let workspaceSlug: string;
test.beforeEach(async ({ page }) => {
api = await createTestApi();
await api.createIssue("E2E Comment Test " + Date.now());
await loginAsDefault(page);
issueTitle = "E2E Comment Test " + Date.now();
const issue = await api.createIssue(issueTitle);
issueId = issue.id;
workspaceSlug = await loginAsDefault(page);
});
test.afterEach(async () => {
await api.cleanup();
if (api) {
await api.cleanup();
}
});
test("can add a comment on an issue", async ({ page }) => {
// Wait for issues to load and click first one. `*=` matches both legacy
// `/issues/{id}` and URL-refactored `/{slug}/issues/{id}` hrefs.
const issueLink = page.locator('a[href*="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
await page.waitForURL(/\/issues\/[\w-]+/);
await page.goto(`/${workspaceSlug}/issues/${issueId}`, { waitUntil: "domcontentloaded" });
await waitForPageText(page, issueTitle);
// Wait for issue detail to load
await expect(page.locator("text=Properties")).toBeVisible();
// Type a comment
const commentText = "E2E comment " + Date.now();
const commentInput = page.locator(
'input[placeholder="Leave a comment..."]',
);
await commentInput.fill(commentText);
const editor = page
.locator('.ProseMirror[data-placeholder="Leave a comment..."], .ProseMirror:has([data-placeholder="Leave a comment..."])')
.first();
await expect(editor).toBeVisible();
await editor.click({ force: true });
await editor.fill(commentText);
// Submit the comment
await page.locator('form button[type="submit"]').last().click();
await page.keyboard.press("ControlOrMeta+Enter");
// Comment should appear in the activity section
await expect(page.locator(`text=${commentText}`)).toBeVisible({
@@ -43,15 +47,18 @@ test.describe("Comments", () => {
});
test("comment submit button is disabled when empty", async ({ page }) => {
const issueLink = page.locator('a[href*="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
await page.waitForURL(/\/issues\/[\w-]+/);
await page.goto(`/${workspaceSlug}/issues/${issueId}`, { waitUntil: "domcontentloaded" });
await waitForPageText(page, issueTitle);
await expect(page.locator("text=Properties")).toBeVisible();
// Submit button should be disabled when input is empty
const submitBtn = page.locator('form button[type="submit"]').last();
const editor = page
.locator('.ProseMirror[data-placeholder="Leave a comment..."], .ProseMirror:has([data-placeholder="Leave a comment..."])')
.first();
await expect(editor).toBeVisible();
const composer = editor.locator("xpath=ancestor::div[contains(@class, 'rounded-lg')][1]");
const submitBtn = composer.locator("button:has(svg.lucide-arrow-up)").last();
await expect(submitBtn).toBeDisabled();
});
});

View File

@@ -23,6 +23,7 @@ export class TestApiClient {
private token: string | null = null;
private workspaceSlug: string | null = null;
private workspaceId: string | null = null;
private email: string | null = null;
private createdIssueIds: string[] = [];
async login(email: string, name: string) {
@@ -52,11 +53,14 @@ export class TestApiClient {
throw new Error(`No verification code found for ${email}`);
}
const configuredDevCode = process.env.MULTICA_DEV_VERIFICATION_CODE?.trim();
const code = configuredDevCode || result.rows[0].code;
// Step 3: Verify code to get JWT
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code: result.rows[0].code }),
body: JSON.stringify({ email, code }),
});
if (!verifyRes.ok) {
throw new Error(`verify-code failed: ${verifyRes.status}`);
@@ -64,6 +68,7 @@ export class TestApiClient {
const data = await verifyRes.json();
this.token = data.token;
this.email = email;
// Update user name if needed
if (name && data.user?.name !== name) {
@@ -110,6 +115,7 @@ export class TestApiClient {
if (res.ok) {
const created = (await res.json()) as TestWorkspace;
this.workspaceId = created.id;
this.workspaceSlug = created.slug;
return created;
}
@@ -117,12 +123,40 @@ export class TestApiClient {
const created = refreshed.find((item) => item.slug === slug) ?? refreshed[0];
if (created) {
this.workspaceId = created.id;
this.workspaceSlug = created.slug;
return created;
}
throw new Error(`Failed to ensure workspace ${slug}: ${res.status} ${res.statusText}`);
}
async markUserOnboarded() {
if (!this.email) {
throw new Error("Cannot mark E2E user onboarded before login");
}
const client = new pg.Client(DATABASE_URL);
await client.connect();
try {
const result = await client.query(
`
UPDATE "user"
SET
onboarded_at = COALESCE(onboarded_at, now()),
onboarding_questionnaire = COALESCE(onboarding_questionnaire, '{}'::jsonb)
|| '{"source":["friends_colleagues"],"source_other":null,"source_skipped":false}'::jsonb
WHERE email = $1
`,
[this.email],
);
if (result.rowCount !== 1) {
throw new Error(`Failed to mark E2E user onboarded: ${this.email}`);
}
} finally {
await client.end();
}
}
async createIssue(title: string, opts?: Record<string, unknown>) {
const res = await this.authedFetch("/api/issues", {
method: "POST",
@@ -153,6 +187,13 @@ export class TestApiClient {
return this.token;
}
getEmail() {
if (!this.email) {
throw new Error("Test API client is not logged in");
}
return this.email;
}
private async authedFetch(path: string, init?: RequestInit) {
const headers: Record<string, string> = {
"Content-Type": "application/json",

View File

@@ -1,9 +1,31 @@
import { type Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { TestApiClient } from "./fixtures";
const DEFAULT_E2E_NAME = "E2E User";
const DEFAULT_E2E_EMAIL = "e2e@multica.ai";
const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
const E2E_WORKER = process.env.TEST_PARALLEL_INDEX ?? process.env.TEST_WORKER_INDEX ?? "0";
const E2E_RUN_ID = process.env.E2E_RUN_ID ?? `${Date.now().toString(36)}-${process.pid.toString(36)}`;
const DEFAULT_E2E_EMAIL = `e2e-${E2E_WORKER}-${E2E_RUN_ID}@multica.ai`;
const DEFAULT_E2E_WORKSPACE = `e2e-workspace-${E2E_WORKER}-${E2E_RUN_ID}`;
async function waitForIssuesPage(page: Page) {
await waitForPageText(page, "New Issue");
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible({
timeout: 15000,
});
}
export async function waitForPageText(page: Page, text: string, timeout = 30000) {
await page.waitForFunction(
(expected) => document.body?.innerText.includes(expected),
text,
{ timeout },
);
}
export async function reloadAppPage(page: Page) {
await page.reload({ waitUntil: "domcontentloaded" });
await waitForPageText(page, "Issues");
}
/**
* Log in as the default E2E user and ensure the workspace exists first.
@@ -16,17 +38,22 @@ export async function loginAsDefault(page: Page): Promise<string> {
const api = new TestApiClient();
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
const workspace = await api.ensureWorkspace(
"E2E Workspace",
`E2E Workspace ${E2E_WORKER}`,
DEFAULT_E2E_WORKSPACE,
);
await api.markUserOnboarded();
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => {
if (!token) {
throw new Error("E2E login did not return an auth token");
}
await page.addInitScript((t) => {
localStorage.setItem("multica_token", t);
localStorage.setItem("multica:chat:isOpen", "false");
}, token);
await page.goto(`/${workspace.slug}/issues`);
await page.waitForURL("**/issues", { timeout: 10000 });
await page.goto(`/${workspace.slug}/issues`, { waitUntil: "domcontentloaded" });
await waitForIssuesPage(page);
return workspace.slug;
}
@@ -37,13 +64,27 @@ export async function loginAsDefault(page: Page): Promise<string> {
export async function createTestApi(): Promise<TestApiClient> {
const api = new TestApiClient();
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
await api.ensureWorkspace(`E2E Workspace ${E2E_WORKER}`, DEFAULT_E2E_WORKSPACE);
await api.markUserOnboarded();
return api;
}
export async function preferManualCreateMode(page: Page) {
await page.evaluate(() => {
localStorage.setItem(
"multica_create_mode",
JSON.stringify({ state: { lastMode: "manual" }, version: 0 }),
);
});
await reloadAppPage(page);
await waitForIssuesPage(page);
}
export async function openWorkspaceMenu(page: Page) {
// Click the workspace switcher button (has ChevronDown icon)
await page.locator("aside button").first().click();
const workspaceButton = page.getByRole("button", { name: /E2E Workspace/ }).first();
await expect(workspaceButton).toBeVisible({ timeout: 15000 });
await workspaceButton.click();
// Wait for dropdown to appear
await page.locator('[class*="popover"]').waitFor({ state: "visible" });
await expect(page.locator('[class*="popover"]')).toBeVisible();
}

View File

@@ -1,7 +1,35 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault, createTestApi } from "./helpers";
import pg from "pg";
import { loginAsDefault, createTestApi, preferManualCreateMode, reloadAppPage } from "./helpers";
import type { TestApiClient } from "./fixtures";
const DATABASE_URL =
process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
async function setIssueTimestamps(
issueId: string,
timestamps: { createdAt: Date; updatedAt?: Date },
) {
const client = new pg.Client(DATABASE_URL);
await client.connect();
try {
await client.query(
`
UPDATE issue
SET created_at = $2, updated_at = $3
WHERE id = $1
`,
[
issueId,
timestamps.createdAt.toISOString(),
(timestamps.updatedAt ?? timestamps.createdAt).toISOString(),
],
);
} finally {
await client.end();
}
}
test.describe("Issues", () => {
let api: TestApiClient;
@@ -18,7 +46,7 @@ test.describe("Issues", () => {
test("issues page loads with board view", async ({ page }) => {
await api.createIssue("E2E Board View " + Date.now());
await page.reload();
await reloadAppPage(page);
// Board columns should be visible
await expect(page.locator("text=Backlog")).toBeVisible();
@@ -29,7 +57,7 @@ test.describe("Issues", () => {
test("can switch from board to list view", async ({ page }) => {
const title = "E2E List Switch " + Date.now();
await api.createIssue(title);
await page.reload();
await reloadAppPage(page);
await expect(page.locator("text=Backlog")).toBeVisible();
// Switch to list view
@@ -37,7 +65,77 @@ test.describe("Issues", () => {
await expect(page.getByText(title)).toBeVisible();
});
test("can filter issues by created and updated dates", async ({ page }) => {
const suffix = Date.now();
const todayTitle = `E2E Date Today ${suffix}`;
const oldTitle = `E2E Date Old ${suffix}`;
const updatedTodayTitle = `E2E Date Updated Today ${suffix}`;
await api.createIssue(todayTitle);
const oldIssue = await api.createIssue(oldTitle);
const updatedTodayIssue = await api.createIssue(updatedTodayTitle);
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 8);
await setIssueTimestamps(oldIssue.id, { createdAt: oldDate });
await setIssueTimestamps(updatedTodayIssue.id, {
createdAt: oldDate,
updatedAt: new Date(),
});
await reloadAppPage(page);
await expect(page.getByText(todayTitle)).toBeVisible();
await expect(page.getByText(oldTitle)).toBeVisible();
await expect(page.getByText(updatedTodayTitle)).toBeVisible();
await page.getByRole("button", { name: /filter/i }).click();
await page.getByRole("menuitem", { name: /^Date\b/ }).hover();
await page.getByRole("menuitem", { name: "Today" }).click();
await expect(page.getByRole("button", { name: /1 filter/i })).toBeVisible();
await expect(page.getByText(todayTitle)).toBeVisible();
await expect(page.getByText(oldTitle)).toBeHidden({ timeout: 10000 });
await expect(page.getByText(updatedTodayTitle)).toBeHidden({ timeout: 10000 });
await page.getByRole("button", { name: /1 filter/i }).click();
const dateFilterItem = page.getByRole("menuitem", { name: /^Date\b/ });
await dateFilterItem.focus();
await page.keyboard.press("ArrowRight");
const updatedDateField = page.getByRole("menuitemradio", { name: "Updated" });
await expect(updatedDateField).toBeVisible();
await updatedDateField.press("Enter");
await expect(page.getByText(todayTitle)).toBeVisible();
await expect(page.getByText(updatedTodayTitle)).toBeVisible();
await expect(page.getByText(oldTitle)).toBeHidden({ timeout: 10000 });
});
test("can filter issues by custom created date", async ({ page }) => {
const suffix = Date.now();
const todayTitle = `E2E Date Custom Today ${suffix}`;
const oldTitle = `E2E Date Custom Old ${suffix}`;
await api.createIssue(todayTitle);
const oldIssue = await api.createIssue(oldTitle);
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 8);
await setIssueTimestamps(oldIssue.id, { createdAt: oldDate });
await reloadAppPage(page);
await expect(page.getByText(todayTitle)).toBeVisible();
await expect(page.getByText(oldTitle)).toBeVisible();
await page.getByRole("button", { name: /filter/i }).click();
await page.getByRole("menuitem", { name: /^Date\b/ }).hover();
const customDateButton = page.getByRole("button", { name: "Custom date or range" });
await expect(customDateButton).toBeVisible();
await customDateButton.click();
const todayDataDay = await page.evaluate(() => new Date().toLocaleDateString());
await page.locator(`[data-day="${todayDataDay}"]`).click();
await page.getByRole("button", { name: "Apply" }).click();
await expect(page.getByText(todayTitle)).toBeVisible();
await expect(page.getByText(oldTitle)).toBeHidden({ timeout: 10000 });
});
test("can create a new issue", async ({ page }) => {
await preferManualCreateMode(page);
const newIssueButton = page.getByRole("button", { name: "New Issue" });
await expect(newIssueButton).toBeVisible();
await newIssueButton.click();
@@ -63,7 +161,7 @@ test.describe("Issues", () => {
const issue = await api.createIssue("E2E Detail Test " + Date.now());
// Reload to see the new issue
await page.reload();
await reloadAppPage(page);
// Navigate to the issue detail. Use a suffix match so the selector works
// whether the href is legacy `/issues/{id}` or URL-refactored
@@ -83,6 +181,8 @@ test.describe("Issues", () => {
});
test("can dismiss issue creation", async ({ page }) => {
await preferManualCreateMode(page);
await page.getByRole("button", { name: "New Issue" }).click();
const titleInput = page.getByRole("textbox", { name: "Issue title" });

View File

@@ -1,41 +1,41 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
import { loginAsDefault, waitForPageText } from "./helpers";
const ROUTE_CHANGE_TIMEOUT = 30000;
test.describe("Navigation", () => {
test.beforeEach(async ({ page }) => {
await loginAsDefault(page);
await page.waitForLoadState("networkidle");
});
test("sidebar navigation works", async ({ page }) => {
// Click Inbox
await page.locator("nav a", { hasText: "Inbox" }).click();
await page.waitForURL("**/inbox");
await expect(page).toHaveURL(/\/inbox/);
await page.getByRole("link", { name: "Inbox" }).click();
await expect(page).toHaveURL(/\/inbox/, { timeout: ROUTE_CHANGE_TIMEOUT });
await waitForPageText(page, "Inbox");
// Click Agents
await page.locator("nav a", { hasText: "Agents" }).click();
await page.waitForURL("**/agents");
await expect(page).toHaveURL(/\/agents/);
await page.getByRole("link", { name: "Agents" }).click();
await expect(page).toHaveURL(/\/agents/, { timeout: ROUTE_CHANGE_TIMEOUT });
await waitForPageText(page, "Agents");
// Click Issues
await page.locator("nav a", { hasText: "Issues" }).click();
await page.waitForURL("**/issues");
await expect(page).toHaveURL(/\/issues/);
await page.getByRole("link", { name: "Issues", exact: true }).click();
await expect(page).toHaveURL(/\/issues/, { timeout: ROUTE_CHANGE_TIMEOUT });
await waitForPageText(page, "Issues");
});
test("settings page loads via workspace menu", async ({ page }) => {
// Settings is inside the workspace dropdown menu
await openWorkspaceMenu(page);
await page.locator("text=Settings").click();
await page.waitForURL("**/settings");
test("settings page loads via sidebar", async ({ page }) => {
await page.getByRole("link", { name: "Settings", exact: true }).click();
await expect(page).toHaveURL(/\/settings/, { timeout: ROUTE_CHANGE_TIMEOUT });
await waitForPageText(page, "Settings");
await expect(page.getByRole("heading", { name: "Workspace" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Members" })).toBeVisible();
await expect(page.getByRole("tab", { name: "General" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Members" })).toBeVisible();
});
test("agents page shows agent list", async ({ page }) => {
await page.locator("nav a", { hasText: "Agents" }).click();
await page.waitForURL("**/agents");
await page.getByRole("link", { name: "Agents" }).click();
await expect(page).toHaveURL(/\/agents/, { timeout: ROUTE_CHANGE_TIMEOUT });
await waitForPageText(page, "Agents");
// Should show "Agents" heading
await expect(page.locator("text=Agents").first()).toBeVisible();

View File

@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
import { TestApiClient } from "./fixtures";
import { waitForPageText } from "./helpers";
// Smoke test for Onboarding V2: verifies the new per-question flow
// renders and captures screenshots for review. Uses a unique email
@@ -16,12 +17,11 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
await api.login(EMAIL, "OBv2 Tester");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => {
await page.addInitScript((t) => {
localStorage.setItem("multica_token", t);
}, token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
await waitForPageText(page, "Continue on web");
// 1. Welcome screen
await expect(page.getByRole("button", { name: "Continue on web" })).toBeVisible({ timeout: 15000 });
@@ -32,7 +32,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
// 2. Source step
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
await expect(page.getByText(/Step 1 of \d+/)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
@@ -42,7 +42,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
// 3. Role step
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
await expect(page.getByText(/Step 2 of \d+/)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
@@ -51,12 +51,12 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
// 4. Use case step
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
await expect(page.getByText(/Step 3 of \d+/)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
// Pick ship_code then Continue → workspace step.
await page.getByRole("radio", { name: /Ship code with AI agents/i }).click();
await page.getByRole("checkbox", { name: /Ship code with AI agents/i }).click();
await page.getByRole("button", { name: "Continue" }).click();
// 5. Workspace step (legacy)
@@ -69,10 +69,9 @@ test("onboarding v2 — rage-skip all 3 questions", async ({ page }) => {
await api.login(`rage-skip-${Date.now()}@localhost`, "Rage Skipper");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
await page.addInitScript((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
await waitForPageText(page, "Continue on web");
await page.getByRole("button", { name: "Continue on web" }).click();
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
@@ -97,10 +96,9 @@ test("onboarding v2 — zh-Hans renders Chinese labels", async ({ page, context
await api.login(`zh-${Date.now()}@localhost`, "中文用户");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
await page.addInitScript((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
await waitForPageText(page, "在 web 端继续");
await page.getByRole("button").first().click().catch(() => {});

View File

@@ -1,20 +1,18 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
import { loginAsDefault, waitForPageText } from "./helpers";
test.describe("Settings", () => {
test("updating workspace name reflects in sidebar immediately", async ({
page,
}) => {
await loginAsDefault(page);
const workspaceSlug = await loginAsDefault(page);
// Read the current workspace name from the sidebar
const sidebarName = page.locator("aside button").first();
const originalName = await sidebarName.innerText();
const sidebarName = page.getByRole("button", { name: /E2E Workspace/ }).first();
const originalName = (await sidebarName.innerText()).split("\n").pop()?.trim() ?? "E2E Workspace";
// Navigate to settings
await openWorkspaceMenu(page);
await page.locator("text=Settings").click();
await page.waitForURL("**/settings");
await page.goto(`/${workspaceSlug}/settings?tab=workspace`, { waitUntil: "domcontentloaded" });
await waitForPageText(page, "General");
// Change workspace name
const nameInput = page
@@ -27,16 +25,16 @@ test.describe("Settings", () => {
// Save
await page.locator("button", { hasText: "Save" }).click();
// Wait for "Saved!" confirmation
await expect(page.locator("text=Saved!")).toBeVisible({ timeout: 5000 });
await expect(page.getByText("Workspace settings saved").first()).toBeVisible({ timeout: 5000 });
// Sidebar should reflect the new name WITHOUT page refresh
await expect(sidebarName).toContainText(newName);
await expect(page.getByRole("button", { name: new RegExp(newName) }).first()).toBeVisible();
// Restore original name so other tests aren't affected
await nameInput.clear();
await nameInput.fill(originalName.trim());
await page.locator("button", { hasText: "Save" }).click();
await expect(page.locator("text=Saved!")).toBeVisible({ timeout: 5000 });
await expect(page.getByText("Workspace settings saved").first()).toBeVisible({ timeout: 5000 });
await expect(page.getByRole("button", { name: new RegExp(originalName) }).first()).toBeVisible();
});
});

View File

@@ -8,3 +8,4 @@ export * from "./constants";
export * from "./visibility-label";
export * from "./use-workspace-agent-availability";
export * from "./mcp-support";
export * from "./openclaw-runtime-config";

View File

@@ -2,13 +2,13 @@
// forwards MCP servers to the underlying CLI. The MCP config tab is hidden
// for every other provider so a user can't save a value the runtime will
// silently ignore. Keep this list in sync with the backends in
// `server/pkg/agent/` that read `ExecOptions.McpConfig`, plus the OpenClaw
// per-task wrapper preparer in `server/internal/daemon/execenv/` which
// materialises `mcp.servers` into the synthesised config rather than going
// through ExecOptions.
// `server/pkg/agent/` that read `ExecOptions.McpConfig`, plus providers whose
// per-task preparers in `server/internal/daemon/execenv/` materialise MCP
// config for CLIs that do not receive it through ExecOptions.
const MCP_SUPPORTED_PROVIDERS = new Set([
"claude",
"codex",
"cursor",
"hermes",
"kimi",
"kiro",

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
OPENCLAW_GATEWAY_TOKEN_MASK,
serializeOpenclawRuntimeConfig,
} from "./openclaw-runtime-config";
describe("serializeOpenclawRuntimeConfig", () => {
it("keeps the masked gateway token sentinel so the API can preserve the persisted token", () => {
expect(
serializeOpenclawRuntimeConfig({
mode: "gateway",
gateway: {
host: "gw.internal",
port: 18789,
token: OPENCLAW_GATEWAY_TOKEN_MASK,
tls: true,
},
}),
).toEqual({
mode: "gateway",
gateway: {
host: "gw.internal",
port: 18789,
token: OPENCLAW_GATEWAY_TOKEN_MASK,
tls: true,
},
});
});
it("omits an empty gateway token so users can clear a persisted token", () => {
expect(
serializeOpenclawRuntimeConfig({
mode: "gateway",
gateway: {
host: "gw.internal",
port: 18789,
},
}),
).toEqual({
mode: "gateway",
gateway: {
host: "gw.internal",
port: 18789,
},
});
});
it("passes through a real gateway token value", () => {
expect(
serializeOpenclawRuntimeConfig({
mode: "gateway",
gateway: {
token: "rotated-secret",
},
}),
).toEqual({
mode: "gateway",
gateway: {
token: "rotated-secret",
},
});
});
});

View File

@@ -0,0 +1,94 @@
// OpenClaw-specific `runtime_config` schema (issue #3260).
//
// Stored under `agent.runtime_config` as freeform JSONB; only meaningful for
// agents whose runtime provider is openclaw. The daemon decodes the same
// schema in `server/internal/daemon/openclaw_runtime_config.go` — keep both
// sides in lockstep when changing field names.
export type OpenclawRoutingMode = "local" | "gateway";
export interface OpenclawGatewayPin {
host?: string;
port?: number;
token?: string;
tls?: boolean;
}
export interface OpenclawRuntimeConfig {
mode?: OpenclawRoutingMode;
gateway?: OpenclawGatewayPin;
}
// Sentinel the API substitutes for a non-empty `gateway.token` on every read.
// When the form re-submits the same sentinel, the backend's matching
// preserve hook restores the persisted token instead of overwriting it.
// Mirrors `runtimeConfigGatewayTokenMask` in server/internal/handler/agent.go.
export const OPENCLAW_GATEWAY_TOKEN_MASK = "***";
// Parse an arbitrary runtime_config payload into the typed schema. Unknown
// keys are dropped, malformed payloads collapse to an empty object. The form
// never throws on bad input — invalid configs simply render as defaults so
// the user can correct them without a JSON parse error blocking the UI.
export function parseOpenclawRuntimeConfig(
raw: unknown,
): OpenclawRuntimeConfig {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const root = raw as Record<string, unknown>;
const out: OpenclawRuntimeConfig = {};
if (root.mode === "local" || root.mode === "gateway") {
out.mode = root.mode;
}
if (root.gateway && typeof root.gateway === "object" && !Array.isArray(root.gateway)) {
const gw = root.gateway as Record<string, unknown>;
const pin: OpenclawGatewayPin = {};
if (typeof gw.host === "string" && gw.host !== "") pin.host = gw.host;
if (typeof gw.port === "number" && Number.isFinite(gw.port) && gw.port > 0) pin.port = gw.port;
if (typeof gw.token === "string" && gw.token !== "") pin.token = gw.token;
if (typeof gw.tls === "boolean") pin.tls = gw.tls;
if (Object.keys(pin).length > 0) out.gateway = pin;
}
return out;
}
// Render the typed form state back into the wire shape the API accepts.
// Empty gateway sub-objects collapse to `undefined` so the wire payload
// only carries fields the user actually populated — partial pins (host+port
// only, etc.) work as documented.
export function serializeOpenclawRuntimeConfig(
cfg: OpenclawRuntimeConfig,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (cfg.mode) out.mode = cfg.mode;
if (cfg.gateway) {
const gw: Record<string, unknown> = {};
if (cfg.gateway.host) gw.host = cfg.gateway.host;
if (cfg.gateway.port) gw.port = cfg.gateway.port;
if (cfg.gateway.tls) gw.tls = true;
// The mask sentinel is the explicit "keep persisted token" signal for
// the API. Omitting the field means "clear/no token" for partial
// gateway pins, so the sentinel must survive serialization.
if (cfg.gateway.token) {
gw.token = cfg.gateway.token;
}
if (Object.keys(gw).length > 0) out.gateway = gw;
}
return out;
}
// Stable shallow equality across two parsed configs, used by the form's
// dirty detector. Treats absent gateway block and an empty gateway block as
// identical so toggling between local/gateway without filling endpoint
// fields doesn't surface a spurious "unsaved changes" notice.
export function openclawRuntimeConfigEquals(
a: OpenclawRuntimeConfig,
b: OpenclawRuntimeConfig,
): boolean {
if ((a.mode ?? "local") !== (b.mode ?? "local")) return false;
const aGw = a.gateway ?? {};
const bGw = b.gateway ?? {};
if ((aGw.host ?? "") !== (bGw.host ?? "")) return false;
if ((aGw.port ?? 0) !== (bGw.port ?? 0)) return false;
if ((aGw.token ?? "") !== (bGw.token ?? "")) return false;
if (Boolean(aGw.tls) !== Boolean(bGw.tls)) return false;
return true;
}

View File

@@ -1,7 +1,15 @@
export {
useAgentsViewStore,
AGENT_SCOPES,
AGENT_SORT_DEFAULT_DIRECTION,
AGENT_DEFAULT_HIDDEN_COLUMNS,
EMPTY_AGENT_FILTERS,
type AgentsScope,
type AgentsViewState,
type AgentSortField,
type AgentSortDirection,
type AgentColumnKey,
type AgentListFilters,
} from "./view-store";
export {
useTranscriptViewStore,

View File

@@ -44,7 +44,7 @@ describe("useAgentsViewStore", () => {
expect(useAgentsViewStore.getState().scope).toBe("all");
});
it("partialize persists only scope under the workspace-namespaced key", async () => {
it("partialize persists only view prefs (no actions) under the workspace-namespaced key", async () => {
setCurrentWorkspace("acme", "ws_a");
await flush();
useAgentsViewStore.getState().setScope("all");
@@ -52,7 +52,14 @@ describe("useAgentsViewStore", () => {
const raw = localStorage.getItem("multica_agents_view:acme");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.state).toEqual({ scope: "all" });
expect(Object.keys(parsed.state).sort()).toEqual([
"filters",
"hiddenColumns",
"scope",
"sortDirection",
"sortField",
]);
expect(parsed.state.scope).toBe("all");
});
it("rehydrates a different saved scope on workspace switch", async () => {
@@ -93,4 +100,25 @@ describe("useAgentsViewStore", () => {
expect(useAgentsViewStore.getState().scope).toBe("mine");
expect(localStorage.getItem("multica_agents_view:acme")).not.toBeNull();
});
it("backfills new filter dimensions when rehydrating a pre-owners payload", async () => {
// A payload persisted before the `owners` filter existed must not drop
// the key to undefined (the agents list filter predicate reads
// `filters.owners.length` and would crash).
localStorage.setItem(
"multica_agents_view:acme",
JSON.stringify({
state: { filters: { availability: ["online"], runtimes: [] } },
version: 0,
}),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
const filters = useAgentsViewStore.getState().filters;
expect(filters.owners).toEqual([]);
expect(filters.availability).toEqual(["online"]);
});
});

View File

@@ -8,30 +8,181 @@ import {
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type AgentsScope = "mine" | "all";
// View preferences for the agents list page: scope, sort, column visibility,
// and filters. Persisted per workspace, per user/device. Row selection is
// session-scoped on purpose (same rationale as the skills/autopilots view
// stores).
// Scope mixes the ownership lens (mine/all) with the archived lifecycle
// stage. Impure on paper, but the three are mutually exclusive in practice
// and "mine" is the historical product default; the archived view ignores
// the ownership lens entirely (showing only *my* archived agents would hide
// other people's archived agents with no UI to explain why).
export type AgentsScope = "mine" | "all" | "archived";
export const AGENT_SCOPES: AgentsScope[] = ["mine", "all", "archived"];
export type AgentSortField = "lastActive" | "name" | "runs" | "created";
export type AgentSortDirection = "asc" | "desc";
/** Per-field direction applied when the user switches TO that field. */
export const AGENT_SORT_DEFAULT_DIRECTION: Record<
AgentSortField,
AgentSortDirection
> = {
lastActive: "desc",
name: "asc",
runs: "desc",
created: "desc",
};
/** Multi-select filter state. Empty array per dimension = inactive. */
export interface AgentListFilters {
/** AgentAvailability values (online / unstable / offline). */
availability: string[];
/** Runtime ids. */
runtimes: string[];
/** Owner user ids. Owner is the same person-axis as the Mine scope: the
* "mine" scope is the clean no-filter personal view, and applying any
* filter (owner or otherwise) leaves it for "all" — see setScope /
* toggleFilter. So owner-as-filter and Mine never coexist, which keeps
* the axis orthogonal (no "mine + owner=someone-else = empty" state). */
owners: string[];
/** Runtime-native model identifiers (e.g. claude / codex / gpt-…). */
models: string[];
}
export const EMPTY_AGENT_FILTERS: AgentListFilters = {
availability: [],
runtimes: [],
owners: [],
models: [],
};
// User-hideable columns. Name and the structural columns (checkbox, kebab)
// are always visible.
export type AgentColumnKey =
| "status"
| "owner"
| "runtime"
| "lastActive"
| "runs"
| "model"
| "created";
/** Model and created are opt-in: hidden until the user enables them. Owner
* is shown by default (the user wants to see who owns each agent). */
export const AGENT_DEFAULT_HIDDEN_COLUMNS: AgentColumnKey[] = [
"model",
"created",
];
export interface AgentsViewState {
scope: AgentsScope;
sortField: AgentSortField;
sortDirection: AgentSortDirection;
hiddenColumns: AgentColumnKey[];
filters: AgentListFilters;
setScope: (scope: AgentsScope) => void;
/** Header click: toggles direction on the active field, otherwise switches
* to the field with its default direction. */
toggleSort: (field: AgentSortField) => void;
/** Display panel select: switches field (default direction), no toggle. */
setSortField: (field: AgentSortField) => void;
setSortDirection: (direction: AgentSortDirection) => void;
toggleColumn: (key: AgentColumnKey) => void;
toggleFilter: (key: keyof AgentListFilters, value: string) => void;
clearFilters: () => void;
}
const DEFAULTS = {
// "mine" is the historical default — most members care about their own
// agents first; admins flip to "all".
scope: "mine" as AgentsScope,
sortField: "lastActive" as AgentSortField,
sortDirection: AGENT_SORT_DEFAULT_DIRECTION.lastActive,
hiddenColumns: AGENT_DEFAULT_HIDDEN_COLUMNS,
filters: EMPTY_AGENT_FILTERS,
};
export const useAgentsViewStore = create<AgentsViewState>()(
persist(
(set) => ({
scope: "mine",
setScope: (scope) => set({ scope }),
...DEFAULTS,
// "Mine" is the clean personal view: entering it clears all filters,
// so Mine never carries filters. Switching to all/archived leaves
// filters intact (you can carry "owner = Bob" between them).
setScope: (scope) =>
set(scope === "mine" ? { scope, filters: EMPTY_AGENT_FILTERS } : { scope }),
toggleSort: (field) =>
set((state) =>
state.sortField === field
? {
sortDirection: state.sortDirection === "asc" ? "desc" : "asc",
}
: {
sortField: field,
sortDirection: AGENT_SORT_DEFAULT_DIRECTION[field],
},
),
setSortField: (field) =>
set((state) =>
state.sortField === field
? {}
: {
sortField: field,
sortDirection: AGENT_SORT_DEFAULT_DIRECTION[field],
},
),
setSortDirection: (direction) => set({ sortDirection: direction }),
toggleColumn: (key) =>
set((state) => ({
hiddenColumns: state.hiddenColumns.includes(key)
? state.hiddenColumns.filter((k) => k !== key)
: [...state.hiddenColumns, key],
})),
toggleFilter: (key, value) =>
set((state) => {
const list = state.filters[key] as string[];
const next = list.includes(value)
? list.filter((v) => v !== value)
: [...list, value];
// Applying any filter leaves the clean "mine" view for "all" —
// Mine is the no-filter mode (see setScope). Archived keeps its
// own scope (it can carry filters).
const scope = state.scope === "mine" ? "all" : state.scope;
return { scope, filters: { ...state.filters, [key]: next } };
}),
clearFilters: () => set({ filters: EMPTY_AGENT_FILTERS }),
}),
{
name: "multica_agents_view",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state) => ({ scope: state.scope }),
storage: createJSONStorage(() =>
createWorkspaceAwareStorage(defaultStorage),
),
partialize: (state) => ({
scope: state.scope,
sortField: state.sortField,
sortDirection: state.sortDirection,
hiddenColumns: state.hiddenColumns,
filters: state.filters,
}),
// On rehydrate, if the new workspace has no persisted value, reset to
// the default "mine" instead of leaving the previous workspace's in-
// memory scope in place. Default merge keeps current state when
// persisted is undefined, which would leak "all" across workspaces.
// the defaults instead of leaving the previous workspace's in-memory
// view state in place. Default merge keeps current state when
// persisted is undefined, which would leak state across workspaces.
merge: (persisted, current) => {
if (!persisted) return { ...current, scope: "mine" };
return { ...current, ...(persisted as Partial<AgentsViewState>) };
if (!persisted) return { ...current, ...DEFAULTS };
const p = persisted as Partial<AgentsViewState>;
// Deep-merge filters so a payload persisted before a new filter
// dimension existed (e.g. `owners`) still gets that key's default
// instead of dropping it to `undefined` and crashing `.length`.
return {
...current,
...p,
filters: { ...EMPTY_AGENT_FILTERS, ...(p.filters ?? {}) },
};
},
},
),

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { isBenignException } from "./benign-exceptions";
describe("isBenignException", () => {
it("drops ResizeObserver loop errors via $exception_list value", () => {
expect(
isBenignException({
$exception_list: [
{
type: "Error",
value: "ResizeObserver loop completed with undelivered notifications.",
},
],
}),
).toBe(true);
});
it("drops the older 'loop limit exceeded' phrasing", () => {
expect(
isBenignException({
$exception_list: [
{ type: "Error", value: "ResizeObserver loop limit exceeded" },
],
}),
).toBe(true);
});
it("drops when the signal is on the top-level $exception_message", () => {
expect(
isBenignException({
$exception_message: "ResizeObserver loop limit exceeded",
}),
).toBe(true);
});
it("matches case-insensitively", () => {
expect(
isBenignException({ $exception_message: "resizeobserver LOOP limit exceeded" }),
).toBe(true);
});
it("keeps real errors", () => {
expect(
isBenignException({
$exception_list: [
{
type: "TypeError",
value: "Cannot read properties of undefined (reading 'split')",
},
],
}),
).toBe(false);
});
it("does not match an unrelated mention of ResizeObserver", () => {
// Only the benign "loop" phrasing is silenced; a genuine bug in
// ResizeObserver usage must still be reported.
expect(
isBenignException({
$exception_message: "ResizeObserver is not defined",
}),
).toBe(false);
});
it("fails open on missing or malformed properties", () => {
expect(isBenignException(undefined)).toBe(false);
expect(isBenignException({})).toBe(false);
expect(isBenignException({ $exception_list: "not-an-array" })).toBe(false);
expect(isBenignException({ $exception_list: [null, 42, {}] })).toBe(false);
expect(isBenignException({ $exception_message: 123 })).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
// Known-benign browser exceptions that are pure noise in `$exception`
// telemetry. These are dropped ENTIRELY in `before_send` (not merely deduped by
// exception-dedupe.ts) — they carry no actionable signal, the browser
// self-recovers, and at scale they dominate the error stream, drowning real
// failures and burning the billed event budget.
//
// ResizeObserver "loop ..." errors are the canonical case: the spec fires them
// when observation callbacks don't settle within a single animation frame. The
// browser resumes delivery on the next frame, so nothing actually breaks. Every
// app that uses ResizeObserver (directly or via a UI library) emits them. The
// CSSWG explicitly considers them benign — see w3c/csswg-drafts#5023. Across
// Chrome versions the message is either "ResizeObserver loop limit exceeded"
// (older) or "ResizeObserver loop completed with undelivered notifications"
// (newer); both contain "ResizeObserver loop".
//
// The bar for adding a pattern here is high: it must be a benign,
// self-recovering error with no actionable signal. A real bug must never be
// silenced — when unsure, leave it to the dedupe fuse, which only caps repeats.
const BENIGN_MESSAGE_PATTERNS: RegExp[] = [/ResizeObserver loop/i];
/**
* Whether this `$exception` event is known-benign browser noise that should be
* dropped entirely. Reads the message from the (pre-redaction) event
* properties — the matched messages carry no PII, so reading them raw is safe,
* and matching before redaction avoids any chance of a scrub mangling the
* signal. Never throws: any unexpected shape returns `false` (keep the event),
* the fail-open direction `before_send` requires.
*/
export function isBenignException(
properties: Record<string, unknown> | undefined,
): boolean {
if (!properties || typeof properties !== "object") return false;
const messages: unknown[] = [properties.$exception_message];
const list = properties.$exception_list;
if (Array.isArray(list)) {
for (const entry of list) {
if (entry && typeof entry === "object" && "value" in entry) {
messages.push((entry as { value: unknown }).value);
}
}
}
for (const message of messages) {
if (typeof message !== "string") continue;
for (const pattern of BENIGN_MESSAGE_PATTERNS) {
if (pattern.test(message)) return true;
}
}
return false;
}

View File

@@ -0,0 +1,234 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { shouldDropException } from "./exception-dedupe";
const STORAGE_KEY = "mc_exc_fp";
// In-memory sessionStorage stand-in. Optional flags let a test force getItem /
// setItem to throw (quota, disabled storage) so we can assert the fail-open
// direction.
function makeStorage(opts: { throwOnGet?: boolean; throwOnSet?: boolean } = {}) {
const data = new Map<string, string>();
return {
data,
getItem(k: string): string | null {
if (opts.throwOnGet) throw new Error("getItem blocked");
return data.has(k) ? data.get(k)! : null;
},
setItem(k: string, v: string): void {
if (opts.throwOnSet) throw new Error("quota exceeded");
data.set(k, v);
},
removeItem(k: string): void {
data.delete(k);
},
clear(): void {
data.clear();
},
key(i: number): string | null {
return Array.from(data.keys())[i] ?? null;
},
get length(): number {
return data.size;
},
};
}
// Build a redacted-shape `$exception` properties object. By the time dedupe
// runs, redactExceptionProperties has already scrubbed value/message.
function exc(o: {
type?: string;
value?: string;
frames?: Array<Record<string, unknown>> | null;
} = {}): Record<string, unknown> {
const entry: Record<string, unknown> = {
type: o.type ?? "TypeError",
value: o.value ?? "boom",
};
if (o.frames !== null) {
entry.stacktrace = {
type: "raw",
frames: o.frames ?? [
{ filename: "app.tsx", function: "render", lineno: 10, colno: 5 },
],
};
}
return { $exception_list: [entry] };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("shouldDropException — per-fingerprint limit", () => {
beforeEach(() => {
vi.stubGlobal("sessionStorage", makeStorage());
});
it("keeps the first 3 of a fingerprint and drops from the 4th", () => {
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(true);
expect(shouldDropException(exc())).toBe(true);
});
it("treats different fingerprints independently — one does not drop the other", () => {
// Exhaust fingerprint A.
const a = () => exc({ type: "TypeError", value: "a" });
const b = () => exc({ type: "RangeError", value: "b" });
shouldDropException(a());
shouldDropException(a());
shouldDropException(a());
expect(shouldDropException(a())).toBe(true); // A fused
// B is untouched.
expect(shouldDropException(b())).toBe(false);
expect(shouldDropException(b())).toBe(false);
expect(shouldDropException(b())).toBe(false);
expect(shouldDropException(b())).toBe(true);
});
it("discriminates on colno (minified bundles collapse statements onto one line)", () => {
const at = (colno: number) =>
exc({ frames: [{ filename: "b.js", function: "x", lineno: 1, colno }] });
// Same file/line/function, different column → distinct fingerprints, so
// each keeps its own first-3 budget.
shouldDropException(at(10));
shouldDropException(at(10));
shouldDropException(at(10));
expect(shouldDropException(at(10))).toBe(true);
expect(shouldDropException(at(20))).toBe(false);
});
it("stores only a hash + counter — no raw value reaches storage", () => {
const storage = makeStorage();
vi.stubGlobal("sessionStorage", storage);
shouldDropException(exc({ value: "secret-marker-12345" }));
const blob = storage.data.get(STORAGE_KEY) ?? "";
expect(blob).not.toContain("secret-marker-12345");
expect(blob).not.toContain("app.tsx");
});
});
describe("shouldDropException — degraded frames", () => {
beforeEach(() => {
vi.stubGlobal("sessionStorage", makeStorage());
});
it("tolerates missing lineno/colno/function and still dedupes", () => {
const partial = () => exc({ frames: [{ filename: "only-file.js" }] });
expect(() => shouldDropException(partial())).not.toThrow();
shouldDropException(partial());
shouldDropException(partial());
expect(shouldDropException(partial())).toBe(true);
});
it("tolerates no stacktrace at all (fingerprints on type + value)", () => {
const noframes = () => exc({ frames: null });
shouldDropException(noframes());
shouldDropException(noframes());
shouldDropException(noframes());
expect(shouldDropException(noframes())).toBe(true);
});
it("keeps events with no usable signal (empty type/value/frames)", () => {
const empty = { $exception_list: [{ type: "", value: "" }] };
expect(shouldDropException(empty)).toBe(false);
expect(shouldDropException(empty)).toBe(false);
expect(shouldDropException(empty)).toBe(false);
expect(shouldDropException(empty)).toBe(false); // never fused — no fingerprint
});
it("is safe on undefined / malformed properties", () => {
expect(shouldDropException(undefined)).toBe(false);
expect(
shouldDropException({ $exception_list: "nope" as unknown as [] }),
).toBe(false);
});
});
describe("shouldDropException — storage fail-open", () => {
it("fails open when sessionStorage is undefined (SSR)", () => {
vi.stubGlobal("sessionStorage", undefined);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
});
it("fails open when accessing sessionStorage throws (sandboxed iframe)", () => {
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
get() {
throw new Error("blocked by sandbox");
},
});
try {
expect(() => shouldDropException(exc())).not.toThrow();
expect(shouldDropException(exc())).toBe(false);
} finally {
// Remove the throwing getter so it doesn't leak into other tests.
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: undefined,
});
}
});
it("fails open when getItem throws", () => {
vi.stubGlobal("sessionStorage", makeStorage({ throwOnGet: true }));
expect(() => shouldDropException(exc())).not.toThrow();
expect(shouldDropException(exc())).toBe(false);
});
it("fails open on a corrupted JSON blob and re-seeds clean state", () => {
const storage = makeStorage();
storage.data.set(STORAGE_KEY, "{not valid json");
vi.stubGlobal("sessionStorage", storage);
expect(shouldDropException(exc())).toBe(false);
// Blob is now valid JSON again with this fingerprint counted once.
const reseeded = JSON.parse(storage.data.get(STORAGE_KEY)!);
expect(typeof reseeded).toBe("object");
expect(Object.values(reseeded)).toEqual([1]);
});
it("setItem failure under-counts (fewer drops), never over-drops", () => {
vi.stubGlobal("sessionStorage", makeStorage({ throwOnSet: true }));
// Persisting the increment always fails, so the counter never advances and
// no event is ever dropped — the required "less drop" direction.
for (let i = 0; i < 5; i++) {
expect(shouldDropException(exc())).toBe(false);
}
});
});
describe("shouldDropException — distinct-fingerprint cap", () => {
it("keeps (does not track) a new fingerprint once the cap is reached", () => {
const storage = makeStorage();
// Seed 50 distinct fingerprints already at count 1.
const seed: Record<string, number> = {};
for (let i = 0; i < 50; i++) seed[`seed-${i}`] = 1;
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
vi.stubGlobal("sessionStorage", storage);
// The 51st, brand-new fingerprint is kept and NOT added to the blob.
expect(shouldDropException(exc({ value: "fingerprint-51" }))).toBe(false);
const after = JSON.parse(storage.data.get(STORAGE_KEY)!);
expect(Object.keys(after)).toHaveLength(50);
});
it("still fuses a fingerprint that is already tracked at the cap", () => {
const storage = makeStorage();
const seed: Record<string, number> = {};
for (let i = 0; i < 49; i++) seed[`seed-${i}`] = 1;
vi.stubGlobal("sessionStorage", storage);
// Track a real one to reach 50 distinct, exhausting its budget.
const target = () => exc({ value: "tracked-at-cap" });
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
shouldDropException(target()); // 50th distinct, count 1
shouldDropException(target()); // 2
shouldDropException(target()); // 3
expect(shouldDropException(target())).toBe(true); // fused despite cap
});
});

View File

@@ -0,0 +1,193 @@
// Session-scoped dedupe / throttle for `$exception` events.
//
// Runs in posthog-js `before_send` AFTER `redactExceptionProperties`, so the
// fingerprint is built purely from already-redacted fields — no raw message,
// value, or PII is ever written to storage (only a hash + a small counter).
//
// The fuse: keep the first EXCEPTION_SAMPLE_LIMIT of each (tab-session,
// fingerprint) pair and drop the rest. One runaway error — a render loop, a
// polling fetch that keeps throwing — otherwise emits 100+ identical
// `$exception` events per session (MUL-3331 / MUL-3330). Different fingerprints
// never affect each other.
//
// Safety invariant (load-bearing): `before_send` must never throw — a throw
// there breaks ALL event delivery — and every storage failure must fail OPEN.
// When in doubt we KEEP the event: emitting a duplicate is cheap, silently
// dropping a real first-occurrence error is not. setItem failures therefore
// only ever under-count (fewer drops), never over-drop.
//
// Scope is the browser tab session (`sessionStorage`): cleared when the tab
// closes, isolated per tab. This is intentionally NOT the posthog 30-min
// session — see the dedupe discussion on MUL-3331.
const STORAGE_KEY = "mc_exc_fp";
// Keep the first N of each fingerprint per session, drop from N+1.
const EXCEPTION_SAMPLE_LIMIT = 3;
// Cap distinct fingerprints tracked per session so a session that throws many
// *different* errors can't grow the blob without bound. Past the cap, new
// fingerprints are not tracked and fail open (kept).
const MAX_FINGERPRINTS = 50;
type FingerprintCounts = Record<string, number>;
/**
* Decide whether this already-redacted `$exception` event should be dropped as
* a session-level duplicate. Returns `true` to drop, `false` to keep.
*
* Never throws. Any missing fingerprint signal, unavailable/corrupt storage, or
* unexpected error results in `false` (keep) — the fail-open direction.
*/
export function shouldDropException(
properties: Record<string, unknown> | undefined,
): boolean {
const fingerprint = buildFingerprint(properties);
// Nothing stable to dedupe on → keep.
if (fingerprint === null) return false;
const storage = getSessionStorage();
if (!storage) return false;
// The entire read-decide-write sequence is guarded: a throw anywhere (parse,
// getItem, property access) degrades to keep.
try {
const counts = readCounts(storage);
const current = typeof counts[fingerprint] === "number" ? counts[fingerprint] : 0;
// Already at the limit for this fingerprint → fuse blows, drop.
if (current >= EXCEPTION_SAMPLE_LIMIT) return true;
// A brand-new fingerprint once the cap is reached: don't track it (would
// grow the blob), and keep the event.
if (current === 0 && Object.keys(counts).length >= MAX_FINGERPRINTS) {
return false;
}
counts[fingerprint] = current + 1;
try {
storage.setItem(STORAGE_KEY, JSON.stringify(counts));
} catch {
// Persisting the increment failed (quota / disabled). We still keep this
// event (return false below). The unpersisted increment only means the
// next identical error is also kept — under-counting toward the limit,
// i.e. fewer drops, never more. This is the required failure direction.
}
return false;
} catch {
return false;
}
}
/** Read and validate the counts blob. A corrupt or unexpected payload is
* treated as empty (fail open — this event is kept and re-seeds the blob). */
function readCounts(storage: Storage): FingerprintCounts {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as FingerprintCounts;
}
} catch {
// Corrupt JSON blob → start fresh.
}
return {};
}
/**
* Build a stable fingerprint from the redacted exception properties. Uses the
* exception type, the redacted message/value, and a single deterministic stack
* frame. Returns `null` when there's nothing stable to key on (keep the event).
*
* Every frame field (`function` / `lineno` / `colno`) is treated as optional
* and degrades to empty — minified or partial stacks must not throw or collapse
* every error into one bucket via an undefined access.
*/
function buildFingerprint(properties: Record<string, unknown> | undefined): string | null {
if (!properties || typeof properties !== "object") return null;
const list = properties.$exception_list;
const entry =
Array.isArray(list) && list.length > 0 && list[0] && typeof list[0] === "object"
? (list[0] as Record<string, unknown>)
: undefined;
const type = readString(entry?.type) ?? readString(properties.$exception_type) ?? "";
const value =
readString(entry?.value) ?? readString(properties.$exception_message) ?? "";
const frame = topFrame(entry);
// No signal at all → don't dedupe.
if (type === "" && value === "" && !frame) return null;
const parts = [type, value];
if (frame) {
// colno is kept (load-bearing): minified bundles collapse many statements
// onto one line, so line alone under-discriminates distinct errors.
parts.push(frame.filename, frame.fn, frame.lineno, frame.colno);
}
return hash(parts.join(""));
}
interface TopFrame {
filename: string;
fn: string;
lineno: string;
colno: string;
}
/**
* Extract a single deterministic stack frame for fingerprinting. We always take
* the LAST frame in the array — a fixed end, with NO engine/order detection.
* The same error within a session yields the same frames array and therefore
* the same chosen frame, which is all the fingerprint needs; we don't care
* which end is semantically "topmost". Missing pieces degrade to "".
*/
function topFrame(entry: Record<string, unknown> | undefined): TopFrame | null {
if (!entry) return null;
const stacktrace = entry.stacktrace;
const frames =
stacktrace && typeof stacktrace === "object"
? (stacktrace as Record<string, unknown>).frames
: undefined;
if (!Array.isArray(frames) || frames.length === 0) return null;
const f = frames[frames.length - 1];
if (!f || typeof f !== "object") return null;
const frame = f as Record<string, unknown>;
return {
filename: readString(frame.filename) ?? "",
fn: readString(frame.function) ?? "",
lineno: readNumberAsString(frame.lineno) ?? "",
colno: readNumberAsString(frame.colno) ?? "",
};
}
function readString(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function readNumberAsString(v: unknown): string | undefined {
return typeof v === "number" && Number.isFinite(v) ? String(v) : undefined;
}
/** djb2 — a tiny stable string hash. Only used to bound the storage-key length;
* collision risk across a single tab session's exceptions is negligible. */
function hash(input: string): string {
let h = 5381;
for (let i = 0; i < input.length; i++) {
h = ((h << 5) + h) ^ input.charCodeAt(i);
}
return (h >>> 0).toString(36);
}
/** Resolve `sessionStorage`, returning `null` if it is absent (SSR) or throws
* on access (sandboxed iframe, storage disabled). */
function getSessionStorage(): Storage | null {
try {
if (typeof sessionStorage === "undefined") return null;
return sessionStorage;
} catch {
return null;
}
}

View File

@@ -9,6 +9,7 @@ vi.mock("posthog-js", () => {
reset: vi.fn(),
identify: vi.fn(),
capture: vi.fn(),
captureException: vi.fn(),
};
return { default: mock };
});
@@ -22,10 +23,12 @@ async function loadModule() {
init: ReturnType<typeof vi.fn>;
register: ReturnType<typeof vi.fn>;
reset: ReturnType<typeof vi.fn>;
captureException: ReturnType<typeof vi.fn>;
};
posthog.init.mockClear();
posthog.register.mockClear();
posthog.reset.mockClear();
posthog.captureException.mockClear();
return { analytics, posthog };
}
@@ -183,3 +186,105 @@ describe("capturePageview", () => {
expect(capture).toHaveBeenCalledTimes(2);
});
});
describe("captureException", () => {
it("buffers a pre-init exception and flushes it on init", async () => {
const { analytics, posthog } = await loadModule();
const err = new Error("boom");
// Before init: buffered, nothing sent yet.
analytics.captureException(err, { source: "global-error" });
expect(posthog.captureException).not.toHaveBeenCalled();
// Init flushes the buffer in order.
analytics.initAnalytics({ key: "k", host: "" });
expect(posthog.captureException).toHaveBeenCalledTimes(1);
expect(posthog.captureException).toHaveBeenCalledWith(
err,
expect.objectContaining({ source: "global-error" }),
);
});
it("sends immediately once initialized", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
posthog.captureException.mockClear();
const err = new Error("later");
analytics.captureException(err);
expect(posthog.captureException).toHaveBeenCalledTimes(1);
expect(posthog.captureException).toHaveBeenCalledWith(err, expect.any(Object));
});
});
describe("before_send $exception pipeline", () => {
// before_send is registered inside posthog.init's config; pull it back out of
// the mock and drive it directly. Dedupe needs a working sessionStorage.
function makeMemoryStorage() {
const data = new Map<string, string>();
return {
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
clear: () => data.clear(),
key: (i: number) => Array.from(data.keys())[i] ?? null,
get length() {
return data.size;
},
};
}
type BeforeSend = (
e: { event: string; properties: Record<string, unknown> } | null,
) => unknown;
function getBeforeSend(posthog: { init: ReturnType<typeof vi.fn> }): BeforeSend {
const config = posthog.init.mock.calls[0]?.[1] as { before_send: BeforeSend };
return config.before_send;
}
function excEvent() {
return {
event: "$exception",
properties: {
$exception_list: [
{
type: "TypeError",
value: "Bad email bob@corp.com",
stacktrace: {
frames: [{ filename: "a.tsx", function: "f", lineno: 1, colno: 2 }],
},
},
],
},
};
}
beforeEach(() => {
vi.stubGlobal("sessionStorage", makeMemoryStorage());
});
it("redacts the message, then drops repeats past the per-fingerprint limit", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
const beforeSend = getBeforeSend(posthog);
const first = beforeSend(excEvent()) as { properties: { $exception_list: Array<{ value: string }> } };
// Redaction still runs before the fuse.
expect(first.properties.$exception_list[0]!.value).toBe("Bad email [redacted]");
expect(beforeSend(excEvent())).not.toBeNull();
expect(beforeSend(excEvent())).not.toBeNull();
// 4th identical exception is dropped.
expect(beforeSend(excEvent())).toBeNull();
});
it("passes non-$exception events through untouched", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
const beforeSend = getBeforeSend(posthog);
const evt = { event: "$pageview", properties: { $current_url: "/acme/issues" } };
expect(beforeSend(evt)).toBe(evt);
});
});

Some files were not shown because too many files have changed in this diff Show More