Compare commits

..

93 Commits

Author SHA1 Message Date
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
405 changed files with 36743 additions and 5152 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

@@ -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" },
@@ -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

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

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

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

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

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

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

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

@@ -293,21 +293,140 @@ export function createEnDict(allowSignup: boolean): LandingDict {
},
entries: [
{
version: "0.3.19",
date: "2026-06-09",
title: "More Reliable Agents, Attachments, and Issue Threads",
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",

View File

@@ -269,21 +269,140 @@ export function createJaDict(allowSignup: boolean): LandingDict {
},
entries: [
{
version: "0.3.19",
date: "2026-06-09",
title: "より安定したエージェント、添付ファイル、イシューの会話",
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 つだけ保持するため、結論を置き換えたときの表示が全員でそろいます。",
"リアルタイム接続が復帰したあと、イシュー画面はデータを更新し、古いタイムラインが残りにくくなりました。",
"エージェントタスクの開始者履歴が、古いタスク記録でもより信頼できるようになりました。",
"スクロール中の固定イシューコメントの境界がよりきれいに表示されます。",
"新しく投稿された添付ファイルは安定した非公開ダウンロードリンクを使うため、一時的なアップロードリンクが期限切れになっても画像やファイルを表示できます。",
"新規イシューから始まったオートパイロット実行は、割り当てられたタスクが完了できない場合に正しく失敗し、進行中のまま残りません。",
"受信箱からコメントリンクを開いたとき、デスクトップ画面全体ではなくイシューのタイムラインだけがスクロールします。",

View File

@@ -268,21 +268,140 @@ export function createKoDict(allowSignup: boolean): LandingDict {
},
entries: [
{
version: "0.3.19",
date: "2026-06-09",
title: "더 안정적인 에이전트, 첨부 파일, 이슈 대화",
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: [
"이슈 설명의 이미지와 파일은 웹과 데스크톱에서 다시 열어도 계속 표시됩니다.",
"각 이슈 대화 스레드는 해결 답변을 하나만 유지해 결론을 바꿀 때 모두에게 일관되게 보입니다.",
"실시간 연결이 복구된 뒤 이슈 화면이 데이터를 새로고침해 오래된 타임라인이 남지 않습니다.",
"에이전트 작업을 시작한 사람의 기록이 오래된 작업에서도 더 안정적으로 유지됩니다.",
"스크롤 중 고정된 이슈 댓글의 가장자리가 더 깔끔하게 보입니다.",
"새로 올린 첨부 파일은 안정적인 비공개 다운로드 링크를 사용해 임시 업로드 링크가 만료된 뒤에도 이미지와 파일이 계속 표시됩니다.",
"새 이슈에서 시작된 오토파일럿 실행은 배정된 작업이 완료되지 못하면 올바르게 실패 처리되어 진행 중에 멈춰 있지 않습니다.",
"받은함에서 댓글 링크를 열 때 데스크톱 화면 전체가 밀리지 않고 이슈 타임라인 안에서만 스크롤됩니다.",

View File

@@ -293,21 +293,140 @@ export function createZhDict(allowSignup: boolean): LandingDict {
},
entries: [
{
version: "0.3.19",
date: "2026-06-09",
title: "身份上下文优化、附件稳定性和 Issue 讨论升级",
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 时间线,不会把桌面窗口内容顶出可见区域",

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

@@ -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,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);
});
});

View File

@@ -13,6 +13,8 @@
// backend returns an empty key and this module stays inert.
import posthog from "posthog-js";
import { redactExceptionProperties } from "./redact-exception";
import { shouldDropException } from "./exception-dedupe";
export const EVENT_SCHEMA_VERSION = 2;
@@ -56,7 +58,8 @@ let lastCapturedPath: string | null = null;
// buffer stays small (~one step-transition worth).
type PendingOp =
| { kind: "event"; name: string; props?: Record<string, unknown> }
| { kind: "set"; props: Record<string, unknown> };
| { kind: "set"; props: Record<string, unknown> }
| { kind: "exception"; error: unknown; props?: Record<string, unknown> };
const pendingOps: PendingOp[] = [];
// Cached super-properties so resetAnalytics() can re-register them after
// posthog.reset() wipes the persisted set. Without this, logout / account
@@ -142,7 +145,32 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
autocapture: false,
capture_heatmaps: false,
capture_dead_clicks: false,
capture_exceptions: false,
// Exception autocapture IS on: posthog-js attaches window.onerror +
// unhandledrejection handlers and sends `$exception` events with the
// error's stack. Unlike the click/heatmap autocapture above, this is
// explicit failure signal (not behavioral noise) and is the one PostHog
// surface that natively handles thrown JS errors — see the failure-tier
// split in packages/core/diagnostics. (Production builds are minified;
// upload source maps to PostHog to de-minify the stacks.)
//
// Error messages can interpolate user input (a validation error with the
// typed value, a URL with a token), so `before_send` scrubs the message
// and `$exception_list[].value` before the event leaves the client. Stack
// frames (code locations) are kept. See redact-exception.ts.
//
// After scrubbing, a session-level fuse drops repeats of the same error so
// a render loop or a polling fetch that keeps throwing can't emit 100+
// identical `$exception` events per session (MUL-3331). The fingerprint is
// built only from the already-redacted fields, so no PII reaches storage.
// Order matters: redact first, then fingerprint the redacted shape.
capture_exceptions: true,
before_send: (event) => {
if (event && event.event === "$exception") {
redactExceptionProperties(event.properties);
if (shouldDropException(event.properties)) return null;
}
return event;
},
disable_session_recording: true,
disable_surveys: true,
});
@@ -184,6 +212,8 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
const op = pendingOps.shift()!;
if (op.kind === "event") {
posthog.capture(op.name, withClientEventProperties(op.props));
} else if (op.kind === "exception") {
posthog.captureException(op.error, withClientEventProperties(op.props));
} else {
capturePersonSet(op.props);
}
@@ -250,6 +280,31 @@ export function captureEvent(
posthog.capture(name, withClientEventProperties(props));
}
/**
* Report a caught exception that never reached `window.onerror` — a React
* render-phase error swallowed by an error boundary. Global uncaught errors
* and unhandled rejections are already captured automatically by posthog-js
* (`capture_exceptions: true`); this wrapper is for the boundary case those
* handlers can't see.
*
* Currently called by the web route-level `global-error`. Section-level
* `@multica/ui` ErrorBoundary can opt in by passing `onError={captureException}`
* at its call sites; it is not wired app-wide (those failures already degrade
* gracefully with fallback UI).
*
* Calls before initAnalytics() buffer in order, same as captureEvent.
*/
export function captureException(
error: unknown,
props?: Record<string, unknown>,
): void {
if (!initialized) {
pendingOps.push({ kind: "exception", error, props });
return;
}
posthog.captureException(error, withClientEventProperties(props));
}
/**
* Set (overwrite) person properties on the currently identified user.
* Mirrors the backend's `Event.Set` path — keep these aligned so the

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { redactText, redactExceptionProperties } from "./redact-exception";
describe("redactText", () => {
it("redacts email addresses", () => {
expect(redactText("Invalid email: alice@example.com")).toBe(
"Invalid email: [redacted]",
);
});
it("strips URL query strings that may carry tokens, keeping host + path", () => {
expect(
redactText("fetch failed https://api.multica.ai/issues?token=abc123secret"),
).toBe("fetch failed https://api.multica.ai/issues?[redacted]");
});
it("redacts long opaque tokens (JWT / API key / uuid)", () => {
expect(redactText("auth header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9")).toBe(
"auth header [redacted]",
);
});
it("keeps the non-sensitive part of a message intact", () => {
expect(redactText("Cannot read property 'x' of undefined")).toBe(
"Cannot read property 'x' of undefined",
);
});
it("passes through non-strings unchanged", () => {
expect(redactText(undefined)).toBeUndefined();
expect(redactText(42)).toBe(42);
});
});
describe("redactExceptionProperties", () => {
it("scrubs the message and each $exception_list value, leaving frames untouched", () => {
const props = {
$exception_message: "Bad email bob@corp.com",
$exception_list: [
{
type: "TypeError",
value: "Token leaked: ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
stacktrace: { frames: [{ filename: "app.tsx", lineno: 5, function: "render" }] },
},
],
};
redactExceptionProperties(props);
const entry = props.$exception_list[0]!;
expect(props.$exception_message).toBe("Bad email [redacted]");
expect(entry.value).toBe("Token leaked: [redacted]");
// Frames are code locations, not user data — left intact.
expect(entry.stacktrace.frames[0]).toEqual({
filename: "app.tsx",
lineno: 5,
function: "render",
});
expect(entry.type).toBe("TypeError");
});
it("is safe on undefined / malformed properties", () => {
expect(redactExceptionProperties(undefined)).toBeUndefined();
expect(() =>
redactExceptionProperties({ $exception_list: "not-an-array" as unknown as [] }),
).not.toThrow();
});
});

View File

@@ -0,0 +1,61 @@
// PII scrubbing for `$exception` events before they leave the client.
//
// Exception autocapture (`capture_exceptions: true`) sends the error message
// and stack. Stack frames are code locations (file / line / function) and are
// safe, but a message often interpolates user input — a validation error with
// the typed value, a parse error with the raw text, a network error with a URL
// that may carry a token. We keep the diagnostic shape (type + frames + the
// non-sensitive part of the message) and redact the patterns that carry user
// data. Wired as posthog-js `before_send`; see initAnalytics.
const REDACTED = "[redacted]";
// Order matters: strip query strings before the generic long-token rule, so a
// URL's host isn't itself shredded.
const PATTERNS: Array<[RegExp, string]> = [
// Emails.
[/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi, REDACTED],
// URL query/fragment (may carry tokens / PII) — keep scheme+host+path.
[/((?:https?|file|multica):\/\/[^\s?#]*)[?#]\S*/gi, `$1?${REDACTED}`],
// Long opaque tokens: JWTs, API keys, UUIDs, session ids (24+ chars).
[/\b[A-Za-z0-9_-]{24,}\b/g, REDACTED],
];
/** Redact PII-ish substrings from a free-text string. */
export function redactText(input: unknown): unknown {
if (typeof input !== "string" || input.length === 0) return input;
let out = input;
for (const [pattern, replacement] of PATTERNS) {
out = out.replace(pattern, replacement);
}
return out;
}
/**
* Redact the user-facing strings on a `$exception` event's properties in
* place: the top-level message and every entry's `value` in `$exception_list`.
* Types and stack frames are left untouched (code locations, not user data).
* Returns the same properties object for chaining.
*/
export function redactExceptionProperties(
properties: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!properties || typeof properties !== "object") return properties;
if ("$exception_message" in properties) {
properties.$exception_message = redactText(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) {
(entry as { value: unknown }).value = redactText(
(entry as { value: unknown }).value,
);
}
}
}
return properties;
}

View File

@@ -177,11 +177,29 @@ describe("ApiClient", () => {
status: 201,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({
id: "comment-1",
issue_id: "issue-1",
author_type: "member",
author_id: "user-1",
content: "updated",
type: "comment",
parent_id: null,
reactions: [],
attachments: [],
created_at: "2026-06-05T00:00:00Z",
updated_at: "2026-06-05T00:01:00Z",
}), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.previewCommentTriggers("issue-1", "hello", "parent-1");
await client.previewCommentTriggers("issue-1", "hello", "parent-1", "comment-1");
await client.createComment(
"issue-1",
"hello",
@@ -190,6 +208,7 @@ describe("ApiClient", () => {
["attachment-1"],
["agent-1"],
);
await client.updateComment("comment-1", "updated", ["attachment-1"], ["agent-1"]);
expect(fetchMock.mock.calls.map(([url, init]) => ({
url,
@@ -199,7 +218,7 @@ describe("ApiClient", () => {
{
url: "https://api.example.test/api/issues/issue-1/comments/trigger-preview",
method: "POST",
body: JSON.stringify({ content: "hello", parent_id: "parent-1" }),
body: JSON.stringify({ content: "hello", parent_id: "parent-1", editing_comment_id: "comment-1" }),
},
{
url: "https://api.example.test/api/issues/issue-1/comments",
@@ -212,6 +231,15 @@ describe("ApiClient", () => {
suppress_agent_ids: ["agent-1"],
}),
},
{
url: "https://api.example.test/api/comments/comment-1",
method: "PUT",
body: JSON.stringify({
content: "updated",
attachment_ids: ["attachment-1"],
suppress_agent_ids: ["agent-1"],
}),
},
]);
});
@@ -487,6 +515,109 @@ describe("ApiClient", () => {
});
});
describe("cancelTaskById response parsing", () => {
const taskResponse = {
id: "task-1",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "",
status: "cancelled",
priority: 0,
dispatched_at: null,
started_at: null,
completed_at: "2026-06-12T06:40:00Z",
result: null,
error: null,
created_at: "2026-06-12T06:39:00Z",
};
it("parses the cancelled chat message payload", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
...taskResponse,
cancelled_chat_message: {
chat_session_id: "session-1",
message_id: "message-1",
content: "restore me",
restore_to_input: true,
},
}), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
const result = await client.cancelTaskById("task-1");
expect(fetchMock.mock.calls[0]).toMatchObject([
"https://api.example.test/api/tasks/task-1/cancel",
{ method: "POST" },
]);
expect(result.cancelled_chat_message).toEqual({
chat_session_id: "session-1",
message_id: "message-1",
content: "restore me",
restore_to_input: true,
});
});
it("treats a null cancelled chat message as absent", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({
...taskResponse,
cancelled_chat_message: null,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
const client = new ApiClient("https://api.example.test");
const result = await client.cancelTaskById("task-1");
expect(result.id).toBe("task-1");
expect(result.cancelled_chat_message).toBeUndefined();
});
it.each([
["a missing task id", { ...taskResponse, id: undefined }],
[
"a malformed cancelled chat message",
{
...taskResponse,
cancelled_chat_message: {
chat_session_id: "session-1",
message_id: "message-1",
content: "restore me",
restore_to_input: "true",
},
},
],
["a null body", null],
])("falls back for %s", async (_label, body) => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
const client = new ApiClient("https://api.example.test");
const result = await client.cancelTaskById("task-1");
expect(result.id).toBe("");
expect(result.cancelled_chat_message).toBeUndefined();
});
});
describe("chat attachment wiring", () => {
it("uploadFile includes chat_session_id in the FormData body", async () => {
const fetchMock = vi.fn().mockResolvedValue(

View File

@@ -24,6 +24,9 @@ import type {
AgentActivityBucket,
AgentRunCount,
AgentRuntime,
RuntimeProfile,
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
InboxItem,
IssueSubscriber,
Comment,
@@ -66,6 +69,7 @@ import type {
ChatPendingTask,
PendingChatTasksResponse,
SendChatMessageResponse,
CancelTaskResponse,
Project,
CreateProjectRequest,
UpdateProjectRequest,
@@ -132,6 +136,7 @@ import {
AgentTemplateSchema,
AgentTemplateSummaryListSchema,
AttachmentResponseSchema,
CancelTaskResponseSchema,
ChildIssuesResponseSchema,
CommentsListSchema,
CommentTriggerPreviewSchema,
@@ -161,6 +166,8 @@ import {
AppConfigSchema,
type AppConfigResponse,
GroupedIssuesResponseSchema,
ListAutopilotsResponseSchema,
EMPTY_LIST_AUTOPILOTS_RESPONSE,
ListIssuesResponseSchema,
ListWebhookDeliveriesResponseSchema,
RuntimeHourlyActivityListSchema,
@@ -190,6 +197,7 @@ import {
EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
EMPTY_CANCEL_TASK_RESPONSE,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -484,6 +492,9 @@ export class ApiClient {
}
if (params?.open_only) search.set("open_only", "true");
if (params?.scheduled) search.set("scheduled", "true");
if (params?.date_field) search.set("date_field", params.date_field);
if (params?.date_start) search.set("date_start", params.date_start);
if (params?.date_end) search.set("date_end", params.date_end);
if (params?.sort_by) search.set("sort", params.sort_by);
if (params?.sort_direction) search.set("direction", params.sort_direction);
const path = `/api/issues?${search}`;
@@ -521,6 +532,9 @@ export class ApiClient {
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
if (params.date_field) search.set("date_field", params.date_field);
if (params.date_start) search.set("date_start", params.date_start);
if (params.date_end) search.set("date_end", params.date_end);
if (params.sort_by) search.set("sort", params.sort_by);
if (params.sort_direction) search.set("direction", params.sort_direction);
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
@@ -562,6 +576,7 @@ export class ApiClient {
prompt: string;
project_id?: string | null;
parent_issue_id?: string | null;
attachment_ids?: string[];
}): Promise<{ task_id: string }> {
return this.fetch("/api/issues/quick-create", {
method: "POST",
@@ -657,12 +672,13 @@ export class ApiClient {
});
}
async previewCommentTriggers(issueId: string, content: string, parentId?: string): Promise<CommentTriggerPreview> {
async previewCommentTriggers(issueId: string, content: string, parentId?: string, editingCommentId?: string): Promise<CommentTriggerPreview> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments/trigger-preview`, {
method: "POST",
body: JSON.stringify({
content,
...(parentId ? { parent_id: parentId } : {}),
...(editingCommentId ? { editing_comment_id: editingCommentId } : {}),
}),
});
return parseWithFallback(raw, CommentTriggerPreviewSchema, { agents: [] }, {
@@ -683,10 +699,14 @@ export class ApiClient {
return this.fetch("/api/assignee-frequency");
}
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
async updateComment(commentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
body: JSON.stringify({
content,
attachment_ids: attachmentIds,
...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}),
}),
});
}
@@ -1081,6 +1101,61 @@ export class ApiClient {
});
}
// ---------------------------------------------------------------------
// Custom runtime profiles (MUL-3284). All workspace-scoped: the caller
// passes the workspace id the same way the runtimes list resolves it.
// ---------------------------------------------------------------------
async listRuntimeProfiles(workspaceId: string): Promise<RuntimeProfile[]> {
const res = await this.fetch<{ runtime_profiles?: RuntimeProfile[] }>(
`/api/workspaces/${workspaceId}/runtime-profiles`,
);
return res.runtime_profiles ?? [];
}
async getRuntimeProfile(
workspaceId: string,
profileId: string,
): Promise<RuntimeProfile> {
return this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
);
}
async createRuntimeProfile(
workspaceId: string,
body: CreateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
return this.fetch(`/api/workspaces/${workspaceId}/runtime-profiles`, {
method: "POST",
body: JSON.stringify(body),
});
}
async updateRuntimeProfile(
workspaceId: string,
profileId: string,
patch: UpdateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
return this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
{
method: "PATCH",
body: JSON.stringify(patch),
},
);
}
async deleteRuntimeProfile(
workspaceId: string,
profileId: string,
): Promise<void> {
await this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
{ method: "DELETE" },
);
}
async getRuntimeUsage(
runtimeId: string,
params?: { days?: number; tz?: string },
@@ -1541,6 +1616,16 @@ export class ApiClient {
});
}
// Incremental attach: POST /skills/add only inserts the given ids (the
// server upserts with ON CONFLICT DO NOTHING), so callers don't need to
// read the agent's current skill set first.
async addAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
await this.fetch(`/api/agents/${agentId}/skills/add`, {
method: "POST",
body: JSON.stringify(data),
});
}
// Personal Access Tokens
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
return this.fetch("/api/tokens");
@@ -1683,8 +1768,11 @@ export class ApiClient {
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
}
async cancelTaskById(taskId: string): Promise<void> {
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
async cancelTaskById(taskId: string): Promise<CancelTaskResponse> {
const raw = await this.fetch<unknown>(`/api/tasks/${taskId}/cancel`, { method: "POST" });
return parseWithFallback(raw, CancelTaskResponseSchema, EMPTY_CANCEL_TASK_RESPONSE, {
endpoint: "POST /api/tasks/{taskId}/cancel",
});
}
async listAttachments(issueId: string): Promise<Attachment[]> {
@@ -1935,7 +2023,13 @@ export class ApiClient {
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
const search = new URLSearchParams();
if (params?.status) search.set("status", params.status);
return this.fetch(`/api/autopilots?${search}`);
const raw = await this.fetch<unknown>(`/api/autopilots?${search}`);
return parseWithFallback(
raw,
ListAutopilotsResponseSchema,
EMPTY_LIST_AUTOPILOTS_RESPONSE as ListAutopilotsResponse,
{ endpoint: "GET /api/autopilots" },
);
}
async getAutopilot(id: string): Promise<GetAutopilotResponse> {

View File

@@ -91,6 +91,67 @@ describe("ApiClient schema fallback", () => {
});
});
describe("listAutopilots", () => {
const baseAutopilot = {
id: "ap-1",
workspace_id: "ws-1",
title: "Daily triage",
description: null,
assignee_id: "agent-1",
status: "active",
execution_mode: "run_only",
issue_title_template: null,
created_by_type: "member",
created_by_id: "user-1",
last_run_at: null,
created_at: "2026-06-01T00:00:00Z",
updated_at: "2026-06-01T00:00:00Z",
};
it("falls back to an empty list when the response is malformed", async () => {
stubFetchJson({ autopilots: "not-an-array", total: 1 });
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilots();
expect(res).toEqual({ autopilots: [], total: 0 });
});
it("accepts an old-server row without assignee_type or derived fields", async () => {
// Pre-MUL-2429 servers omit assignee_type; servers older than the
// list-derived-fields change omit trigger_kinds/next_run_at/
// last_run_status. Both must parse, not fall back.
stubFetchJson({ autopilots: [baseAutopilot], total: 1 });
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilots();
expect(res.autopilots).toHaveLength(1);
expect(res.autopilots[0]?.assignee_type).toBe("agent");
expect(res.autopilots[0]?.trigger_kinds).toBeUndefined();
expect(res.autopilots[0]?.last_run_status).toBeUndefined();
});
it("passes derived fields through and tolerates enum drift", async () => {
stubFetchJson({
autopilots: [
{
...baseAutopilot,
assignee_type: "squad",
trigger_kinds: ["schedule", "some_future_kind"],
next_run_at: "2026-06-13T09:00:00Z",
last_run_status: "some_future_status",
},
],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilots();
expect(res.autopilots[0]?.trigger_kinds).toEqual([
"schedule",
"some_future_kind",
]);
expect(res.autopilots[0]?.next_run_at).toBe("2026-06-13T09:00:00Z");
expect(res.autopilots[0]?.last_run_status).toBe("some_future_status");
});
});
describe("getConfig", () => {
it("drops malformed daemon setup URLs instead of throwing", async () => {
stubFetchJson({

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
AppConfigSchema,
DashboardAgentRunTimeListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
@@ -270,3 +271,24 @@ describe("dashboard + runtime usage schema drift", () => {
expect((parsed[0] as Record<string, unknown>).region).toBe("us-east");
});
});
describe("AppConfigSchema cdn_signed drift", () => {
it("defaults cdn_signed to false when the server omits it (pre-MUL-3254 servers)", () => {
const parsed = AppConfigSchema.parse({ cdn_domain: "cdn.example.com" });
expect(parsed.cdn_signed).toBe(false);
});
it("coerces a malformed cdn_signed to false instead of failing the whole config", () => {
const parsed = AppConfigSchema.parse({
cdn_domain: "cdn.example.com",
cdn_signed: "yes",
});
expect(parsed.cdn_signed).toBe(false);
expect(parsed.cdn_domain).toBe("cdn.example.com");
});
it("keeps cdn_signed=true from a signing-enabled server", () => {
const parsed = AppConfigSchema.parse({ cdn_signed: true });
expect(parsed.cdn_signed).toBe(true);
});
});

View File

@@ -10,6 +10,7 @@ import type {
BillingPriceTier,
BillingTopupsPage,
BillingTransactionsPage,
CancelTaskResponse,
CreateAgentFromTemplateResponse,
CreateBillingCheckoutSessionResponse,
CreateBillingPortalSessionResponse,
@@ -25,6 +26,11 @@ import type { CloudRuntimeNode } from "../runtimes/cloud-runtime";
export interface AppConfigResponse {
cdn_domain: string;
// True when the CDN domain serves private content via time-bounded signed
// URLs (CloudFront signing) — raw storage URLs on that domain are NOT
// publicly fetchable and must not be used as native media sources
// (MUL-3254). Older servers omit the field; treat that as false.
cdn_signed?: boolean;
allow_signup: boolean;
google_client_id?: string;
posthog_key?: string;
@@ -163,6 +169,7 @@ const BooleanWithDefaultSchema = (fallback: boolean) =>
export const AppConfigSchema = z.object({
cdn_domain: z.string().default(""),
cdn_signed: BooleanWithDefaultSchema(false),
allow_signup: BooleanWithDefaultSchema(true),
google_client_id: OptionalStringSchema,
posthog_key: OptionalStringSchema,
@@ -175,6 +182,7 @@ export const AppConfigSchema = z.object({
export const EMPTY_APP_CONFIG: AppConfigResponse = {
cdn_domain: "",
cdn_signed: false,
allow_signup: true,
google_client_id: "",
daemon_server_url: "",
@@ -420,6 +428,67 @@ const RuntimeUsageByHourSchema = z.object({
export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema);
// ---------------------------------------------------------------------------
// Task cancellation (`POST /api/tasks/:id/cancel`)
//
// This response is consumed directly by chat recovery. The embedded task
// object stays loose so daemon/runtime fields can drift, but the optional
// `cancelled_chat_message` payload must be well-formed before the UI deletes
// a message from cache or restores text into the input.
// ---------------------------------------------------------------------------
const AgentTaskResponseSchema = z.object({
id: z.string(),
agent_id: z.string().default(""),
runtime_id: z.string().default(""),
issue_id: z.string().default(""),
status: z.string().default("cancelled"),
priority: z.number().default(0),
dispatched_at: z.string().nullable().default(null),
started_at: z.string().nullable().default(null),
completed_at: z.string().nullable().default(null),
result: z.unknown().default(null),
error: z.string().nullable().default(null),
failure_reason: z.string().optional(),
created_at: z.string().default(""),
chat_session_id: z.string().optional(),
autopilot_run_id: z.string().optional(),
parent_task_id: z.string().optional(),
attempt: z.number().optional(),
trigger_comment_id: z.string().optional(),
trigger_summary: z.string().optional(),
kind: z.string().optional(),
work_dir: z.string().optional(),
relative_work_dir: z.string().optional(),
}).loose();
const CancelledChatMessageSchema = z.object({
chat_session_id: z.string(),
message_id: z.string(),
content: z.string(),
restore_to_input: z.boolean().default(false),
}).loose();
export const CancelTaskResponseSchema = AgentTaskResponseSchema.extend({
cancelled_chat_message: CancelledChatMessageSchema.nullish()
.transform((value) => value ?? undefined),
}).loose();
export const EMPTY_CANCEL_TASK_RESPONSE: CancelTaskResponse = {
id: "",
agent_id: "",
runtime_id: "",
issue_id: "",
status: "cancelled",
priority: 0,
dispatched_at: null,
started_at: null,
completed_at: null,
result: null,
error: null,
created_at: "",
};
// ---------------------------------------------------------------------------
// Agent template catalog — `/api/agent-templates*` and the
// create-from-template response. The desktop app's create-agent picker
@@ -666,6 +735,47 @@ export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesRespon
total: 0,
};
// ---------------------------------------------------------------------------
// Autopilot list schema. Enums (`status`, `execution_mode`, `trigger_kinds`,
// `last_run_status`) stay `z.string()` so future server-side values degrade
// to a generic UI fallback. The three derived fields (trigger_kinds /
// next_run_at / last_run_status) are list-endpoint-only and absent on older
// servers — optional by contract, the list renders "—" without them.
// ---------------------------------------------------------------------------
const AutopilotListItemSchema = z.object({
id: z.string(),
workspace_id: z.string(),
title: z.string(),
description: z.string().nullable().optional(),
project_id: z.string().nullable().optional(),
// Older servers (pre-MUL-2429) omit assignee_type; "agent" is the
// documented default.
assignee_type: z.string().default("agent"),
assignee_id: z.string(),
status: z.string(),
execution_mode: z.string(),
issue_title_template: z.string().nullable().optional(),
created_by_type: z.string(),
created_by_id: z.string(),
last_run_at: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
trigger_kinds: z.array(z.string()).optional(),
next_run_at: z.string().nullable().optional(),
last_run_status: z.string().nullable().optional(),
}).loose();
export const ListAutopilotsResponseSchema = z.object({
autopilots: z.array(AutopilotListItemSchema).default([]),
total: z.number().default(0),
}).loose();
export const EMPTY_LIST_AUTOPILOTS_RESPONSE = {
autopilots: [],
total: 0,
};
export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
id: "",
workspace_id: "",

View File

@@ -0,0 +1,13 @@
export {
useAutopilotsViewStore,
AUTOPILOT_SCOPES,
AUTOPILOT_SORT_DEFAULT_DIRECTION,
AUTOPILOT_DEFAULT_HIDDEN_COLUMNS,
EMPTY_AUTOPILOT_FILTERS,
type AutopilotScope,
type AutopilotSortField,
type AutopilotSortDirection,
type AutopilotColumnKey,
type AutopilotListFilters,
type AutopilotsViewState,
} from "./view-store";

View File

@@ -0,0 +1,176 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
// View preferences for the autopilots list page: scope, sort, column
// visibility, and filters. Persisted per workspace (workspace-aware storage),
// per user/device (localStorage). Search text and row selection are
// deliberately NOT stored — they are session-scoped (same rationale as the
// skills view store).
// Status is the promoted SCOPE dimension (lifecycle stage, mutually
// exclusive) — it therefore does NOT appear in `filters`; one dimension
// lives in exactly one place. "all" = active + paused. There is no
// archived scope because the product has no UI archiving flow (the DB
// status value exists but nothing in the UI can set it); add the scope
// back together with archive actions if that flow ever ships.
export type AutopilotScope = "all" | "active" | "paused";
export const AUTOPILOT_SCOPES: AutopilotScope[] = ["all", "active", "paused"];
export type AutopilotSortField = "name" | "lastRun" | "nextRun" | "created";
export type AutopilotSortDirection = "asc" | "desc";
/** Per-field direction applied when the user switches TO that field. */
export const AUTOPILOT_SORT_DEFAULT_DIRECTION: Record<
AutopilotSortField,
AutopilotSortDirection
> = {
name: "asc",
lastRun: "desc",
nextRun: "asc",
created: "desc",
};
/** Multi-select filter state. Empty array per dimension = inactive. */
export interface AutopilotListFilters {
assignees: string[];
modes: string[];
triggerKinds: string[];
creators: string[];
}
export const EMPTY_AUTOPILOT_FILTERS: AutopilotListFilters = {
assignees: [],
modes: [],
triggerKinds: [],
creators: [],
};
// User-hideable columns. Name and the structural columns (checkbox, kebab)
// are always visible.
export type AutopilotColumnKey =
| "assignee"
| "trigger"
| "lastRun"
| "nextRun"
| "mode"
| "creator"
| "created";
/** Mode, creator and created are opt-in: hidden until the user enables them. */
export const AUTOPILOT_DEFAULT_HIDDEN_COLUMNS: AutopilotColumnKey[] = [
"mode",
"creator",
"created",
];
export interface AutopilotsViewState {
scope: AutopilotScope;
sortField: AutopilotSortField;
sortDirection: AutopilotSortDirection;
hiddenColumns: AutopilotColumnKey[];
filters: AutopilotListFilters;
setScope: (scope: AutopilotScope) => void;
/** Header click: toggles direction on the active field, otherwise switches
* to the field with its default direction. */
toggleSort: (field: AutopilotSortField) => void;
/** Display panel select: switches field (default direction), no toggle. */
setSortField: (field: AutopilotSortField) => void;
setSortDirection: (direction: AutopilotSortDirection) => void;
toggleColumn: (key: AutopilotColumnKey) => void;
toggleFilter: (key: keyof AutopilotListFilters, value: string) => void;
clearFilters: () => void;
}
const DEFAULTS = {
scope: "all" as AutopilotScope,
sortField: "lastRun" as AutopilotSortField,
sortDirection: AUTOPILOT_SORT_DEFAULT_DIRECTION.lastRun,
hiddenColumns: AUTOPILOT_DEFAULT_HIDDEN_COLUMNS,
filters: EMPTY_AUTOPILOT_FILTERS,
};
export const useAutopilotsViewStore = create<AutopilotsViewState>()(
persist(
(set) => ({
...DEFAULTS,
setScope: (scope) => set({ scope }),
toggleSort: (field) =>
set((state) =>
state.sortField === field
? {
sortDirection: state.sortDirection === "asc" ? "desc" : "asc",
}
: {
sortField: field,
sortDirection: AUTOPILOT_SORT_DEFAULT_DIRECTION[field],
},
),
setSortField: (field) =>
set((state) =>
state.sortField === field
? {}
: {
sortField: field,
sortDirection: AUTOPILOT_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];
return { filters: { ...state.filters, [key]: next } };
}),
clearFilters: () => set({ filters: EMPTY_AUTOPILOT_FILTERS }),
}),
{
name: "multica_autopilots_view",
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 defaults instead of leaving the previous workspace's in-memory
// view state in place (same rationale as the skills view store).
merge: (persisted, current) => {
if (!persisted) return { ...current, ...DEFAULTS };
const p = persisted as Partial<AutopilotsViewState>;
// Deep-merge filters so a payload persisted before a new filter
// dimension existed still gets that key's default instead of
// dropping it to undefined (which crashes `.length` reads).
return {
...current,
...p,
filters: { ...EMPTY_AUTOPILOT_FILTERS, ...(p.filters ?? {}) },
};
},
},
),
);
registerForWorkspaceRehydration(() =>
useAutopilotsViewStore.persist.rehydrate(),
);

View File

@@ -1,4 +1,4 @@
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION, newSessionDraftKey } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
export { useRecentContextStore, selectRecentContexts } from "./recent-context-store";
export type { RecentContextEntry, RecentContextType } from "./recent-context-store";

View File

@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createChatStore, newSessionDraftKey } from "./store";
import type { StorageAdapter } from "../types";
function memStorage(): StorageAdapter {
const m = new Map<string, string>();
return {
getItem: (k) => m.get(k) ?? null,
setItem: (k, v) => {
m.set(k, v);
},
removeItem: (k) => {
m.delete(k);
},
};
}
describe("newSessionDraftKey", () => {
it("derives a stable per-agent slot for an uncreated chat", () => {
expect(newSessionDraftKey("agent-1")).toBe("__new__:agent-1");
expect(newSessionDraftKey(null)).toBe("__new__:");
});
});
describe("chat store — migrateInputDraft", () => {
let store: ReturnType<typeof createChatStore>;
beforeEach(() => {
store = createChatStore({ storage: memStorage() });
});
it("moves a draft to the new key and clears the source", () => {
const from = newSessionDraftKey("agent-1");
store.getState().setInputDraft(from, "!file[x.pdf]()");
store.getState().migrateInputDraft(from, "session-1");
const drafts = store.getState().inputDrafts;
expect(drafts["session-1"]).toBe("!file[x.pdf]()");
// Source slot is cleared so it can't resurface in the next new chat.
expect(from in drafts).toBe(false);
});
it("is a no-op when the source draft is absent", () => {
store.getState().setInputDraft("session-1", "keep me");
store.getState().migrateInputDraft(newSessionDraftKey("agent-1"), "session-1");
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
});
it("is a no-op when from === to", () => {
store.getState().setInputDraft("session-1", "keep me");
store.getState().migrateInputDraft("session-1", "session-1");
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
});
});

View File

@@ -11,6 +11,16 @@ const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
const DRAFTS_KEY = "multica:chat:drafts";
/** Placeholder sessionId for a chat that hasn't been created yet. */
export const DRAFT_NEW_SESSION = "__new__";
/**
* Draft storage key for an as-yet-uncreated chat with the given agent.
* Shared by ChatInput (which writes the draft) and ensureSession (which
* migrates it onto the real session id the moment the session is created),
* so the two never disagree on the slot name.
*/
export function newSessionDraftKey(selectedAgentId: string | null): string {
return `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
}
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
@@ -84,6 +94,14 @@ export interface ChatState {
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
/**
* Move a draft from one key to another, deleting the source. Used when a
* chat session is lazily created: the `__new__:agent` draft is migrated
* onto the real sessionId so it isn't stranded under the abandoned key
* (which would resurface as a stale draft the next time a new chat opens
* for that agent).
*/
migrateInputDraft: (from: string, to: string) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
@@ -159,6 +177,19 @@ export function createChatStore(options: ChatStoreOptions) {
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
migrateInputDraft: (from, to) => {
if (from === to) return;
const current = get().inputDrafts;
if (!(from in current)) {
logger.debug("migrateInputDraft skipped (no source draft)", { from, to });
return;
}
logger.info("migrateInputDraft", { from, to });
const next = { ...current, [to]: current[from]! };
delete next[from];
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setChatSize: (w, h) => {
logger.debug("setChatSize", { w, h });
storage.setItem(CHAT_WIDTH_KEY, String(w));

View File

@@ -3,6 +3,10 @@ import { useStore } from "zustand";
interface ConfigState {
cdnDomain: string;
// True when cdnDomain serves private content via time-bounded signed URLs
// (CloudFront signing enabled server-side). Renderers must not treat a raw
// storage URL on that domain as a loadable media source (MUL-3254).
cdnSigned: boolean;
allowSignup: boolean;
googleClientId: string;
daemonServerUrl: string;
@@ -11,7 +15,7 @@ interface ConfigState {
// must be hidden. Defaults to false so unknown / older servers behave like
// the managed-cloud case.
workspaceCreationDisabled: boolean;
setCdnDomain: (domain: string) => void;
setCdnConfig: (config: { cdnDomain: string; cdnSigned?: boolean }) => void;
setAuthConfig: (config: {
allowSignup: boolean;
googleClientId?: string;
@@ -25,12 +29,13 @@ interface ConfigState {
export const configStore = createStore<ConfigState>((set) => ({
cdnDomain: "",
cdnSigned: false,
allowSignup: true,
googleClientId: "",
daemonServerUrl: "",
daemonAppUrl: "",
workspaceCreationDisabled: false,
setCdnDomain: (domain) => set({ cdnDomain: domain }),
setCdnConfig: ({ cdnDomain, cdnSigned = false }) => set({ cdnDomain, cdnSigned }),
setAuthConfig: ({ allowSignup, googleClientId = "", workspaceCreationDisabled = false }) =>
set({ allowSignup, googleClientId, workspaceCreationDisabled }),
setDaemonConfig: ({ daemonServerUrl = "", daemonAppUrl = "" }) =>

View File

@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../analytics", () => ({ captureEvent: vi.fn() }));
// A controllable PerformanceObserver stand-in: records the callback so a test
// can fire synthetic long-task entries, and counts constructions so we can
// assert idempotent install.
let lastCallback: ((list: { getEntries: () => Array<{ duration: number }> }) => void) | null;
let constructed: number;
let observeCalls: number;
class FakePerformanceObserver {
constructor(cb: (list: { getEntries: () => Array<{ duration: number }> }) => void) {
constructed += 1;
lastCallback = cb;
}
observe() {
observeCalls += 1;
}
}
function fireLongTask(duration: number) {
lastCallback?.({ getEntries: () => [{ duration }] });
}
async function load() {
vi.resetModules();
const mod = await import("./freeze-watchdog");
const { captureEvent } = await import("../analytics");
return {
installFreezeWatchdog: mod.installFreezeWatchdog,
captureEvent: captureEvent as unknown as ReturnType<typeof vi.fn>,
};
}
beforeEach(() => {
lastCallback = null;
constructed = 0;
observeCalls = 0;
vi.stubGlobal("window", {});
vi.stubGlobal("location", { pathname: "/acme/issues" });
vi.stubGlobal("PerformanceObserver", FakePerformanceObserver);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
vi.useRealTimers();
});
describe("installFreezeWatchdog", () => {
it("reports a long task at or above the 2s threshold with duration + path", async () => {
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
fireLongTask(2300);
expect(captureEvent).toHaveBeenCalledTimes(1);
expect(captureEvent).toHaveBeenCalledWith("client_unresponsive", {
source: "longtask",
duration_ms: 2300,
path: "/acme/issues",
});
});
it("ignores blocks below the threshold (normal render cost)", async () => {
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
fireLongTask(600);
fireLongTask(1999);
expect(captureEvent).not.toHaveBeenCalled();
});
it("is idempotent — a second install does not add a second observer", async () => {
const { installFreezeWatchdog } = await load();
installFreezeWatchdog();
installFreezeWatchdog();
expect(constructed).toBe(1);
expect(observeCalls).toBe(1);
});
it("is a no-op on the server (no window)", async () => {
vi.stubGlobal("window", undefined);
const { installFreezeWatchdog, captureEvent } = await load();
expect(() => installFreezeWatchdog()).not.toThrow();
expect(constructed).toBe(0);
expect(captureEvent).not.toHaveBeenCalled();
});
it("is a no-op when PerformanceObserver is unavailable", async () => {
vi.stubGlobal("PerformanceObserver", undefined);
const { installFreezeWatchdog } = await load();
expect(() => installFreezeWatchdog()).not.toThrow();
});
it("emits at most one client_unresponsive per 60s cooldown window", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
// A sustained freeze arrives as several long-task entries back to back.
fireLongTask(2500);
fireLongTask(2500);
fireLongTask(3000);
expect(captureEvent).toHaveBeenCalledTimes(1);
});
it("emits again only after the cooldown window elapses", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
fireLongTask(2500);
expect(captureEvent).toHaveBeenCalledTimes(1);
// Still inside the window → suppressed.
vi.advanceTimersByTime(59_999);
fireLongTask(2500);
expect(captureEvent).toHaveBeenCalledTimes(1);
// Window elapsed → emits again.
vi.advanceTimersByTime(1);
fireLongTask(2500);
expect(captureEvent).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,72 @@
// Client freeze watchdog — shared by web and desktop.
//
// Installs a long-task observer in the main thread. A "long task" is any
// stretch where the thread didn't return to the event loop; the browser
// already tracks them and delivers each entry once the thread unblocks, so
// even a multi-second freeze reports its duration after the fact. We only
// emit for blocks at or above FREEZE_THRESHOLD_MS to keep this to genuine
// "almost froze" events, not the normal 50600ms render cost.
//
// This is the in-thread, recoverable tier: it catches freezes the thread
// survives. A true non-recoverable hang (the thread never unblocks) can only
// be caught from outside — on desktop that is the main process `unresponsive`
// handler (see apps/desktop renderer-recovery). Web has no free external
// watcher, so this observer is its only freeze signal for now.
//
// The emitted `client_unresponsive` event carries `client_type` automatically
// (an analytics super-property), so desktop vs web is queryable without any
// platform branch here.
import { captureEvent } from "../analytics";
// 2s is well above the normal switch/render cost (measured 50600ms) and just
// under Electron's renderer-hang threshold, so an event here means "the user
// felt a real stall" without flooding on routine heavy renders.
const FREEZE_THRESHOLD_MS = 2000;
// A single sustained freeze is delivered by the browser as several separate
// long-task entries, so emitting per entry makes client_unresponsive volume
// grow without bound with the freeze length (MUL-3331). A global cooldown caps
// it to at most one event per window. Module-level (page-lifetime) state is the
// right scope here — it matches the `installed` singleton and resets on a full
// reload, which is rare and itself a distinct signal. No route bucketing: a
// global window is the most direct cap on volume.
const COOLDOWN_MS = 60_000;
let lastEmitMs = 0;
let installed = false;
/**
* Install the long-task observer. Safe to call multiple times (idempotent) and
* safe on the server (no-op when `window` / `PerformanceObserver` is absent).
* Call once from a client-only effect.
*/
export function installFreezeWatchdog(): void {
if (installed) return;
if (typeof window === "undefined") return;
if (typeof PerformanceObserver === "undefined") return;
installed = true;
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < FREEZE_THRESHOLD_MS) continue;
// Cooldown is checked only against qualifying freezes, so sub-threshold
// long tasks neither emit nor reset the window.
const now = Date.now();
if (now - lastEmitMs < COOLDOWN_MS) continue;
lastEmitMs = now;
captureEvent("client_unresponsive", {
source: "longtask",
duration_ms: Math.round(entry.duration),
path: typeof location !== "undefined" ? location.pathname : undefined,
});
}
});
// No `buffered: true` — we only want freezes from now on. Replaying tasks
// buffered before install would mislabel slow startup as a runtime freeze.
observer.observe({ type: "longtask" });
} catch {
// longtask entry type unsupported on this engine — nothing else to do.
}
}

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import type { InboxItem } from "../types";
import { deduplicateInboxItems } from "./queries";
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",
type: "new_comment",
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",
},
});
});
it("preserves the newest row's own comment anchor", () => {
const merged = deduplicateInboxItems([
item({
id: "older-comment",
created_at: "2026-06-15T08:00:00Z",
details: { comment_id: "comment-1" },
}),
item({
id: "newer-comment",
created_at: "2026-06-15T08:02:00Z",
details: { comment_id: "comment-2" },
}),
]);
expect(merged).toHaveLength(1);
expect(merged[0]?.id).toBe("newer-comment");
expect(merged[0]?.details?.comment_id).toBe("comment-2");
});
});

View File

@@ -50,7 +50,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

@@ -646,8 +646,17 @@ export function useCreateComment(issueId: string) {
export function useUpdateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds: string[] }) =>
api.updateComment(commentId, content, attachmentIds),
mutationFn: ({
commentId,
content,
attachmentIds,
suppressAgentIds,
}: {
commentId: string;
content: string;
attachmentIds: string[];
suppressAgentIds?: string[];
}) => api.updateComment(commentId, content, attachmentIds, suppressAgentIds),
onMutate: async ({ commentId, content, attachmentIds }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));

View File

@@ -13,6 +13,9 @@ import { BOARD_STATUSES } from "./config";
export interface IssueSortParam {
sort_by?: ListIssuesParams["sort_by"];
sort_direction?: ListIssuesParams["sort_direction"];
date_field?: ListIssuesParams["date_field"];
date_start?: ListIssuesParams["date_start"];
date_end?: ListIssuesParams["date_end"];
}
export const issueKeys = {
@@ -73,9 +76,13 @@ export const issueKeys = {
/** Full-issue timeline (single TanStack Query, no cursor). */
timeline: (issueId: string) =>
[...issueKeys.timelineAll(), issueId] as const,
/** Prefix across all issues — WS task lifecycle events invalidate here so
* an open composer's trigger preview refreshes when an agent's queue
* state changes (the dedup guard makes the answer queue-dependent). */
commentTriggerPreviewAll: () => ["issues", "comment-trigger-preview"] as const,
/** PREFIX for invalidation — the composer hook appends parent + content signature. */
commentTriggerPreview: (issueId: string) =>
["issues", "comment-trigger-preview", issueId] as const,
[...issueKeys.commentTriggerPreviewAll(), issueId] as const,
reactionsAll: () => ["issues", "reactions"] as const,
reactions: (issueId: string) =>
[...issueKeys.reactionsAll(), issueId] as const,

View File

@@ -1,5 +1,28 @@
import { beforeEach, describe, expect, it } from "vitest";
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { useIssueDraftStore } from "./draft-store";
import { setCurrentWorkspace } from "../../platform/workspace-storage";
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
// can round-trip values.
beforeAll(() => {
if (typeof globalThis.localStorage?.clear !== "function") {
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (k) => values.get(k) ?? null,
key: (i) => Array.from(values.keys())[i] ?? null,
removeItem: (k) => { values.delete(k); },
setItem: (k, v) => { values.set(k, v); },
};
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
}
});
const RESET_STATE = {
draft: {
@@ -11,6 +34,7 @@ const RESET_STATE = {
assigneeId: undefined,
startDate: null,
dueDate: null,
attachments: [],
},
lastAssigneeType: undefined,
lastAssigneeId: undefined,
@@ -46,6 +70,36 @@ describe("issue draft store — last assignee", () => {
expect(draft.assigneeId).toBeUndefined();
});
it("clearDraft removes persisted draft attachments", () => {
const { setDraft, clearDraft } = useIssueDraftStore.getState();
setDraft({
title: "first",
attachments: [
{
id: "11111111-2222-3333-4444-555555555555",
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "alice",
filename: "shot.png",
url: "https://cdn.example.test/shot.png",
download_url: "https://cdn.example.test/shot.png",
markdown_url: "https://app.example.test/api/attachments/11111111-2222-3333-4444-555555555555/download",
content_type: "image/png",
size_bytes: 123,
created_at: "2026-06-12T00:00:00Z",
},
],
});
clearDraft();
expect(useIssueDraftStore.getState().draft.attachments).toEqual([]);
});
it("setLastAssignee(undefined) lets the user opt back out of a default", () => {
const { setLastAssignee, clearDraft } = useIssueDraftStore.getState();
@@ -59,3 +113,42 @@ describe("issue draft store — last assignee", () => {
expect(useIssueDraftStore.getState().draft.assigneeType).toBeUndefined();
});
});
describe("issue draft store — legacy rehydrate", () => {
beforeEach(() => {
localStorage.clear();
setCurrentWorkspace(null, null);
});
afterEach(() => {
setCurrentWorkspace(null, null);
});
it("backfills attachments for drafts persisted before the field existed", async () => {
localStorage.setItem(
"multica_issue_draft:acme",
JSON.stringify({
state: {
draft: {
title: "legacy",
description: "body",
status: "todo",
priority: "none",
startDate: null,
dueDate: null,
// no `attachments` — written by a build that predates the field
},
},
version: 0,
}),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
const { draft } = useIssueDraftStore.getState();
expect(draft.title).toBe("legacy");
expect(draft.attachments).toEqual([]);
});
});

View File

@@ -1,6 +1,6 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "../../types";
import type { IssueStatus, IssuePriority, IssueAssigneeType, Attachment } from "../../types";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
@@ -13,6 +13,7 @@ interface IssueDraft {
assigneeId?: string;
startDate: string | null;
dueDate: string | null;
attachments: Attachment[];
}
const EMPTY_DRAFT: IssueDraft = {
@@ -24,6 +25,7 @@ const EMPTY_DRAFT: IssueDraft = {
assigneeId: undefined,
startDate: null,
dueDate: null,
attachments: [],
};
interface IssueDraftStore {
@@ -65,6 +67,18 @@ export const useIssueDraftStore = create<IssueDraftStore>()(
{
name: "multica_issue_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
// Drafts persisted by older builds predate fields added later (e.g.
// `attachments`). Backfill EMPTY_DRAFT defaults on rehydrate so every
// read site can rely on the declared IssueDraft shape instead of
// re-defending with `?? fallback`.
merge: (persistedState, currentState) => {
const persisted = (persistedState ?? {}) as Partial<IssueDraftStore>;
return {
...currentState,
...persisted,
draft: { ...EMPTY_DRAFT, ...persisted.draft },
};
},
},
),
);

View File

@@ -15,6 +15,13 @@ export type IssueGrouping = "status" | "assignee";
export type SwimlaneGrouping = "parent" | "project" | "assignee";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
export type IssueDateField = "created_at" | "updated_at";
export interface IssueDateFilter {
field: IssueDateField;
from: string;
to: string;
}
export const SWIMLANE_GROUPINGS: SwimlaneGrouping[] = ["parent", "project", "assignee"];
@@ -70,6 +77,7 @@ export interface IssueViewState {
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
dateFilter: IssueDateFilter | null;
// When true, the list only shows issues that currently have at least one
// agent task in `running` status. Drives the workspace "agents working"
// quick filter chip in the issues header. Not persisted across reloads —
@@ -103,6 +111,7 @@ export interface IssueViewState {
toggleProjectFilter: (projectId: string) => void;
toggleNoProject: () => void;
toggleLabelFilter: (labelId: string) => void;
setDateFilter: (filter: IssueDateFilter | null) => void;
toggleAgentRunningFilter: () => void;
hideStatus: (status: IssueStatus) => void;
showStatus: (status: IssueStatus) => void;
@@ -129,6 +138,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
projectFilters: [],
includeNoProject: false,
labelFilters: [],
dateFilter: null,
agentRunningFilter: false,
sortBy: "position",
sortDirection: "asc",
@@ -208,6 +218,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
? state.labelFilters.filter((id) => id !== labelId)
: [...state.labelFilters, labelId],
})),
setDateFilter: (filter) => set({ dateFilter: filter }),
toggleAgentRunningFilter: () =>
set((state) => ({ agentRunningFilter: !state.agentRunningFilter })),
hideStatus: (status) =>
@@ -236,6 +247,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
projectFilters: [],
includeNoProject: false,
labelFilters: [],
dateFilter: null,
agentRunningFilter: false,
}),
setSortBy: (field) => set({ sortBy: field }),
@@ -279,6 +291,8 @@ export const viewStorePersistOptions = (name: string) => ({
// state changes second-to-second, and a stored toggle would let users
// return to an unexplained empty list. Keep it ephemeral. See the
// field comment on IssueViewState.
// `dateFilter` is also intentionally not persisted: relative presets such
// as Today would otherwise become stale after a calendar-day rollover.
viewMode: state.viewMode,
grouping: state.grouping,
statusFilters: state.statusFilters,

View File

@@ -103,7 +103,9 @@
"./i18n/react": "./i18n/react.ts",
"./i18n/browser": "./i18n/browser.ts",
"./skills": "./skills/index.ts",
"./skills/frontmatter": "./skills/frontmatter.ts"
"./skills/frontmatter": "./skills/frontmatter.ts",
"./skills/stores": "./skills/stores/index.ts",
"./autopilots/stores": "./autopilots/stores/index.ts"
},
"dependencies": {
"@formatjs/intl-localematcher": "catalog:",

View File

@@ -49,7 +49,13 @@ export function AuthInitializer({
api
.getConfig()
.then((cfg) => {
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
if (cfg.cdn_domain) {
configStore.getState().setCdnConfig({
cdnDomain: cfg.cdn_domain,
// Old servers omit this — false keeps the previous behavior.
cdnSigned: cfg.cdn_signed === true,
});
}
configStore.getState().setAuthConfig({
allowSignup: cfg.allow_signup,
googleClientId: cfg.google_client_id,

View File

@@ -1,7 +1,8 @@
"use client";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { ApiClient } from "../api/client";
import { installFreezeWatchdog } from "../diagnostics/freeze-watchdog";
import { setApiInstance, setSchemaLogger } from "../api";
import { createAuthStore, registerAuthStore } from "../auth";
import { createChatStore, registerChatStore } from "../chat";
@@ -80,6 +81,12 @@ export function CoreProvider({
// eslint-disable-next-line react-hooks/exhaustive-deps
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth, identity), []);
// Client-only freeze watchdog — shared by web and desktop. No-op on the
// server and idempotent, so mounting it here covers both apps in one place.
useEffect(() => {
installFreezeWatchdog();
}, []);
// I18nProvider wraps everything else: server and client must use the same
// (locale, resources) to avoid hydration mismatch. Language switching goes
// through window.location.reload(), never client-side changeLanguage.

View File

@@ -1,7 +1,17 @@
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
export { useProjectDraftStore } from "./draft-store";
export { useProjectViewStore } from "./stores/view-store";
export {
useProjectViewStore,
PROJECT_SORT_DEFAULT_DIRECTION,
PROJECT_DEFAULT_HIDDEN_COLUMNS,
EMPTY_PROJECT_FILTERS,
type ProjectViewMode,
type ProjectSortField,
type ProjectSortDirection,
type ProjectColumnKey,
type ProjectListFilters,
} from "./stores/view-store";
export {
projectResourceKeys,
projectResourcesOptions,

View File

@@ -44,7 +44,7 @@ describe("useProjectViewStore", () => {
expect(useProjectViewStore.getState().viewMode).toBe("comfortable");
});
it("partialize persists only viewMode under the workspace-namespaced key", async () => {
it("partialize persists view prefs (no actions) under the workspace-namespaced key", async () => {
setCurrentWorkspace("acme", "ws_a");
await flush();
useProjectViewStore.getState().setViewMode("comfortable");
@@ -52,7 +52,14 @@ describe("useProjectViewStore", () => {
const raw = localStorage.getItem("multica_projects_view:acme");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.state).toEqual({ viewMode: "comfortable" });
expect(Object.keys(parsed.state).sort()).toEqual([
"filters",
"hiddenColumns",
"sortDirection",
"sortField",
"viewMode",
]);
expect(parsed.state.viewMode).toBe("comfortable");
});
it("rehydrates a different saved viewMode on workspace switch", async () => {

View File

@@ -5,29 +5,139 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
// Projects is the one dual-view list: a dense table (compact) and a card
// grid (comfortable), toggled by viewMode. Sort + filters feed both views;
// hiddenColumns only applies to the table. No scope (lead is optional and
// often an agent, so there's no strong personal axis; status is a 5-value
// lifecycle better expressed as a filter). Search stays session-local.
export type ProjectViewMode = "compact" | "comfortable";
export type ProjectSortField = "name" | "priority" | "status" | "progress" | "created";
export type ProjectSortDirection = "asc" | "desc";
export const PROJECT_SORT_DEFAULT_DIRECTION: Record<
ProjectSortField,
ProjectSortDirection
> = {
name: "asc",
priority: "desc",
status: "asc",
progress: "desc",
created: "desc",
};
/** Multi-select filters. Empty array per dimension = inactive. */
export interface ProjectListFilters {
/** ProjectStatus values. */
statuses: string[];
/** ProjectPriority values. */
priorities: string[];
/** Composite "type:id" lead refs (member or agent). */
leads: string[];
}
export const EMPTY_PROJECT_FILTERS: ProjectListFilters = {
statuses: [],
priorities: [],
leads: [],
};
// Hideable table columns. Name + status are the always-visible core (status
// is the project's defining lifecycle field), so they're not in this set.
export type ProjectColumnKey = "priority" | "progress" | "lead" | "issues" | "created";
/** Issues count is opt-in; the rest show by default (matching the prior
* compact table). */
export const PROJECT_DEFAULT_HIDDEN_COLUMNS: ProjectColumnKey[] = ["issues"];
export interface ProjectViewState {
viewMode: ProjectViewMode;
sortField: ProjectSortField;
sortDirection: ProjectSortDirection;
hiddenColumns: ProjectColumnKey[];
filters: ProjectListFilters;
setViewMode: (mode: ProjectViewMode) => void;
toggleSort: (field: ProjectSortField) => void;
setSortField: (field: ProjectSortField) => void;
setSortDirection: (direction: ProjectSortDirection) => void;
toggleColumn: (key: ProjectColumnKey) => void;
toggleFilter: (key: keyof ProjectListFilters, value: string) => void;
clearFilters: () => void;
}
const DEFAULTS = {
viewMode: "compact" as ProjectViewMode,
sortField: "created" as ProjectSortField,
sortDirection: PROJECT_SORT_DEFAULT_DIRECTION.created,
hiddenColumns: PROJECT_DEFAULT_HIDDEN_COLUMNS,
filters: EMPTY_PROJECT_FILTERS,
};
export const useProjectViewStore = create<ProjectViewState>()(
persist(
(set) => ({
viewMode: "compact",
...DEFAULTS,
setViewMode: (mode) => set({ viewMode: mode }),
toggleSort: (field) =>
set((state) =>
state.sortField === field
? { sortDirection: state.sortDirection === "asc" ? "desc" : "asc" }
: {
sortField: field,
sortDirection: PROJECT_SORT_DEFAULT_DIRECTION[field],
},
),
setSortField: (field) =>
set((state) =>
state.sortField === field
? {}
: {
sortField: field,
sortDirection: PROJECT_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];
return { filters: { ...state.filters, [key]: next } };
}),
clearFilters: () => set({ filters: EMPTY_PROJECT_FILTERS }),
}),
{
name: "multica_projects_view",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state) => ({ viewMode: state.viewMode }),
partialize: (state) => ({
viewMode: state.viewMode,
sortField: state.sortField,
sortDirection: state.sortDirection,
hiddenColumns: state.hiddenColumns,
filters: state.filters,
}),
// Deep-merge filters so a payload persisted before a filter dimension
// existed still gets that key's default (avoids `.length` on
// undefined). Same hardening as the other view stores.
merge: (persisted, current) => {
if (!persisted) return { ...current, viewMode: "compact" };
return { ...current, ...(persisted as Partial<ProjectViewState>) };
if (!persisted) return { ...current, ...DEFAULTS };
const p = persisted as Partial<ProjectViewState>;
return {
...current,
...p,
filters: { ...EMPTY_PROJECT_FILTERS, ...(p.filters ?? {}) },
};
},
}
)
);
registerForWorkspaceRehydration(() => useProjectViewStore.persist.rehydrate());
registerForWorkspaceRehydration(() => useProjectViewStore.persist.rehydrate());

View File

@@ -19,6 +19,7 @@ import {
applyChatDoneToCache,
applyWorkspaceUpdatedToCache,
handleInboxNew,
invalidateChatMessageQueries,
resolveInboxSourceSlug,
} from "./use-realtime-sync";
@@ -134,6 +135,18 @@ describe("applyChatDoneToCache", () => {
});
});
describe("invalidateChatMessageQueries", () => {
it("invalidates both legacy and paged chat message caches", () => {
const qc = createQueryClient();
const invalidate = vi.spyOn(qc, "invalidateQueries");
invalidateChatMessageQueries(qc, sessionId);
expect(invalidate).toHaveBeenCalledWith({ queryKey: chatKeys.messages(sessionId) });
expect(invalidate).toHaveBeenCalledWith({ queryKey: chatKeys.messagesPage(sessionId) });
});
});
describe("applyWorkspaceUpdatedToCache", () => {
const wsId = "ws-1";

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