Compare commits

..

241 Commits

Author SHA1 Message Date
Naiyuan Qing
b7857a6aa3 feat(chat): workspace-scoped attachment binding + fire-and-forget send (#4249)
* feat(chat): workspace-scoped attachment binding + fire-and-forget send

Uploads are now workspace-scoped: the chat session is created and
attachments are bound to the message at send time, so a paste/drop no
longer creates an empty session the user never sends.

- LinkAttachmentsToChatMessage returns the ids it actually bound; the
  client diffs requested-vs-bound and warns on partial bind, replacing
  an extra listChatMessagesPage fetch.
- Cancelling an empty chat task detaches attachments before deleting the
  user message (attachment FK is ON DELETE CASCADE) and returns them via
  cancelled_chat_message.attachments, so a restored draft can re-bind.
- SendChatMessageResponse.attachment_ids has no omitempty: "requested but
  bound zero" serializes [] so the client can tell it apart from an older
  server and still warn.
- Send is fire-and-forget: it no longer steals focus when the user has
  navigated to another session (guarded on the live store + new-chat agent
  id); the reply surfaces via the unread dot. commitInput gets clearEditor
  so a navigated-away commit doesn't wipe the editor now showing another
  session, while still clearing the sent draft's data.
- Draft restore is session-aware so a failed fire-and-forget send restores
  into the session it was sent from, never the one the user moved to.
- Removed the now-unreferenced migrateInputDraft store action.

Verified: core/views typecheck, chat-input (15) / store (3) / api client
(24) unit tests, go build + vet, handler SendChatMessage + CancelTaskByUser
DB tests. Full make check / E2E left to CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(chat): guard attachment survival on empty-chat cancel

Cancelling an empty chat task deletes the user message, and
attachment.chat_message_id is ON DELETE CASCADE (migration 083), so the
detach-before-delete in finalizeCancelledChatMessage is the only thing
keeping the user's attachment from being silently destroyed. Nothing
covered it.

Add a DB regression test that binds an attachment to the cancelled user
message and asserts: the row survives the cascade (chat_message_id NULL,
chat_session_id retained), the cancel response returns it via
cancelled_chat_message.attachments, and a resend re-binds it to the new
message. Verified red when the detach step is removed.

Related issue: MUL-3364

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(comment): pessimistic submit for comment/reply composers

The comment and reply composers cleared the editor after `await onSubmit`
returned, with no in-flight lock. On a slow send the WS `comment:created`
event already dropped the real comment into the timeline while the box
still held the same text + spinner, so it read as two comments. And
because `submitComment`/`submitReply` swallow errors (toast, no rethrow),
a failed send still reached `clearContent` and silently discarded the
user's draft.

Recover the comment/reply portion of the closed #4236: make the submit
callback resolve a success boolean (true on success, false on the caught
failure), lock the editor while in flight (pointer-events-none + dimmed
wrapper + aria-busy, since ContentEditor can't toggle Tiptap `editable`
post-mount), keep the button spinning, and clear only on success — a
failed send keeps the draft. Chat composer is out of scope (already
reworked on this branch); attachment binding is untouched.

Adds two view tests (in-flight lock then clear-on-success; failed send
keeps the draft); both verified red against the un-fixed code.

Related issue: MUL-3364

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 09:40:38 +08:00
Jiayuan Zhang
63b9b10df5 MUL-3328: add retry button for failed agent comments (#4217)
* MUL-3328: add retry button for failed agent comments

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

* MUL-3328: cover child-done system comments

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

* MUL-3328: restrict retry affordance to failures

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

* MUL-3328: clean migration whitespace

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:18:44 +02:00
Jiayuan Zhang
2d71872daa feat(autopilot): default subscriber template (MUL-2533) (#3060)
* feat(autopilot): default subscriber template (MUL-2533) — server

Add per-autopilot member subscriber template that fans out to every
issue the autopilot spawns. New autopilot_subscriber table; extend
issue_subscriber.reason with 'autopilot' so the dispatch-time fanout
is distinguishable from manual subscriptions.

API: POST/PATCH /api/autopilots accept a `subscribers` array (member
user_type only for the first version); PATCH semantics are full-replace.
GET returns subscribers on the detail endpoint; the list endpoint omits
them to avoid an N+1.

Dispatch: dispatchCreateIssue lists the template inside the same tx as
the issue insert and writes the rows with reason='autopilot' before
EventIssueCreated fires, so notification listeners see the full
subscriber set on the first event.

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

* feat(autopilot): default subscriber template (MUL-2533) — frontend

New SubscriberMultiSelect picker (members-only search + chips) wired
into the create / edit AutopilotDialog. The detail page renders the
saved template as read-only chips; edits flow through the dialog.

TS types expose the new `subscribers` field on Autopilot, plus an
AutopilotSubscriberInput shape for the create/update wire payloads.

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

* fix(autopilot): notify template subscribers on issue creation (MUL-2533)

The autopilot create-issue path fans out template subscribers into
issue_subscriber inside the same tx as the issue insert, but the
issue:created notification listener only matches handler.IssueResponse
payloads and only direct-notifies the assignee + @mentions. The autopilot
publishes a map[string]any payload, so the listener falls through and the
template subscribers never receive an inbox item for the creation event —
breaking OQ3 ("reason='autopilot' subscribers receive all subscription
events, consistent with reason='manual'").

Fix it where the divergence lives: in dispatchCreateIssue, right after
EventIssueCreated fires, write an inbox_item (type='issue_subscribed',
severity='info') for each member subscriber and publish EventInboxNew so
the recipient's inbox WS feed updates in real time. The write is after
the tx commit so an inbox hiccup can't roll back the issue; failures are
logged, not propagated. The manual path is unchanged — manual subscribers
don't exist at creation time, so there is nothing to notify there.

Adds a new InboxItemType 'issue_subscribed' (en/zh labels) and two
covering tests in autopilot_subscriber_test.go: one asserts the inbox
row lands for a template subscriber on dispatch, the other asserts the
no-subscriber autopilot stays silent.

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

* fix(autopilot): align subscriber PR with current main

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

* fix autopilot subscriber template transaction

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:18:06 +02:00
Bohan Jiang
2f398c36ad fix: gzip daemon claim responses (#4259)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 18:48:33 +08:00
LinYushen
c5eb778532 Revert "fix: keep runtime provider arbiter during profile rollout (#4251)" (#4258)
This reverts commit a08281a1b2.
2026-06-17 18:23:46 +08:00
Multica Eve
c27d35b7fe style(runtimes): unify header action buttons to match Add a computer (MUL-3368) (#4253)
The runtimes page header has three outline action buttons — "Add runtime",
"Cloud Runtime", and "Add a computer" — that previously rendered with
inconsistent dimensions and responsive behavior:

- "Add a computer" used `h-8 w-8 ... md:w-auto md:px-2.5` (icon-only
  below md, expands to icon+label on md+), with an `aria-label` for the
  icon-only state and the label wrapped in `<span class="hidden md:inline">`.
- "Add runtime" had no responsive className, no aria-label, and the label
  stayed visible at every width.
- "Cloud Runtime" had the same gaps as "Add runtime", plus the Cloud
  icon was sized `h-3 w-3` instead of `h-3.5 w-3.5` like the others.

Visually they read as three different button styles crammed into one
header. Aligning all three to the "Add a computer" pattern gives the
header a single, consistent action group at all breakpoints, fixes the
mobile layout (header no longer wraps onto multiple lines for users with
both Cloud Runtime enabled and admin role), and makes every icon-only
state screen-reader accessible.

Changes:
- Apply `h-8 w-8 gap-1 px-0 md:w-auto md:px-2.5` to all three buttons.
- Add `aria-label` to "Add runtime" and "Cloud Runtime".
- Wrap the label of "Add runtime" and "Cloud Runtime" in
  `<span className="hidden md:inline">` so they collapse to icon-only
  below md, matching "Add a computer".
- Normalize the Cloud icon size from `h-3 w-3` to `h-3.5 w-3.5`.

Verification: typecheck + lint + 1395 unit tests across @multica/views
all pass.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 17:56:39 +08:00
Multica Eve
5a3324e886 refactor(runtimes): trim redundant copy in custom runtimes dialog (MUL-3367) (#4254)
The Custom runtimes dialog repeated the same idea in three places: the
header tagline, the empty-state body, and the right-panel default state.
Per MUL-3367, tighten the prose so each surface adds new information:

- dialog_description: short tagline that points at the protocol families
  list directly below.
- empty_description: drops the "such as Claude or Codex" example (already
  enumerated in the protocols list right under it) and the "your daemon
  should run" filler.
- detail.default_title / default_description: replace the
  "Manage custom runtimes" + paragraph (which restated the header) with
  a quiet "Select a runtime" placeholder.
- detail.default_builtin_hint: dropped entirely. The supported-protocols
  section is already visible in the left column and each row carries a
  Reference badge — pointing at it from the right panel is noise.

Updated en, zh-Hans, ja and ko locales in lockstep, plus the dialog test
assertions.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 17:54:09 +08:00
Multica Eve
3077810049 fix(db): clean pending check suites on workspace delete (#4252)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 17:53:43 +08:00
Multica Eve
a08281a1b2 fix: keep runtime provider arbiter during profile rollout (#4251)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 17:51:47 +08:00
Multica Eve
23eba24076 MUL-3363: add 2026-06-17 changelog entry (#4248)
* docs: add 2026-06-17 changelog entry

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

* docs: shorten 2026-06-17 changelog copy

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 17:46:15 +08:00
Bohan Jiang
0f36c88855 fix(markdown): don't auto-link bare filenames as external URLs (#4245)
* fix(markdown): don't auto-link bare filenames as external URLs

Agent comments that mention a project file like `plan.md` were turned into
clickable links to https://plan.md (dead external site). linkify-it fuzzy
detection matches `plan.md` as a domain because its extension is also a valid
TLD (md = Moldova; likewise io, sh, rs, py).

Suppress schemeless (fuzzy) linkify matches whose token is a bare filename
(single segment ending in a known source/config extension). Explicit schemes
(`https://plan.md`) and real domains (`example.com`) are unaffected. The file
extension list is now shared between the file-path and bare-filename detectors
so they can't drift.

Fixes #4222

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

* docs(markdown): drop inaccurate .io example from bare-filename comment

io is not in the FILE_EXTENSIONS list, so .io domains are never suppressed.
Listing it as an example was misleading.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 17:06:09 +08:00
LinYushen
77ac17ef49 Make custom runtimes appear immediately (#4234)
* Make custom runtimes appear immediately

* Scope daemon profile refresh by authorized runtimes

* Relay runtime profile refresh hints

* Localize runtime profile close label
2026-06-17 16:00:22 +08:00
Multica Eve
af146b6dc7 fix(runtimes): re-enable Delete affordance for owners on self-healing runtimes (MUL-3352) (#4240)
The runtime detail page disabled the Delete button — and the list-page
kebab dropped its only action — whenever the runtime was an online
local daemon (isSelfHealingRuntime). The intent was to spare users a
delete that the daemon would silently undo via re-register, but the
side effect was an Owner staring at an action they had every
permission for, with no obvious explanation.

The fix moves the warning into the confirmation dialog instead of
hiding the action:

- runtime-detail.tsx: drop the disabled+tooltip branch around the
  Diagnostics card Delete button. canDelete (workspace owner/admin OR
  runtime owner) now fully governs visibility, and the button is
  always clickable when present.
- runtime-list.tsx: the row kebab no longer hides itself for
  self-healing runtimes. showActions follows along.
- delete-runtime-dialog.tsx: drop the defensive self-healing guard in
  handleConfirm (it returned early with a toast on the very click the
  user just confirmed). Add a SelfHealNotice banner that renders in
  both LightBody and CascadeBody when isSelfHealingRuntime is true,
  so the user sees the trade-off before pressing the destructive
  button rather than after.
- locales: drop delete_disabled_tooltip and the cascade
  self_healing_blocked_toast across en/zh-Hans/ja/ko; add a single
  detail.delete_dialog.self_heal_notice key that powers the banner.
- tests: flip the row-menu self-healing assertion (kebab is now
  visible), add a banner-present / banner-absent / confirm-proceeds
  triplet to delete-runtime-dialog.test.tsx, and pin the
  enabled-Delete-for-owner case on runtime-detail-visibility.test.tsx.

Verified with @multica/views typecheck (clean) and a targeted vitest
run across the four affected suites — 73/73 tests pass. Lint stays at
0 errors on the package.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:31:14 +08:00
Jiayuan Zhang
41586f1499 fix(github): surface in-flight CI on PR cards (MUL-2392) (#2887)
* fix(github): surface in-flight CI on PRs and recover out-of-order check_suite events

Two bugs caused PR cards to render "checks not reported yet" while CI
was actually running (MUL-2392):

1. handleCheckSuiteEvent dropped every action except `completed`, so
   `requested`/`rerequested` events (status queued/in_progress) never
   landed in the suite table. Aggregated `checks_pending` stayed at 0
   until the first suite finished, and the frontend fell through to the
   unknown bucket. Persist all actions; the ListPullRequestsByIssue
   aggregation already counts status<>completed as pending.

2. A check_suite for an unmirrored PR was logged and dropped, with no
   replay path. Add a `github_pending_check_suite` stash keyed by
   (workspace, repo, pr_number, suite_id); the pull_request webhook
   drains it after the PR upsert and replays each entry through the
   normal check_suite upsert. One-shot drain via DELETE … RETURNING
   keeps it idempotent and free of retry storms.

Follow-ups for fork PRs (empty `pull_requests[]`) and a more specific
frontend placeholder ship in separate issues.

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

* fix(github): guard pending check_suite stash against out-of-order events

UpsertPendingCheckSuite previously overwrote unconditionally on
conflict, so an older `requested/in_progress` event arriving after a
newer `completed/success` for the same suite would roll the stash
back to pending. The subsequent PR upsert then drained the stale
state and the PR card stuck on "pending" until the next suite. Mirror
the suite_updated_at guard from UpsertPullRequestCheckSuite and add a
regression test covering the PR-missing path.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 09:24:15 +02:00
Jiayuan Zhang
59263df748 feat(issues): unify trigger chip copy to will-start phrasing (#4215)
* feat(issues): unify trigger chip copy to will-start phrasing

Make the comment trigger chip's on/off states symmetric around the verb
'start' instead of mixing natural language with the 'trigger' jargon:

- on:           Will start when sent
- skipped:      Won't start this time
- all skipped:  No agents will start
- multi on:     N agents will start when sent

Updates all four locales (en/zh-Hans/ja/ko); CJK on-state copy already
reads as future-conditional so only the skip states are realigned to the
'start' verb. Updates the component test expectations to match.

Refs MUL-3211

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

* feat(issues): align restore hint to will-start phrasing

Carry the trigger-chip copy unification into the suppressed-agent restore
hint (trigger_click_to_restore), the last surface still mixing 'trigger'
with the chip's 'start' wording:

- en: Won't start this time. Click to restore.
- CJK: skip-state term realigned to the 'start' verb, rest unchanged.

Refs MUL-3211

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

* feat(issues): trim trigger preview popover copy and drop redundant reason lines

The active hover popover stacked header + reason + presence, repeating the
same fact across lines and again against the chip. Tighten it:

- Drop the reason line for assignee / @mention: the header (name · source)
  already conveys why they fire. Keep reason only for squad-leader (the link
  is non-obvious) and the unknown fallback, both trimmed of the duplicated
  name.
- Shorten presence (Starts right away. / Offline now — starts once online.)
  and de-jargon the skip/manage hints (no more 'trigger').
- Align the popover title to the chip wording (Will start when sent).

All four locales updated; removes the two now-unused reason keys.

Refs MUL-3211

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 09:12:45 +02:00
Wes
d26cac0008 Guard issue icons against unknown values (#4206) 2026-06-17 14:56:10 +08:00
Matt Voska
6f2e9aa7a8 feat(cli): add --thinking-level to agent create and update (#4170) (#4207)
The agent record already carries a top-level `thinking_level` field —
exposed by `agent get --output json`, settable from the web inspector,
and accepted/validated by `PUT /api/agents/{id}` — but the CLI had no
flag to write it. Scripted or version-controlled agent management could
set `--model` but not the thinking depth, forcing a drop to raw HTTP.

Add `--thinking-level` to `agent create` and `agent update`, mirroring
`--model`: a thin pass-through to the top-level `thinking_level` field.
On update an empty string clears it back to the runtime default (the
server reads it as a tri-state pointer: omitted = no change, "" = clear,
value = set). The CLI deliberately does not enumerate valid levels —
they are runtime/model-specific and the server already owns the catalog
(`agent.IsKnownThinkingValue`, `server/pkg/agent/thinking.go`), returning
a 400 for an unknown value or a runtime with no thinking concept, which
the CLI surfaces verbatim.

- server/cmd/multica/cmd_agent.go: register the flag on both commands,
  Changed-gate it into the request body, add it to the no-fields error.
- server/cmd/multica/cmd_agent_test.go: cover create/update send,
  unset-omission, empty-clears, the flag-exposed guard, and that a
  server-side rejection surfaces to the user.
- multica-creating-agents builtin skill + source map: document the new
  CLI write surface and re-derive shifted cmd_agent.go line numbers.

Closes #4170

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Matt Voska <voska@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 14:47:59 +08:00
Naiyuan Qing
acb1c3fb64 MUL-3331: dedupe/throttle client failure telemetry (#4231)
* feat(analytics): session-level $exception dedupe in before_send

A runaway client error (a render loop, a polling fetch that keeps
throwing) emits 100+ identical $exception events per session, which
showed up as a top PostHog cost/noise source after exception
autocapture landed (MUL-3331 / MUL-3330).

Add a per-tab-session fuse in before_send, after redaction: fingerprint
the already-redacted exception (type + redacted value + one deterministic
stack frame incl. colno), keep the first 3 per (session, fingerprint),
drop the rest. State lives in sessionStorage as a hash->count blob, so no
PII is persisted. Every storage failure fails open (keep the event);
before_send never throws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(diagnostics): global 60s cooldown for client_unresponsive

A single sustained freeze is delivered as several long-task entries, so
emitting per entry made client_unresponsive volume grow without bound
with the freeze length (MUL-3331). Cap it with a module-level (page-
lifetime) global cooldown: at most one event per 60s window. No route
bucketing — a global window is the most direct cap on volume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(analytics): add exception-dedupe safety matrix

This file was written alongside the dedupe implementation but missed the
original commit, so the $exception fail-open / cap / fingerprint matrix
never landed on the branch. No implementation change — the tests pass as
written against the existing exception-dedupe.ts.

Covers: first-3-then-drop, fingerprint independence, colno discrimination,
hash-only storage (no PII), degraded/missing frames, undefined / throwing
/ corrupt-JSON sessionStorage fail-open, setItem-failure under-counts, and
the distinct-fingerprint cap (51st new fingerprint kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 14:42:38 +08:00
Bohan Jiang
114a1ffb8f Fix: fail fast when Codex app-server exits MUL-2840 (#4228)
* fix: fail fast when codex process exits

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

* fix: fail active codex turns on process exit

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

* fix: prefer codex context terminal states

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

---------

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

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

* fix: address runtime profile dialog nits

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 13:39:48 +08:00
AdamQQQ
3030c803bf fix(scripts): fix version comparison to prevent unnecessary CLI upgrades (#4227)
`multica version` now outputs two lines (version + go info). The old
`awk '{print $2}'` captured both lines, causing the version comparison
to always mismatch and triggering unnecessary upgrades.

Fixes https://github.com/multica-ai/multica/issues/4226

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-17 13:27:24 +08:00
Multica Eve
6bb8cac9ea MUL-3332: daemon picks up new custom runtime profiles without restart (#4225)
* MUL-3332: daemon picks up new custom runtime profiles without restart

The workspaceSyncLoop's already-tracked branch refreshed only settings and
repos via refreshWorkspaceRepos and never re-fetched runtime profiles, so
a custom runtime profile created via the web UI / CLI did not become a
registered runtime row until the daemon restarted (or a runtimeGone
recovery happened to fire).

Detect server-side profile drift each sync tick by hashing the workspace's
profile list with profileSetSignature(), caching the digest on
workspaceState.profileSetSig, and triggering reregisterWorkspaceAfterRuntimeGone
when the live signature differs from the cached one. Steady-state syncs cost
exactly one extra GetRuntimeProfiles round trip; only real drift fans out to
a Register call.

The fetch is best-effort: a 404 / network blip preserves the cached signature
so a transient failure cannot loop the daemon into spurious re-registrations.

Tests in runtime_profile_drift_test.go cover digest stability under reorder,
field-by-field drift detection (add / enable-flip / command_name /
protocol_family / fixed_args / visibility), the no-drift hot path (no
re-register), the new-profile drift path (single re-register + index update +
sig converges), and best-effort fetch error handling.

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

* MUL-3332: split orphan recovery from profile drift; converge to zero

Addresses two blocking review concerns on #4225 (raised by GPT-Boy):

1. Profile drift must not kill running tasks on existing runtimes.

   The first cut reused reregisterWorkspaceAfterRuntimeGone, which after
   re-register calls /recover-orphans for every returned runtime ID. The
   server's RecoverOrphanedTasksForRuntime hard-fails every
   dispatched/running/waiting_local_directory row on that runtime — the
   correct response when a runtime row was actually deleted server-side,
   but a catastrophic false positive on profile drift: a built-in runtime
   still actively executing the user's tasks would have its work killed
   just because the user added an unrelated sibling custom profile.

   Fix: extract applyRegisterResponseInPlace as the shared in-place state
   converger between the two paths, and stop calling /recover-orphans from
   the drift path. reregisterWorkspaceAfterRuntimeGone keeps the
   /recover-orphans call because in that path the rows really were gone.

2. Disabling the only profile on a custom-only daemon must converge.

   The first cut hit registerRuntimesForWorkspace's len(runtimes)==0 guard
   and bailed out, so the disabled profile's runtime stayed alive in
   local tracking and on the server (still polling, still heartbeating,
   still online for the full 150 s stale-heartbeat window).

   Fix: introduce ErrNoRuntimesToRegister as a sentinel, have
   registerRuntimesForWorkspace return profileSig even on the empty case
   (so the drift path can cache the converged-empty signature), and have
   the drift refresh's error handler take a convergeWorkspaceRuntimesToZero
   branch that clears local runtimeIDs / runtimeIndex entries and
   Deregisters the orphaned IDs so the server marks them offline
   immediately. The same Deregister step also runs on partial drift (a
   built-in survives, the disabled profile's runtime drops) so the user
   sees the dropped runtime go offline within the next sync tick instead
   of after the 150 s sweep.

Tests:

- TestRefreshWorkspaceRuntimeProfiles_DriftWithRunningRuntimeSkipsOrphanRecovery
  (mixed built-in + custom, add another profile, asserts zero
  /recover-orphans calls).
- TestRefreshWorkspaceRuntimeProfiles_DisableConvergesCustomOnlyDaemon
  (custom-only daemon, disable only profile, asserts local state
  cleared, signature converges to empty digest, Deregister called with
  the orphaned ID, no recover-orphans, follow-up tick is no-op).
- TestRefreshWorkspaceRuntimeProfiles_DisableOneOfManyDeregistersDroppedID
  (partial drift: only the dropped ID is Deregistered, surviving
  built-in is left alone and not orphan-recovered).
- TestRefreshWorkspaceRuntimeProfiles_NewProfileTriggersReregister
  extended to also assert no /recover-orphans calls.
- TestRegisterRuntimes_SkipsProfileNotOnPath strengthened to assert the
  ErrNoRuntimesToRegister sentinel and that profileSig is still returned
  on the empty path.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 12:36:30 +08:00
Bohan Jiang
64ce459e30 fix(github): preserve early installation webhook metadata (#4193)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 12:26:25 +08:00
LinYushen
1f5cb51d4e MUL-3284: Web UI + CLI (custom runtime PR3) (#4177)
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override

- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
  update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
  / unset-path for a per-machine command override. protocol-family validated
  client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
  validated; update only sends changed flags (protocol_family immutable);
  delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
  CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
  round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
  appendProfileRuntimes now prefers an override path when set AND executable,
  else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
  bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
  relative-path rejection, config preservation); cli/config round-trip;
  daemon prefers-override / falls-back-when-not-executable.

Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.

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

* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page

Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).

- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
  whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
  RuntimeProtocolFamily / RuntimeProfile types; api client
  list/get/create/update/deleteRuntimeProfile against
  /api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
  mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
  rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
  protocol family → step 2 display_name/command_name/description; edit form
  for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
  409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
  create/edit/delete flow is keyboard + screen-reader operable.

Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.

Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.

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

* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)

Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".

- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
  detail row, the create body field, the update patch field, and the form
  textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
  fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
  update and stopped sending `fixed_args`; updated the "no fields" message.
  Test now asserts the CLI never sends fixed_args.

Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.

Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.

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

* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)

Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.

- Server (runtime_profile.go): drop `visibility` from the create + update
  request structs; CreateRuntimeProfile always stores 'workspace'
  (runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
  removed validRuntimeProfileVisibility. The column + response field stay
  (always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
  the VisibilityOption component, the detail row, the visibility state, and the
  create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
  (profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
  Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
  the VISIBILITY list column; removed validateVisibility; stopped sending the
  field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
  visibility:"private" -> response and DB row are 'workspace'); CLI create test
  now asserts visibility is never sent.

Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.

Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 11:38:17 +08:00
LinYushen
52e76e7b23 MUL-3284: server API + daemon (custom runtime PR2) (#4149)
* MUL-3284: add runtime_profile schema (custom runtime PR1)

Schema-only foundation for custom runtimes. Additive migration 120:

- New workspace-level `runtime_profile` table: the shared, team-visible
  definition of a custom runtime (e.g. an in-house Codex wrapper).
  protocol_family is CHECK-constrained to the exact backend list in
  agent.New() (server/pkg/agent/agent.go). The only args column is
  `fixed_args` (args every agent on the runtime must inherit); there is
  deliberately no generic per-agent args field — those stay on
  agent.custom_args.
- `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE
  CASCADE): NULL = built-in runtime, non-NULL = a registered instance of
  a custom profile.
- Partial unique index agent_runtime_workspace_daemon_profile_key on
  (workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL.

The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left
INTACT so the existing registration upsert
(ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps
resolving its arbiter and the server stays green. Converting that key to
a partial (WHERE profile_id IS NULL) index and making the upsert
profile-aware is PR2's registration work, not this migration.

Verified up + down against Postgres 17: full `migrate up` applies 120;
schema shows the table, column, partial index and intact legacy
constraint; functional checks pass (partial index blocks dup
(ws,daemon,profile), allows same profile on another daemon; CHECK and
display_name uniqueness reject bad input; legacy ON CONFLICT still
resolves; profile delete cascades to instances); down/up round-trip is
clean.

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

* MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix)

Per review (house rule: no new database foreign keys / cascades; relational
integrity lives in the application layer):

- runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE
  -> plain UUID NOT NULL.
- runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL
  -> plain UUID.
- agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE
  -> plain UUID.

CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index,
and the partial unique index agent_runtime_workspace_daemon_profile_key are
unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint
remains untouched.

Behavioral consequence: the database no longer auto-removes a profile's
agent_runtime instance rows on profile delete. That cleanup moves into PR2's
profile-delete path. Up-migration comments document this; down-migration
comment no longer references FKs/cascade.

Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on
the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK
and display_name uniqueness still reject bad input; deleting a profile now
leaves the runtime row orphaned (proving cascade is gone); down/up round-trip
clean with the legacy constraint intact.

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

* MUL-3284 PR2 (server): runtime_profile CRUD + profile-aware registration

Server/DB half of the custom-runtime feature.

- Migration 121: convert the legacy UNIQUE (workspace_id, daemon_id, provider)
  constraint on agent_runtime into a partial unique index scoped to built-in
  rows (WHERE profile_id IS NULL). With 120's partial index on profile_id this
  lets one daemon host the built-in provider AND custom profiles of the same
  protocol family without collision.
- Queries: runtime_profile CRUD; ListEnabledRuntimeProfilesForWorkspace
  (daemon-facing); CountAgentsByProfile + DeleteAgentRuntimesByProfile for the
  app-layer cascade; profile-aware UpsertAgentRuntimeWithProfile; the built-in
  UpsertAgentRuntime ON CONFLICT now spells out WHERE profile_id IS NULL so it
  targets the right partial index. sqlc regenerated.
- agent.SupportedTypes / IsSupportedType: single-source protocol_family
  whitelist, in lockstep with agent.New and the migration 120 CHECK.
- Handlers + routes: runtime_profile CRUD (member-read, admin-write) with
  protocol_family whitelist validation, display_name uniqueness (409), and
  fixed_args validation (no generic per-agent args — iron rule); a
  daemon-token endpoint GET /api/daemon/workspaces/{id}/runtime-profiles;
  DeleteRuntimeProfile does the app-layer cascade (delete instance rows then
  profile, in one tx) and refuses (409) while active agents are bound.
- DaemonRegister accepts an optional per-runtime profile_id: validates the
  profile belongs to the workspace and is enabled, registers via the
  profile-aware upsert, and skips legacy hostname merge for custom rows.
  AgentRuntimeResponse now carries profile_id.

Verified on Postgres 17: migrate up through 121; built-in + custom codex
coexist on one daemon; both upsert arbiters are idempotent; delete-by-profile
cascade removes only the custom instance; migrate down reverses 121 then 120
and replays clean. go build ./... and go vet pass; handler test package
compiles.

Daemon-side wiring (fetch profiles, PATH-resolve command_name, register with
profile_id, exec uses command_name) lands in a follow-up commit on this branch.

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

* MUL-3284 PR2 (daemon): pull profiles, PATH-resolve, register, exec command

Daemon-side half of custom runtime profiles, against the server contract on
this branch.

- client.go: GetRuntimeProfiles(workspaceID) -> GET
  /api/daemon/workspaces/{id}/runtime-profiles (mirrors GetWorkspaceRepos);
  RuntimeProfile / RuntimeProfilesResponse types.
- types.go: Runtime gains profile_id (parsed from the register response so
  runtimeIndex carries it).
- daemon.go:
  * appendProfileRuntimes — called inside registerRuntimesForWorkspace before
    the empty-runtimes guard. Best-effort fetch (older server 404s are logged
    and swallowed; never fails registration). Per enabled profile: resolve
    command_name via PATH (exec.LookPath, behind a `lookPath` test hook),
    skip+log when absent, best-effort version probe, record the resolved
    absolute path keyed by profile_id, and append a registration entry
    {name, type=protocol_family, version, status:online, profile_id}. A
    custom-only host (no built-in agents) still registers.
  * profileCommandPaths map (guarded by d.mu) + recordProfileCommandPath /
    customCommandPathForRuntime helpers.
  * runTask: looks up the claimed task's RuntimeID -> profile command path and
    overrides the executable path, synthesizing an AgentEntry so a custom
    runtime runs even when the host has no built-in agent of the same
    provider. provider (=protocol_family) is unchanged so agent.New still
    selects the right backend.
- Tests: GetRuntimeProfiles request shape; profile runtime appended + path
  recorded (custom-only host); profile skipped when command not on PATH;
  profiles-fetch-404 is best-effort; customCommandPathForRuntime bookkeeping.
- agent: lockstep test pinning SupportedTypes to agent.New and the migration
  120 protocol_family CHECK.

Iron rule honored: profile carries no generic per-agent args. fixed_args are
parsed and carried but intentionally NOT wired into the launch command yet
(optional/best-effort; explicit TODO(MUL-3284) in appendProfileRuntimes).

Verified: go build ./... clean; go vet ./internal/daemon/... clean;
go test ./internal/daemon/... pass (existing + 5 new); full
go test ./internal/handler/ suite passes against a migrated Postgres 17;
agent lockstep test passes.

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

* MUL-3284 PR2: profile delete runs full archived-agent cascade (fix 500)

Review fix. DeleteRuntimeProfile previously guarded only on ACTIVE agents, but
agent.runtime_id is ON DELETE RESTRICT — a profile whose runtimes had only
ARCHIVED agents passed the guard, then DeleteAgentRuntimesByProfile hit the FK
and the handler 500'd.

Now it mirrors the mature runtime-delete cascade (DeleteAgentRuntime): in one
transaction it enumerates the profile's runtime rows, refuses (409) any with
active agents or active squads led by archived agents, then for each runtime
pauses autopilots pinned to its archived agents, drops archived squads led by
them, and hard-deletes the archived agents before removing the runtime rows
and the profile. No code path can now fall through to a raw FK error.

- queries: ListAgentRuntimeIDsByProfile (sqlc regen). Reuses the existing
  per-runtime teardown queries (CountActiveSquadsWithArchivedLeadersByRuntime,
  ListArchivedAgentIDsByRuntime, PauseAutopilotsByAgentAssignees,
  DeleteSquadsByArchivedAgentsOnRuntime, DeleteArchivedAgentsByRuntime).
- tests: TestDeleteRuntimeProfile_ArchivedAgentCascade (archived-only profile
  deletes cleanly: 204, runtime + archived agent + profile gone) and
  TestDeleteRuntimeProfile_ActiveAgentBlocks (active agent → 409, survives).

Verified against Postgres 17: both new tests pass; full handler suite, daemon
tests, and agent lockstep test pass; go vet clean.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 11:33:09 +08:00
LinYushen
32dac3dd57 MUL-3284: runtime_profile schema (custom runtime PR1) (#4140)
* MUL-3284: add runtime_profile schema (custom runtime PR1)

Schema-only foundation for custom runtimes. Additive migration 120:

- New workspace-level `runtime_profile` table: the shared, team-visible
  definition of a custom runtime (e.g. an in-house Codex wrapper).
  protocol_family is CHECK-constrained to the exact backend list in
  agent.New() (server/pkg/agent/agent.go). The only args column is
  `fixed_args` (args every agent on the runtime must inherit); there is
  deliberately no generic per-agent args field — those stay on
  agent.custom_args.
- `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE
  CASCADE): NULL = built-in runtime, non-NULL = a registered instance of
  a custom profile.
- Partial unique index agent_runtime_workspace_daemon_profile_key on
  (workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL.

The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left
INTACT so the existing registration upsert
(ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps
resolving its arbiter and the server stays green. Converting that key to
a partial (WHERE profile_id IS NULL) index and making the upsert
profile-aware is PR2's registration work, not this migration.

Verified up + down against Postgres 17: full `migrate up` applies 120;
schema shows the table, column, partial index and intact legacy
constraint; functional checks pass (partial index blocks dup
(ws,daemon,profile), allows same profile on another daemon; CHECK and
display_name uniqueness reject bad input; legacy ON CONFLICT still
resolves; profile delete cascades to instances); down/up round-trip is
clean.

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

* MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix)

Per review (house rule: no new database foreign keys / cascades; relational
integrity lives in the application layer):

- runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE
  -> plain UUID NOT NULL.
- runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL
  -> plain UUID.
- agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE
  -> plain UUID.

CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index,
and the partial unique index agent_runtime_workspace_daemon_profile_key are
unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint
remains untouched.

Behavioral consequence: the database no longer auto-removes a profile's
agent_runtime instance rows on profile delete. That cleanup moves into PR2's
profile-delete path. Up-migration comments document this; down-migration
comment no longer references FKs/cascade.

Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on
the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK
and display_name uniqueness still reject bad input; deleting a profile now
leaves the runtime row orphaned (proving cascade is gone); down/up round-trip
clean with the legacy constraint intact.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 11:32:55 +08:00
taogejiang
1f8f3e8037 Fix Office 365 SMTP auth fallback (#4157)
* Fix Office 365 SMTP auth fallback

* Fix SMTP auth fallback tests

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

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

When a brand-new chat's first file upload triggers lazy session creation,
`setActiveSession(null → uuid)` flips ChatInput's draft key mid-upload, which
changes `defaultValue` to the new (empty) session draft. ContentEditor's
"sync external defaultValue" effect then ran `setContent` over a document that
still held the `uploading` image/fileCard node, wiping it — so the upload's
finalize could no longer find the node. The file vanished and the draft was
left with an empty `!file[name]()`.

The editor was never remounted (instance stays alive); the node was removed by
the content-sync effect. An uploading node is local state an external sync must
not overwrite, exactly like the existing dirty/focused guards. Add a guard that
bails the sync while any `uploading` node is present.

Pure frontend; affects only the first upload in a new chat (subsequent uploads
hit an existing session, so no draft-key flip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(editor): cover the in-flight-upload content-sync guard

The content-sync effect now reads `editor.state.doc.descendants` on every run
to detect uploading nodes; the mocked editor didn't implement it, crashing all
ContentEditor tests. Add `descendants` (driven by `editorState.uploadingNodes`)
to the mock and a regression test asserting an external `defaultValue` change
does not setContent while an upload is in flight, and resumes once it settles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): migrate new-chat draft onto the session id on lazy create

The first file upload in a brand-new chat lazily creates the session, flipping
ChatInput's draft key from `__new__:agent` to the session id mid-upload. The
in-progress (empty-href) file-card markdown the editor had already written into
the `__new__:agent` draft was neither migrated nor cleared, so it stayed
stranded under that key — and resurfaced as a stale `!file[name]()` the next
time a new chat opened for the same agent (the send only cleared the
session-keyed draft).

Migrate the `__new__:agent` draft onto the new session id the moment the
session is created (upload path only — text send already clears the pre-flip
key via `keyAtSend`). Add a shared `newSessionDraftKey` helper so ChatInput and
ensureSession agree on the slot name, and a `migrateInputDraft` store action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:57:17 +08:00
Multica Eve
89ada0ee81 MUL-3324: add 2026-06-16 changelog entry (#4194)
* docs: add 2026-06-16 changelog entry

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

* docs: adjust 2026-06-16 changelog wording

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 17:39:33 +08:00
Naiyuan Qing
1272311ebe Revert "MUL-3312: gate chat uploads on active agent (#4192)" (#4195)
This reverts commit 097064ed0e.
2026-06-16 17:23:35 +08:00
Multica Eve
18a58e80c0 MUL-3316: fix(execenv): switch agent prompt to --content-file to prevent heredoc flag swallowing (#4182) (#4191)
* fix(execenv): switch agent prompt to --content-file to prevent heredoc flag swallowing (#4182)

The Linux/macOS reply template recommended --content-stdin with a quoted
HEREDOC. That pattern is safe for the trivial single-flag comment-add case
that BuildCommentReplyInstructions emits, but as soon as a model wraps
extra flags around the heredoc on multica issue create / update — assignee,
project — the bash heredoc/flag boundary is fragile in two ways the model
cannot see:

  - A 'BODY \\' terminator with a trailing token is not recognised as the
    heredoc end, so flag lines after it are swallowed into the description
    (OXY-78: residual flag text leaked into the description, command exit 0).
  - A clean terminator turns the trailing '--assignee ...' line into a
    separate failing shell statement, while the create itself already exited
    0 with no assignee (OXY-76: assignee silently dropped, no residual text).

In both cases the CLI never receives the swallowed flags, the API request
omits the fields, and the daemon has no visibility. The created issue lands
with assignee_id: null / project_id: null.

This commit:

  * Switches the Linux/macOS branch of BuildCommentReplyInstructions to
    --content-file with a 3-step recipe (write file, post, rm) so the body
    never reaches the shell and all flags live on one shell-token line.
    There is no heredoc boundary for flags to leak across.
  * Adds a parallel cleanup step (Remove-Item) to the Windows branch so the
    cross-platform template is one shape.
  * Rewrites the runtime_config.go ## Comment Formatting non-Windows section
    to mandate --content-file and explicitly ban --content-stdin HEREDOC for
    agent-authored comments, citing #4182.
  * Reorders the Available Commands menu lines for issue create / update /
    comment add to put --content-file / --description-file ahead of the
    stdin variant and add a per-line note pointing at #4182.
  * Updates and renames the affected tests
    (TestBuildCommentReplyInstructionsCodexLinux,
    TestBuildCommentReplyInstructionsNonCodexLinux,
    TestInjectRuntimeConfigLinuxCommentFormattingEmphasizesFile,
    TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged) so the
    new file-first contract is pinned and the old HEREDOC mandate is in the
    banned-strings lists.

This converges Linux/macOS with the long-standing Windows file-only path,
so the cross-platform guidance is now one shape. It also strictly improves
on the previous MUL-2904 guardrail by eliminating shell exposure of the
body entirely (no body ever reaches the shell, so backtick / $() / $VAR
substitution cannot corrupt it).

Closes GitHub multica-ai/multica#4182.

No CLI or backend changes — --content-file / --description-file already
exist.

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

* docs(prompt): correct stale BuildPrompt comment to file-first (#4182)

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
2026-06-16 17:14:25 +08:00
Willow Lopez
2c0f6edca8 MUL-3320: feat(lark): add proxy support for WebSocket connections (#4165)
* feat(lark): add proxy support for WebSocket connections

- Add Proxy field to GorillaDialer (func(*http.Request) (*url.URL, error))
- Default to http.ProxyFromEnvironment when Proxy is nil, so standard
  HTTPS_PROXY/HTTP_PROXY/NO_PROXY env vars are respected automatically
- Allow explicit override via GorillaDialer.Proxy for custom proxy auth
  or fixed proxy URLs
- Add unit tests for proxy defaults and error forwarding

Closes #4032

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

* fix(lark): add missing net/url import in ws_connector_test.go

TestGorillaDialerProxyDefaults and TestGorillaDialerProxyForwardsError
use *url.URL in their Proxy func signatures but net/url was not imported.

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

* fix(lark): preserve configured websocket proxy

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-06-16 17:13:40 +08:00
Wes
3aaca155e7 Fix transcript actions on touch devices (#4161) 2026-06-16 17:06:14 +08:00
Wes
4f1797598e MUL-3321: Add runtime delete CLI command
Adds a command-line runtime delete flow with strict default behavior and explicit cascade support.\n\nFixes #3909.
2026-06-16 16:58:10 +08:00
David Zhang
8ba1ef2dce MUL-3319: Update Codex and Cursor resume docs
Update provider documentation to reflect working Codex and Cursor session resumption.
2026-06-16 16:48:18 +08:00
Naiyuan Qing
097064ed0e MUL-3312: gate chat uploads on active agent (#4192)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 16:43:55 +08:00
Willow Lopez
089832d6ec fix(web): preserve CLI callback params across Google OAuth redirect (MUL-3313) (#4167)
* fix(web): preserve CLI callback params across Google OAuth redirect

When 'multica login' runs in a headless/WSL2 environment, the CLI generates
a login URL with cli_callback and cli_state query parameters. These params
were being lost during the Google OAuth redirect because:

1. The login page did not encode cli_callback/cli_state into the Google
   OAuth state parameter (only platform and nextUrl were included).

2. The callback page had no code path to redirect the JWT back to the
   CLI's local HTTP listener after Google OAuth completed.

Fix:
- Login page: encode cli_callback and cli_state into the Google OAuth
  state parameter alongside existing platform/nextUrl values.
- Callback page: parse cli_callback/cli_state from the returned state,
  validate the callback URL, and redirect the JWT token to the CLI's
  local HTTP listener after successful Google login.

Closes #3049

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

* refactor(auth): reuse redirectToCliCallback helper in OAuth callback

Export the existing redirectToCliCallback helper from @multica/views/auth
and reuse it in the Google OAuth callback page instead of duplicating the
token+state redirect string inline, so the CLI callback URL contract lives
in one place.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-06-16 16:38:15 +08:00
Naiyuan Qing
c222088262 feat: client failure telemetry (JS errors + freeze/crash) to PostHog (#4187)
* feat(analytics): capture JS exceptions to PostHog

Turn on posthog-js exception autocapture (window.onerror + unhandled
rejections, with stack) and add a buffered captureException() wrapper for
boundary-caught React errors those handlers can't see. Wire the web
route-level global-error boundary to report through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(diagnostics): add shared freeze watchdog

Long-task observer (>=2s) emits client_unresponsive via captureEvent;
client_type super-property tags desktop vs web for free. Installed once in
CoreProvider so web and desktop share one in-thread, SSR-safe detector for
recoverable freezes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(desktop): report true hangs and crashes via breadcrumb

A real hang or crashed renderer can't report itself. The main process now
persists a breadcrumb on unresponsive / render-process-gone, and the next
renderer boot flushes it to PostHog (client_unresponsive / client_crash).
A recovered hang clears its breadcrumb so it isn't double-counted by the
in-thread watchdog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(analytics): scrub PII from $exception before send

Error messages can interpolate user input (typed values, URLs with tokens).
Add a before_send hook that redacts emails, URL query strings, and long
opaque tokens from the exception message and $exception_list values, keeping
type + stack frames (code locations, not user data). Addresses the privacy
gap from leaving capture_exceptions on with no sanitizer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover breadcrumb state machine and freeze watchdog

The breadcrumb persist/clear orchestration is the correctness-critical part
and was untested. Cover: hang->write, recover->clear (no double-count),
recover-before-delay->no-op, force-quit->retained, crash->write-and-never-
clear, clean-exit->no-write. Add watchdog tests (threshold, idempotent,
SSR/PerformanceObserver no-op) via a fake observer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(desktop): breadcrumb field precedence + document limits

Spread the persisted context FIRST so explicit event fields (source,
recovered) always win over a future colliding context key. Document why
preload-error skips the breadcrumb and the single-slot last-write-wins
undercount limitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:31:38 +08:00
Naiyuan Qing
79394ee057 MUL-3310: disable bare issue key expansion in comments (#4190)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 16:28:38 +08:00
Bohan Jiang
241a3582cf fix: validate issue status and priority (#4156)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 12:26:44 +08:00
Naiyuan Qing
7c71007e6e refactor(comments): trim trigger preview copy and unify composer buttons (#4174)
Two related cleanups to the issue comment/reply/edit composer:

- Drop the trigger-preview "context" copy added in #4147 (chip prefix
  `trigger_context_*` and per-context popover titles `trigger_preview_title_*`).
  The actual "align context" fix in #4147 was the backend/hook work; the copy
  was redundant decoration. Removes the `context` prop, the dead i18n keys
  across en/zh/ja/ko, and the corresponding test assertions; the popover title
  falls back to the original single `trigger_preview_title`.

- Edit-comment footer: lay the trigger chip on a single row with the action
  cluster (📎 Cancel Save) on the right, attachments on their own full-width
  row above. The 📎 now sits with the action buttons, matching the new-comment
  and reply composers.

- Unify composer buttons on shadcn `Button`: `FileUploadButton` renders a
  ghost icon button instead of a hand-rolled circle, and the reply submit
  button uses `Button` (icon-xs, ghost-when-empty / primary-when-typed) instead
  of a hand-rolled element. Sizes: 📎 and reply submit are both icon-xs (24px).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:44:05 +08:00
DimaS
2f24057bc2 feat(issues): add date filter (#4129)
Co-authored-by: “646826” <“646826@gmail.com”>
2026-06-16 08:38:53 +08:00
Naiyuan Qing
1afa493165 fix(comments): align trigger preview context (#4147)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 08:20:15 +08:00
Naiyuan Qing
f2e72577b2 MUL-3304: align projects compact row navigation (#4155)
* fix(projects): align compact row navigation

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

* docs(projects): clarify row action navigation comment

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

---------

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

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

* docs: clarify 0.3.22 changelog copy

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-06-15 17:55:48 +08:00
Bohan Jiang
7d30ef1c67 fix: preserve openclaw gateway token mask (#4152)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 17:46:35 +08:00
Naiyuan Qing
3ce4cf6f2f fix(lists): navigate rows via onClick, not a nested row anchor (#4146)
Clicking a row's ⋯ kebab (or any in-row control) full-page reloaded the
app. The row was a whole-row <AppLink>, so a child's stopPropagation
stopped the event before AppLink's onClick (which calls preventDefault to
cancel native anchor navigation and do an SPA push) could run — leaving
the browser to perform the native <a> navigation, i.e. a full reload. It
was also invalid HTML: interactive content (button/menu) nested in an <a>.

Rework all five ListGrid row surfaces (agents, runtimes, skills,
autopilots, squads) to a plain <div> row whose whole-row navigation is a
mouse onClick (new useRowLink hook): left-click pushes, cmd/ctrl/middle
opens a background tab. Interactive cells (checkbox, kebab) stopPropagation
so they never trigger row nav — and with no <a> ancestor there is no native
navigation to cancel, so the reload class of bug is gone. Names are plain
text since the row itself is the click target. projects is unchanged — its
inline-editable cells make it a deliberate name-link exception.

Also fixes two adjacent defects found in the same menus:
- agents/runtimes kebab triggers reused the shared <Button>, which lacks
  the data-popup-open styling the other surfaces have, so the trigger
  vanished and lost its background while its menu was open. Switch them to
  the bare-button trigger with data-popup-open: visible + highlighted.
- agents archive menu items used className="text-destructive" instead of
  variant="destructive", so the base focus style overrode the red on hover.
  Switch to variant (list row + detail page).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:56:38 +08:00
Bohan Jiang
93541be975 MUL-3239: include route context in desktop recovery prompts
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:50:54 +08:00
Naiyuan Qing
76c687d39a fix(markdown): allow attachment download file-card hrefs (#4145)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:47:11 +08:00
Bohan Jiang
f9c193e06b fix: fail closed on agent task auth tokens (#4142)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:34:35 +08:00
marovole
0e31a9ca58 fix(agent/runtimes): show Cursor Composer token usage and billing (#4135)
* fix(agent/runtimes): show Cursor Composer token usage and billing

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

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

* fix(views): align Cursor Composer pricing

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

---------

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

* Fix highlight scroll restore regression

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

MUL-3240
2026-06-15 14:52:52 +08:00
Kagura
2ab7b5b7af MUL-3280: fix(editor): repair split email links caused by autolink + inclusive:false
Fixes #4091
2026-06-15 14:38:34 +08:00
Naiyuan Qing
63cf0ed308 feat(lists): rebuild all six list surfaces on a shared Linear-style list grid (#4038)
* fix(issues): render thread replies in chronological order (#3691)

collectThreadReplies walked the parent_id tree depth-first, so an agent
reply forced to nest under its trigger comment rendered before earlier
sibling replies (A-D-B-C instead of A-B-C-D) whenever the agent returned
late. Sort the collected subtree by created_at (id tie-break) so the
thread reads in arrival order — the same order the server already feeds
agents via `comment list --thread` (ListThreadCommentsForIssue).

All other consumers of the array (resolution derivation, fold bars,
counts, deep-link) are order-independent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): rebuild skills list on shared Linear-style list grid

- new ListGrid primitives (subgrid: single source of truth for column tracks)
- skills list: sortable columns, used-by avatar stack, source/creator columns,
  row kebab + batch toolbar with add-to-agent and delete
- skill view store in core; addAgentSkills client method; HoverCheck extracted
  to views/common (issues header now imports the shared copy)
- locale keys for list actions/filters and the reworked detail page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): rework detail page into overview/files tabs

- tabs directly under the breadcrumb header: overview (default) and files
- overview: identity block + rendered SKILL.md as the main column, right
  rail with metadata card (source/creator/updated, inline name+description
  edit toggle) and used-by panel with bind/unbind
- files: file tree + viewer/editor unchanged; SKILL.md "edit" jumps here
- header kebab menu (copy skill ID, delete); page-level save bar shared by
  both tabs; tab state persisted in ?tab=
- file tree: ARIA tree roles + roving-tabindex keyboard navigation
- drop the old right sidebar (metadata dl, permissions paragraph)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* revert(skills): restore detail page to main, keep branch list-only

Drop the overview/files tabs rework from this branch so the PR scope is
the list rebuild only. skill-detail-page.tsx and file-tree.tsx are back
to the main versions; the locale detail/file_tree sections are restored
to match. The detail rework is preserved on stash/skills-detail-tabs
for a follow-up PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): drop description column from skills list

Description is agent-facing routing metadata, not a scannable list
property — Linear's display options expose no description column for
the same reason. Removes the cell, column key, display toggle, lg grid
track, skeleton cells, and the now-dead table.description /
table.no_description locale keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): drive list column hiding by container width, drop by priority

Replace viewport sm:/lg: breakpoints with Tailwind v4 container query
variants (@2xl/@4xl) on the list wrapper, so an open sidebar or split
pane narrows the column set instead of squashing tracks. Remove the
min-w-fit + overflow-x-auto horizontal-scroll fallback: when space runs
out, low-priority columns (created/source/creator, then updated) drop
and return as the container widens; name and usedBy never drop. ListGrid
conventions comment updated — this is the template for all list pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): virtualize list rows with @tanstack/react-virtual

Linear-style headless virtualization: the virtualizer computes the
visible index range and offsets; offsets land as padding on the
scrolling ListGridBody so mounted rows stay direct subgrid children and
column alignment is untouched. Fixed 48px rows skip per-row measurement.

Hideable column tracks move from max-content to deterministic widths
(CSS vars) — with only the visible slice mounted, content-driven tracks
would resize during scroll. A user-hidden column zeroes its var so the
track still collapses; per-cell max-w caps move into the tracks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills): list tiers must fit their container trigger width

The @4xl tier's track sum (~1080px with gaps) exceeded its 896px
trigger; with the horizontal-scroll fallback gone, the right-side
columns were clipped unreachably between 896-1080px. Move tier 3 to
@5xl (1024px), trim usedBy/source/creator tracks, and document the
fit invariant with its arithmetic next to the template and in the
ListGrid conventions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): show description as subtext under the skill name

Lives in the name track as a second truncated line (max-w 36rem,
title attr for the full text) — no track, no header, no slot in the
responsive arithmetic. Both lines fit the fixed 48px row, so the
virtualizer contract is untouched; rows without a description center
the name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Revert "feat(skills): show description as subtext under the skill name"

This reverts commit f39721301b.

* fix(skills): anchor batch toolbar to the page, not the viewport

fixed bottom-6 left-1/2 centered the bar on the window; with the
sidebar open the list's visual center sits ~120px right of the window
center, so the bar looked off-center (worse with desktop split panes).
Page root becomes the positioning context (relative) and the bar uses
absolute — same rule applies to future list pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): show matching count next to search while list is narrowed

"n / total" appears right of the search box only when search or
filters are active — idle state would duplicate the total already in
the page header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autopilots): derive trigger kinds, next run, last run status in list

The list endpoint only selected the autopilot table, so the list UI
could not answer "is this automation working" without N+1 detail
calls. Each list row now carries trigger_kinds + next_run_at (enabled
triggers only — the columns describe how it fires today) and
last_run_status (most recent run). Fields are omitempty and absent
from detail/create/update responses; clients must treat them as
optional per the API compatibility rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autopilots): list schema, parsed client, and view store in core

- listAutopilots now runs through parseWithFallback with a zod schema
  (this endpoint was a bare fetch — overdue per the API compatibility
  rules); malformed bodies degrade to an empty list, old-server rows
  without assignee_type or the new derived fields parse cleanly, and
  enum drift passes through as plain strings
- Autopilot type gains the three optional list-only derived fields
- New autopilots view store (scope/sort/columns/filters, persisted per
  workspace): status is the promoted scope dimension so it does NOT
  appear in filters — one dimension lives in exactly one place

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autopilots): rebuild list on shared ListGrid with scope buttons

Same skeleton as the skills list (container-query tiers, deterministic
var-width tracks with documented fit arithmetic, virtualized 48px rows,
sortable headers, filter + display toolbar, page-anchored batch
toolbar), plus the autopilots-specific pieces:

- Status is the promoted SCOPE dimension: 全部/运行中/已暂停/已归档
  segmented buttons with full-set counts; "all" = active+paused
  (archived gets its own visible home, Linear archive semantics);
  status is therefore absent from the filter dropdown
- Columns: name (paused marker inline), assignee (agent/squad),
  trigger kind badges, last run (outcome dot + time, enum-drift safe
  default), next run; mode/creator/created opt-in hidden
- Filters: assignee, trigger kind, mode, creator (composite type:id
  values for polymorphic actors); sort name/lastRun/nextRun/created
  with lastRun desc default
- Row kebab (pause/resume/archive/unarchive/delete) and batch toolbar
  share one delete dialog; status changes ride useUpdateAutopilot's
  optimistic cache
- Fix noUncheckedIndexedAccess errors the branch had never typechecked
  (skills virtual rows, UsedByCell, added_toast)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autopilots): scope buttons follow the issues header pattern

Replace the bespoke segmented-pill control with the existing scope
button convention from the issues page: outline buttons with bg-accent
active state on md+, collapsing to a radio dropdown below md. Counts
stay (stage inventories from the full set).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills,autopilots): toolbar small-screen treatment follows issues header

Below md: the search box (and its result count) disappear entirely,
and the filter/display controls collapse to square icon-only buttons
(labels and the clear-X are md+), matching the issues header's
responsive pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills,autopilots): two-zone columns — WYSIWYG with scroll escape valve

Static width tiers silently hid user-enabled columns (toggle on,
nothing appears — autopilots' mode/creator/created sat behind a 1280px
container gate no laptop reaches; skills' source/created behind
1024px). Tiers can't know how many columns are enabled, so the
mechanism is replaced, not retuned:

- ≥@2xl container: every enabled column renders; the grid carries
  min-width = Σ(enabled tracks + gaps) (pure constants, no
  measurement) and the wrapper scrolls horizontally only when the
  enabled set outgrows the container
- <@2xl: static core set (skills: name+usedBy; autopilots:
  name+assignee), no scroll, toggles don't apply

Per-tier templates and the hand-maintained fit arithmetic retire;
ListGrid conventions updated accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills,autopilots): widen name column minimums (120px base, 200px wide)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autopilots): drop the archived scope and the list search box

Archiving never existed as a UI flow (the DB status value is only
reachable via direct API; the detail page disables its switch when
archived), so the list stops inventing it: no archived scope, no
archive/unarchive row or batch actions. API-archived rows are excluded
everywhere; a persisted retired scope value falls back to "all".
The search box goes too — scope buttons already partition the small
set, search is redundant (product call). Skills keeps its search (no
scope there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills,autopilots): quiet outline create buttons in page headers

Page-header chrome shouldn't carry the loudest element on the page:
the create button becomes outline with text on md+ and collapses to a
square plus icon below md (same responsive treatment as the toolbar
controls). Primary stays reserved for empty-state CTAs. Agents follows
when its list migrates to ListGrid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(agents): rebuild list on shared ListGrid with identity rows

Same skeleton as the skills/autopilots lists (two-zone container
responsiveness, deterministic var tracks + min-width scroll escape
valve, virtualized fixed-height rows, issues-style scope buttons,
page-anchored batch toolbar, quiet outline create button), plus the
agents-specific decisions:

- Identity rows: the documented exception to the single-line rule —
  avatar + name + description two-line cells, 64px rows (agents are
  few, identity-rich entities); the italic "no description"
  placeholder is gone, empty descriptions just center the name
- Scope: Mine (historical default) | All | Archived with full-set
  counts; archived ignores the ownership lens; no search box
- The 7d sparkline column is replaced by a sortable "Last active"
  column derived from the same 30-day activity buckets (zero API
  change) — per-row-normalized mini bars can't be compared across
  rows, and the default sort finally has a visible anchor; the
  detailed histogram stays on the hover card / detail page
- Workload folds into the status cell ("Online · 2 tasks") — a 0-2
  integer doesn't earn a column
- Columns: status, runtime, last active, runs (30d); model/created
  opt-in hidden; filters: availability, runtime
- Operations unchanged: row kebab reuses AgentRowActions
  (cancel-tasks/duplicate/archive/restore with permissions); batch
  archive (confirmed) + restore; no delete — the API has none
- View store extended (scope incl. archived, sort, columns, filters);
  agent-columns.tsx (DataTable columns) deleted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agents): trim status track to its real worst case (160 -> 144px)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(runtimes): machine detail's runtime table on the shared ListGrid

The master-detail console keeps its shape (machines are few and
strongly categorized; left list, charts, update section untouched) —
only the right pane's runtimes table moves from TanStack DataTable to
the ListGrid family, taking the paradigm pieces that earn their keep
at 1-5 rows: subgrid template + var tracks, two-zone container
responsiveness (the pane is squeezed by the machine list, so the
core-set collapse below @2xl matters more here than on full-width
pages), min-width scroll escape valve, shared header/row/hover visual
language. Deliberately NOT taken: virtualization, sorting, filters,
column toggles, and batch selection — dead weight at this row count,
and batch-deleting runtimes (a cascade-confirm operation) is unsafe
by design.

Workload folds into the health cell ("Online · Working 2") like the
agents status cell; the owner column keeps its only-when-multiple-
owners rule via a zeroed track var. runtime-columns.tsx is deleted;
the row-menu/CLI tests render the exported cells directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(runtimes): collapse the kebab track when no row has actions

On a healthy local machine every row's only action (delete) is hidden
by the self-healing rule, leaving a permanent ~64px dead zone after
the CLI column. The action track now follows the owner column's
conditional-var mechanism: zeroed unless at least one row will show
the menu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(runtimes): drop doubled header border, align create button with convention

PageHeader already carries border-b; the content wrappers' border-t
stacked a second line right under it (the only list page doing this).
"Add a computer" follows the chrome-button convention: outline with
text on md+, square plus icon below md — primary stays reserved for
the empty state CTA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(runtimes): health cell load suffix matches the agents status cell

"Healthy · 2 tasks" instead of the old workload vocabulary
("Working 2 +1q") — the count is unit-bearing and both surfaces now
speak one language. The queued-anomaly distinction the old words
hinted at belongs to the health layer if it ever earns surfacing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lists): pin overflow-y-hidden on the horizontal-scroll wrappers

CSS coerces overflow-x:auto into overflow:auto on both axes, which
silently armed the list wrappers with a vertical scrollbar they were
never meant to have. Combined with the h-full grid's percentage
resolution across scrollbar-induced reflows, the wrapper's vertical
bar and horizontal bar fed each other in a non-converging layout loop
(visible as two stacked, flickering scrollbars on the agents list —
the same latent loop exists in all four wrappers; agents' wider
min-width and 64px rows just hit the trigger zone first). Vertical
scrolling belongs solely to ListGridBody; declare overflow-y-hidden
explicitly to break the loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agents): single scroll container for the list (trial before rollout)

Both scroll axes move to the outer wrapper; the grid drops h-full and
the rows wrapper drops its own overflow. Kills the percentage-height
bridge between the two scroll elements that fed the flickering double
scrollbars and clipped the last row under the horizontal scrollbar.
Sticky header pins inside the scroller; vertical scrollbar now spans
the full pane (Linear's structure). Skills/autopilots follow after
visual confirmation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lists): roll single scroll container out to skills/autopilots, add bottom clearance

ListGridBody retires its own scrolling entirely (the agents trial
confirmed the structure): both axes live on the single outer wrapper,
grids drop the h-full percentage bridge, virtualizers point at the
wrapper. The rows wrapper gains LIST_GRID_BOTTOM_CLEARANCE (64px)
appended to the virtualization padding so the last row scrolls clear
of the chat FAB (~48px at bottom-right) and the batch toolbar (~62px).
Runtimes' machine table is untouched: content-height at the top of a
tall pane, no bridge and no practical FAB overlap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(squads): rebuild list on shared ListGrid (identity rows, minimal)

The last list joins the family. Squads are the fewest entity (1-5 rows),
so this is the agents identity-row shell on the runtime-list minimal
skeleton: ListGrid subgrid + var tracks + two-zone responsiveness +
single scroll container, but NO virtualization, checkbox, or batch.

- Identity two-line rows (squad avatar + name + description, 64px) like
  agents; columns: name / leader / members (polymorphic ActorAvatar
  stack from member_preview), creator + created opt-in hidden
- Scope Mine/All (creator-based, issues-header styling, <md dropdown);
  no archived scope (list API hard-filters archived + no restore
  endpoint), no search (scope-bearing), no filters (set too small)
- Sort name (default) / members / created
- Row kebab = Archive (= the delete endpoint, which archives + transfers
  issues/autopilots to the leader); workspace owner/admin only, so the
  kebab track collapses for non-admins. Reuses the existing
  archive_dialog copy. No batch.
- View store extended (scope + sort + columns); zero API change — pure
  frontend (member_preview/count already in the list payload)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(agents,squads): owner/created-by columns + owner filter

Surface ownership as a real column on both lists, named by what the
field actually means in each permission model:
- Agents: "Owner" — owner_id is the creator (set at creation, never
  transferred) and carries management rights. Promoted to a default-
  visible column (avatar + name); the half-baked inline owner avatar in
  the name cell is removed ("You" badge stays).
- Squads: "Created by" (NOT Owner) — creator_id holds no rights
  (archiving is workspace-admin only), so Owner would mislead. Now a
  default-visible column with avatar + name.

Agents also gains an Owner filter, kept orthogonal to the Mine scope by
the single-axis rule: "Mine" is the clean no-filter personal view, so
applying any filter (owner or otherwise) leaves Mine for All, and
clicking Mine clears all filters. Owner and Mine therefore never
coexist — no "mine + owner=someone-else = empty" contradiction. Squads
keep the plain Mine/All toggle (too few rows for a creator filter).

Both lists keep a Created (date) column, opt-in hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agents): backfill new filter dimensions on rehydrate (owners crash)

A view payload persisted before the owners filter existed overwrote the
default filters wholesale on rehydrate, dropping filters.owners to
undefined and crashing the list's filter predicate (.length on
undefined). The store merge now deep-merges filters over
EMPTY_AGENT_FILTERS so newly-added dimensions always get their default.
Regression test added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills,autopilots): deep-merge filters on rehydrate too

Same latent crash the agents store just hit: the copied view-store
merge spread persisted.filters wholesale, so adding a new filter
dimension later would drop it to undefined for users with older
persisted state. Harden skills and autopilots the same way (merge over
their EMPTY_*_FILTERS) before that bug can ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(projects): rebuild table view on ListGrid + filters + pin/delete kebab

Projects is the dual-view list: the compact table moves onto the shared
ListGrid (subgrid tracks, two-zone responsiveness, single scroll
container, FAB bottom clearance) while the comfortable card grid stays
as the alternate view, toggled by a restyled view switch (Table/Cards
outline buttons, active = bg-accent). Inline editing is preserved —
rows are NOT whole-row links; the name navigates and status/priority/
lead stay click-to-edit (matching prior behaviour, no navigate-vs-edit
conflict).

- View store extended: viewMode + sort (name/priority/status/progress/
  created) + hidden columns + filters (status/priority/lead); merge
  deep-merges filters (migration-safe). No scope (lead optional/often
  an agent; status is a 5-value lifecycle → filter, not scope).
- Toolbar: search (kept — scopeless list) + result count + Filter
  (status/priority/lead) + Display (sort+columns, table view only).
- Row kebab: Pin/Unpin (any member, reuses the existing project pin
  API — zero new endpoints) + Delete (workspace admin). Pin is the
  flexible per-user favourite the list previously lacked.
- Zero API change; status/priority filtering is client-side like the
  other lists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(projects): GRID_COLS must be a literal string (Tailwind can't see interpolation)

The table view's grid-cols template interpolated ${STATUS_WIDTH}px, so
Tailwind never generated the arbitrary-value class — the grid collapsed
to one column and every cell stacked vertically. Inline the literal
116px. This is the documented ListGrid rule (keep the class literal so
Tailwind scans it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(projects): single view-toggle button, decouple Display from view mode

Two fixes from the same principle — view mode is pure presentation and
must not couple to anything:
- The view switch is now ONE button that flips table ⇄ cards (shows the
  current view's icon+label, tooltip names the target), instead of two
  side-by-side buttons.
- The Display (sort/columns) control no longer disappears when you
  switch to cards — it was gated on isCompact, so flipping the view
  made it vanish (the "filter gone after switching" weirdness). It's
  always present now; only the columns *section* inside the popover is
  table-only (cards have no columns). Sort applies to both views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(projects,squads): projects multi-select + squads FAB clearance/toast

Cross-list consistency audit fixes:
- projects: add multi-select (checkbox column + select-all header +
  page-anchored batch toolbar) — it's a dozens-scale full-page list
  like skills/autopilots/agents but was the only one missing it. Batch
  ops: Pin all (any member) + Delete (workspace admin). Table view
  only (cards have no checkboxes). GRID template + min-width updated
  for the checkbox track.
- squads: add the FAB bottom clearance the other full-page lists have
  (last row/kebab was sliding under the chat FAB).
- squads: archive success toast was showing the dialog's question
  title ("Archive this squad?"); use a proper "Squad archived" key.

Intentional and left as-is (documented): squads/runtimes have no
multi-select/virtualization (1-5 rows); projects table isn't
virtualized yet (dual-view + card grid; tracked as low-risk debt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(agents,squads): close the filter/column consistency gaps

Apply the principle "every categorical column is filterable" where it
was missing:
- agents: add a Model filter (model was a categorical column with no
  filter). Distinct non-empty models from the in-scope rows.
- squads: add filters entirely (it had leader/creator columns + a
  column-toggle panel but no Filter button — the only such outlier).
  Leader (agent) + Creator (member) filters, with the result count and
  the same Filter dropdown shape as the other lists. Store gains
  SquadListFilters + toggleFilter/clearFilters + migration-safe
  filters deep-merge.

autopilots creator stays default-hidden per product call (not every
"who made it" must be visible). Filter stores' partialize tests
updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autopilots): match list-page root to flex-1 convention

skills/agents/projects roots use `relative flex flex-1 min-h-0 flex-col`;
autopilots used `h-full`. Both anchor the batch toolbar correctly, but
align the flex sizing for consistency across the six list surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 14:12:24 +08:00
Multica Eve
9a7eebb194 fix: re-sign unresolved attachment media urls (#4132)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 14:09:25 +08:00
Willow Lopez
a4fb84d5ac MUL-3273: fix(agent): parse Cursor token usage fields
Fixes Cursor agent token usage parsing for top-level camelCase, nested camelCase, and legacy nested snake_case result usage shapes. Includes tests for the locally verified nested camelCase stream-json output.
2026-06-15 14:04:05 +08:00
Bohan Jiang
6c17771cce fix: re-sign inline attachment media for token-mode clients (#4085)
The two prior MUL-3254 fixes preserved draft/description state across a
modal close, but Desktop still could not RENDER the reopened image: in
CloudFront signed-URL mode every URL the renderer holds after reopen is
unloadable. The persisted record strips the expired signed download_url,
the raw CDN url is unsigned (403 on a signed distribution), and the
durable /api/attachments/<id>/download endpoint needs credentials that a
cross-site file:// <img> fetch cannot carry (web works via the same-site
session cookie, which is why the bug was desktop-only).

Two changes close the last mile:

- /api/config now reports cdn_signed when CloudFront signing is enabled,
  and pickInlineMediaURL stops picking the raw (unsigned) CDN url in
  that mode — it is a guaranteed 403.
- The Attachment renderer upgrades an auth-gated media URL to a freshly
  signed one via authenticated GET /api/attachments/<id> (the same
  re-sign the click-time download path already does), but only on
  clients without a same-origin /api proxy (api.getBaseUrl() non-empty:
  Desktop, mobile webview). Cached via TanStack Query with a 20-minute
  staleTime, inside the server's 30-minute signed-URL TTL.

Old servers omit cdn_signed; the schema defaults it to false so behavior
is unchanged there. Non-CloudFront deployments return the API path again
from the metadata fetch and the renderer keeps the original URL.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 13:54:36 +08:00
YOMXXX
34d4cd3a28 feat(openclaw): support connecting to existing OpenClaw gateway (#3260) [MUL-3158] (#3664)
* feat(openclaw): support connecting to existing OpenClaw gateway (#3260)

When the daemon host is a lightweight dev machine or CI coordinator, the
heavy agent work (LLM inference, code execution, tool use) often belongs
on a more powerful remote server already running an OpenClaw gateway.
Multica historically hard-coded `openclaw agent --local`, forcing every
turn to execute in-process on the daemon host.

This change adds an opt-in gateway routing mode controlled per-agent via
`runtime_config`:

  {
    "mode": "gateway",
    "gateway": { "host": "...", "port": 18789, "token": "...", "tls": false }
  }

- Backend: ExecOptions gains OpenclawMode + OpenclawGateway; buildOpenclawArgs
  drops `--local` when mode == "gateway". Per-task openclaw-config.json
  wrapper pins gateway.{host,port,auth.{mode,token},tls} so users do not
  need to edit the daemon host's `~/.openclaw/openclaw.json` to point at
  a different endpoint.
- Daemon: AgentData carries the raw runtime_config; decoding is fail-soft
  (malformed JSON falls back to local mode rather than blocking dispatch).
- API: gateway.token is masked to "***" on every GET; PATCH replays the
  sentinel back, and the update handler restores the persisted token so
  the round-trip never destroys the secret. Defense-in-depth masking on
  WS broadcasts, plus String/MarshalJSON masking on the in-memory struct
  to block stray `%+v` / json.Marshal leaks.
- UI: openclaw-only "Routing" tab on the agent detail page with mode
  selector + structured endpoint form. Token uses a "saved — submit a
  new value to rotate" UX and matching backend preserve hook.

Empty `runtime_config` keeps the historical embedded behaviour, so
existing agents are unaffected.

* fix(openclaw): address #3664 review — drop dead gateway field, gate pin on mode

Per Bohan-J's review:

- Remove the dead ExecOptions.OpenclawGateway field (+ its String/MarshalJSON and
  the daemon.go construction block). It carried the plaintext bearer token but was
  never read — buildOpenclawArgs only consumes OpenclawMode and the live gateway
  path runs through execenv.OpenclawGatewayPin — so this narrows the secret's
  footprint.
- Gate the gateway pin on mode=="gateway" in decodeOpenclawRuntimeConfig: a
  {"mode":"local","gateway":{...,"token"}} payload no longer writes the token into
  the 0o600 per-task wrapper that --local makes openclaw ignore.
- Warn on an unrecognized non-empty mode (e.g. "gatway") instead of silently
  falling back to local.
- Run preserveMaskedGatewayToken in CreateAgent too, so a literal "***" at create
  time can't persist as a real bearer token.
- Document the gateway host:port trust boundary (SSRF note for shared daemon hosts).

Adds regression tests for the local-mode pin drop and the unknown-mode warning.
2026-06-13 15:33:28 +08:00
Bohan Jiang
5b7eb9ad20 fix: normalize codex cached input usage (#4083)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 15:32:01 +08:00
Bohan Jiang
04a0677704 fix(markdown): keep dollar amounts literal in editor (#4084)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 02:14:41 +08:00
Bohan Jiang
f415099c4a MUL-3263: support managed MCP config for Cursor (#4081)
* feat: support managed MCP config for Cursor

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

* fix: address Cursor MCP review feedback

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

* docs: include Cursor in skills MCP support

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 02:07:00 +08:00
Bohan Jiang
ef08d8584c MUL-3254: flush issue description edits on close (#4082)
* fix: flush issue description editor on close

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

* fix: make unmount flush opt-in via flushPendingOnUnmount

The unconditional unmount flush re-emitted discarded content into
composers that clear their draft and then unmount (comment edit cancel,
create-issue / feedback submit), resurrecting the cleared draft.

- Add flushPendingOnUnmount prop (default false); only the issue-detail
  description editor opts in.
- Cache the pending markdown in a ref at onUpdate time and emit that
  cached copy on unmount, instead of reading the editor instance during
  teardown.
- Regression tests: default drops the pending update on unmount, opt-in
  flush emits the cached value even when the editor is already
  destroyed, no double-emit after the debounce fired, and issue-detail
  pins the opt-in wiring.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 02:03:13 +08:00
Matt Voska
70b90d287c MUL-3267: fix(markdown): disable single-dollar inline math in web renderer
remark-math defaults to singleDollarTextMath: true, so any paragraph
containing two dollar amounts (e.g. "costs $120/mo (~$85 net)") has
the text between them parsed as inline TeX and rendered by KaTeX in an
italic math font, with ~ treated as a non-breaking space. Disable
single-dollar parsing in both web render paths, matching GitHub's
behavior; explicit $$...$$ math still renders.

Co-authored-by: Matt Voska <voska@users.noreply.github.com>
2026-06-13 01:48:18 +08:00
Bohan Jiang
fa15041864 MUL-3254: fix pasted image draft rendering in desktop (#4066)
* fix: keep issue draft attachment records

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

* fix: avoid persisting signed draft attachment urls

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

* fix: reuse resolved media url for draft previews

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

* fix: address draft attachment review nits

- Backfill an empty caller download_url from the in-session upload on id
  collision so a just-pasted image first-paints from the signed URL
  instead of detouring through markdown_url.
- Prune draft attachments no longer referenced by the persisted
  description when the create dialog reopens.
- Backfill EMPTY_DRAFT defaults on draft-store rehydrate so drafts
  persisted before the attachments field existed get a stable shape.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 01:25:08 +08:00
Bohan Jiang
7db3e507d1 feat(cli): manage workspace repo registry (#4067)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 01:22:45 +08:00
Jiayuan Zhang
7d28b5a040 fix(issues): remove duplicate emoji reaction entry from comment header (#4068)
The comment card exposed two identical add-reaction affordances: a
QuickEmojiPicker in the header's top-right actions and the add button
inside the bottom ReactionBar. Keep only the bottom one.

- Drop QuickEmojiPicker from the root header and reply-row headers
- Always show the ReactionBar add button (it is the only entry point
  now), removing the isLongContent gating
- Remove the now-unused hideAddButton prop from ReactionBar

MUL-3262

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 12:39:40 +02:00
Multica Eve
be00801acf MUL-3256: add 2026-06-12 changelog entry (#4065)
* docs: add 2026-06-12 changelog entry

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

* docs: refine 2026-06-12 changelog copy

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

---------

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

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

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

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 16:45:38 +08:00
LinYushen
99afb82c50 Add index on "user".created_at (#4063)
Adds migration 119 creating idx_user_created_at on "user"(created_at)
using CREATE INDEX CONCURRENTLY, matching the repo convention for
index-only migrations (114/115).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 15:53:55 +08:00
Naiyuan Qing
d2a03b8edc Fix chat stop and send recovery (#4060)
* Fix chat stop and send recovery

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

* Fix chat cancel recovery follow-ups

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

* Guard cancelled chat restore on tx failure

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

---------

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

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

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

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

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

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

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

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

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

* fix(agent): address codebuddy PR review feedback

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

---------

Co-authored-by: krislliu <krislliu@tencent.com>
2026-06-12 15:22:16 +08:00
Multica Eve
9439a85aa6 MUL-3242: fix daemon workdir provisioning race
Fixes GitHub issue #3999 by moving the daemon StartTask transition behind workdir provisioning and extending the active env-root guard through completion metadata writes.
2026-06-12 15:14:27 +08:00
Bohan Jiang
f37d71a443 fix: apply single skill overwrite immediately (#4057)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 14:57:14 +08:00
Bohan Jiang
9f720a401c fix(desktop): improve renderer recovery prompt (#4056)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 14:21:59 +08:00
Bohan Jiang
c510515da7 fix: suggest daemon profiles for empty disk usage
- suggest other profile workspace roots when disk-usage sees an empty selected root
- include the default profile in reverse suggestions and shell-quote profile arguments
- keep JSON output and explicit --workspaces-root behavior unchanged

MUL-3232
2026-06-12 13:37:35 +08:00
Bohan Jiang
21ff178ac0 MUL-2701: hide raw creator UUID in skill import conflict UI (#3498)
* feat(skills): structured conflict + overwrite path for local skill re-import

Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.

- New terminal import status `conflict` carrying { existing_skill_id,
  existing_created_by, can_overwrite }; can_overwrite = requester is the
  skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
  canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
  once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
  constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
  both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
  payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
  existence (workspace-scoped) and creator permission, then replaces
  description/content/config and fully replaces files (pruning files absent
  from the new bundle). Preserves id, created_by, created_at, name, and
  agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
  create-by-name); any mid-write error rolls back the tx, leaving the original
  skill untouched. Retrying a terminal request is a no-op.

Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.

Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).

MUL-2800

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

* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name

Addresses review feedback on PR #3498 (MUL-2800).

Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.

Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.

Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.

Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.

MUL-2800

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

* feat(skills): resolve local import conflicts in desktop

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

* fix(skills): preserve bulk flow after conflict resolution

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

* fix(skills): show creator name instead of UUID in import conflict UI

When a local skill import hits a name conflict with a skill owned by
another user, the locked-creator message rendered the raw
existing_created_by UUID via the {{creator}} placeholder, which is
unreadable.

Resolve the UUID against the workspace member list and render the
display name instead. When the creator has left the workspace (or the
member list hasn't loaded), fall back to the unbranded conflict_locked
message rather than leak the UUID.

Adds two test cases covering both branches.

MUL-2701

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
2026-06-12 13:09:28 +08:00
yuhaowin
5c136f8557 fix(lark): fix auth race and redirect param in LarkBindPage (#4047)
Two bugs prevented the Lark binding flow from completing for already-logged-in users:
1. The useEffect ran before AuthInitializer's getMe() returned, setting state to
   needs-auth; the guard then blocked re-entry once auth loaded.
2. The sign-in redirect used ?redirect= but the login page reads ?next=.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 12:59:54 +08:00
Multica Eve
5957454dd9 docs: add June 11 changelog entry (#4037)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 17:46:36 +08:00
Naiyuan Qing
0985bad9fd fix(issues): render thread replies in chronological order (#3691) (#4033)
collectThreadReplies walked the parent_id tree depth-first, so an agent
reply forced to nest under its trigger comment rendered before earlier
sibling replies (A-D-B-C instead of A-B-C-D) whenever the agent returned
late. Sort the collected subtree by created_at (id tie-break) so the
thread reads in arrival order — the same order the server already feeds
agents via `comment list --thread` (ListThreadCommentsForIssue).

All other consumers of the array (resolution derivation, fold bars,
counts, deep-link) are order-independent.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:45:53 +08:00
Truffle
6acca84c28 fix(agent): clear stale session id when a resumed ACP session is gone [MUL-3216] (#4015)
* fix(agent): clear stale session id when a resumed ACP session is gone

When an agent's stored ACP session no longer exists on the runtime side,
session/resume still succeeds — hermes echoes the requested sessionId
back — so the failure only surfaces when session/prompt returns JSON-RPC
-32603 "Session not found". The backend then reported Status=failed with
the stale SessionID still set, which kept the daemon's resume-failure
fallback (gated on SessionID == "") from ever firing. The failed task
never updates the stored session, so every future mention on the same
(agent, issue) dispatched against the same dead id, forever (#4010).

handleResponse now returns a structured acpRPCError instead of a flat
string (rendered text unchanged), and the hermes/kimi/kiro prompt-error
paths clear the session id when the error is session-not-found class on
a resumed session. The daemon's existing retry then re-executes with a
fresh session and stores the replacement id, healing the mapping.

* fix(agent): clear stale session id when set_model hits a dead resumed session

With a model override, session/set_model runs before session/prompt,
so a resumed session that is gone on the agent side surfaces there
instead of at the prompt — and the error branch returned the stale
SessionID, so the daemon's fresh-session retry (gated on
SessionID == "") never fired. Apply the same clear-the-id fix in the
set_model error branch of all three backends.

Also relax isACPSessionNotFound to accept -32602: kimi-cli raises
RequestError.invalid_params({"session_id": "Session not found"}) for
every unknown-session path (src/kimi_cli/acp/server.py), so pinning
-32603 made the fix dead code for kimi. The wording gate keeps
unrelated invalid_params errors (e.g. "model not available") on the
preserve-the-id path.

Regression tests for all three backends: resumed session + model
override + set_model failing with each runtime's observed
session-not-found shape must yield status=failed with an empty
SessionID.
2026-06-11 14:54:56 +08:00
Bohan Jiang
0cbb834f96 docs: merge latest changelog entries (#4030)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 14:35:51 +08:00
Bohan Jiang
8151f60c6c fix(daemon): drop stale resume session when workdir is not reused (#4027)
CLI backends key their session stores to the cwd (Claude Code looks
sessions up under ~/.claude/projects/<encoded-cwd>/), so a prior session
id can only resolve when the task runs in the exact workdir the session
was recorded against. When the prior workdir no longer exists (GC'd
after the issue went done, daemon reinstall, manual cleanup),
execenv.Reuse falls back to a fresh Prepare but the stale session id was
still passed to the backend: claude exited within a second and the run
failed before doing any work — permanently, because the failed run
records no session_id and the next claim serves the same stale pointer
again.

Gate ResumeSessionID on the workdir actually being reused, and correct
PriorSessionResumed so the runtime brief uses the cold-path wording when
the session is dropped.

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

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 13:07:44 +08:00
Bohan Jiang
e4ec9dc425 MUL-2802: add skill import conflict strategies (#3997)
* feat(skills): structured conflict + overwrite path for local skill re-import

Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.

- New terminal import status `conflict` carrying { existing_skill_id,
  existing_created_by, can_overwrite }; can_overwrite = requester is the
  skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
  canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
  once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
  constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
  both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
  payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
  existence (workspace-scoped) and creator permission, then replaces
  description/content/config and fully replaces files (pruning files absent
  from the new bundle). Preserves id, created_by, created_at, name, and
  agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
  create-by-name); any mid-write error rolls back the tx, leaving the original
  skill untouched. Retrying a terminal request is a no-op.

Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.

Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).

MUL-2800

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

* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name

Addresses review feedback on PR #3498 (MUL-2800).

Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.

Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.

Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.

Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.

MUL-2800

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

* feat(skills): resolve local import conflicts in desktop

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

* fix(skills): preserve bulk flow after conflict resolution

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

* feat(cli): add skill import conflict strategies

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

* fix(i18n): sync skill import locale keys

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

* docs: explain skill import conflict handling

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

* docs: refresh skill import source map anchors

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 13:00:56 +08:00
Antoine GIRARD
5480c69c9e fix: sort execution log past runs by timestamp (newest first) (#4018)
MUL-3217
2026-06-11 12:18:04 +08:00
Multica Eve
7d719cfbbe docs: add June 10 changelog entry (#4004)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 17:46:54 +08:00
Naiyuan Qing
a0b63462d0 fix(issues): keep comment trigger preview fresh against live queue state (#4007)
The preview answer depends on live queue state (pending-task dedup), not
just the mention set, so three staleness bugs showed up around it:

- staleTime: Infinity pinned a "nobody triggers" snapshot taken while
  the mentioned agent was still queued — the chip never appeared even
  though sending really did wake the agent (create recomputes).
  -> staleTime: 0, cached signatures revalidate in the background.
- The in-flight gap on a signature change rendered as an empty agent
  list, flickering the chips and wiping the composer's suppressed-id
  set via the pruning effect. -> placeholderData: keepPreviousData.
- Nothing refreshed an open composer when an agent's task finished.
  -> the WS task-lifecycle handler now also invalidates the
  commentTriggerPreviewAll prefix, so chips appear mid-typing the
  moment the agent becomes triggerable again.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:45:29 +08:00
Naiyuan Qing
d66730ecdb fix(issues): state-specific trigger chip copy (#4006)
Five chip states get distinct copy instead of sharing one sentence and a
vague "not this time":

- single, will trigger:  Starts working when sent (unchanged)
- single, skipped:       Won't be triggered
- several, k will fire:  {{count}} agents start working when sent —
  the count covers only non-suppressed agents; skipped ones read as the
  dimmed heads in the stack next to the number
- several, all skipped:  No agents will be triggered
- popover row state:     Skipped

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:29:24 +08:00
LinYushen
2754b7d7d8 fix(attachments): render description images with CDN URL (#4005) 2026-06-10 17:26:13 +08:00
Naiyuan Qing
f2ba3c8f1a fix(editor): wrap tables in tableWrapper so wide tables scroll locally (#4003)
Table.configure had renderWrapper unset (defaults to false), so tables
rendered as bare <table> elements with no .tableWrapper div. The
overflow-x: auto rule in prose.css targets .tableWrapper and never
matched, so a wide table pushed the horizontal scrollbar onto the
issue detail's page-level scroll container instead of scrolling
within the table itself.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:20:48 +08:00
Naiyuan Qing
dc129b1178 fix(issues): polish comment trigger chip presentation (#4002)
- Copy: one fixed sentence for single and stacked chips — the avatar(s)
  carry who and how many, the text carries condition + outcome
  ("发送后开始工作" / "Starts working when sent"), killing the
  "is it already running?" misread. Drops the per-name and count keys.
- Color: sidebar-style resting state — muted-foreground until hover so
  the strip reads as metadata, not content.
- Motion: pure fade-in (no slide offset).
- Spacing: reply composer reserves pb-9 so the chip strip reads as a
  footer instead of a second content line glued to the text.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:09:03 +08:00
LinYushen
619c4c4953 fix(attachments): bind description uploads via contentReferencesAttachment (#4001)
The issue description editor bound pending uploads with
`md.includes(a.url)`, but the editor persists the durable markdownLink
(`/api/attachments/<id>/download` / markdown_url), never the raw storage
`a.url`. The filter therefore never matched, so description uploads were
never linked via `attachment_ids`.

After reload the attachment was absent from `issueAttachments`, so the
renderer could not resolve it to a freshly-signed CDN `download_url` and
fell back to the persisted auth-gated download endpoint. That endpoint
loads on web (same-site cookie / proxy) but fails as a native <img> on
Desktop/Electron (cross-origin file:// renderer carries no auth), leaving
the image broken — while comments rendered fine because they already bind
via contentReferencesAttachment.

Switch the description binding to contentReferencesAttachment, matching
the comment/reply/chat composers, so description images resolve to the
signed CDN URL on every client. Add a regression test pinning the
absolute-host markdown_url shape.
2026-06-10 17:04:06 +08:00
Naiyuan Qing
906f70a3e2 Add comment trigger preview suppression (#3792)
* Add comment trigger preview suppression

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

* Use TanStack Query for trigger preview

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

* Test note comments skip create triggers

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

* feat(issues): redesign comment trigger chips as avatar chips

Single agent renders as avatar + presence dot + full sentence; several
agents collapse to an overlapping stack + active count, mirroring the
header working chip. Per-agent skip moves into a click-opened popover
(hover layers stay read-only tooltips); suppression reads as brightness,
not a ban glyph. Loading and preview errors render nothing.

Also: share one tooltip body across chip and popover rows, invalidate
cached previews after a comment lands (the enqueued task changes the
dedup answer), move the preview query key into issueKeys, and drop the
now-unconsumed status field from useCommentTriggerPreview.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(server): drop comment trigger wrappers kept only for tests

enqueueMentionedAgentTasks and shouldEnqueueSquadLeaderOnComment had no
production callers after the compute/enqueue split — the comment path
goes through computeCommentAgentTriggers. Tests now exercise the compute
functions directly via package-local helpers, so the legacy adapters
cannot drift from the real path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skills): sync mentioning/squads source maps with shared trigger computation

The squads source map still pointed the comment-trigger contract at the
pre-refactor call chain (comment.go:940 -> shouldEnqueueSquadLeaderOnComment),
and the mentioning skill referenced the deleted wrapper. Re-anchor both
to computeCommentAgentTriggers / computeAssignedSquadLeaderCommentTrigger
/ computeMentionedAgentCommentTriggers with current line numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:27:07 +08:00
Multica Eve
abf99eb700 fix(attachments): server-driven markdown_url + legacy compat (MUL-3192) (#3991)
Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.

Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.

Server:
- AttachmentResponse gains a markdown_url field, computed by
  buildMarkdownURL from the deployment policy:
  • storage URL is already absolute + unsigned (public CDN, S3 public
    bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
    use it verbatim;
  • CloudFront-signed mode → never expose the raw S3 URL (private
    bucket); return cfg.PublicURL + /api/attachments/<id>/download so
    the server can re-sign on every request;
  • LocalStorage relative + cfg.PublicURL set → same prefixed API
    endpoint;
  • cfg.PublicURL unset → fall back to site-relative path so web's
    Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
  signature query params, so a freshly-signed download_url can never
  leak into persistence — the original MUL-3130 bug stays closed.

Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
  carry markdown_url. Schema lenient-defaults to '' so a backend old
  enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
  (1) att.markdown_url (modern server),
  (2) attachmentDownloadPath(att.id) — legacy site-relative shape,
      retained for backends old enough to omit markdown_url,
  (3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
  reframed as the legacy-compat fallback for already-persisted
  /api/attachments/<id>/download or /uploads/<key> URLs in old
  bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
  AttachmentDownloadProvider so Quick Create's editor can swap the URL
  via the resolver during the same session even before the server-side
  binding lands.

Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
  tests (public CDN passthrough, CloudFront-signed swap, relative
  prefixing, PublicURL unset fallback, trailing-slash strip) + 15
  table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
  covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
  10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.

Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.

Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.

Refs: MUL-3192

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 16:00:40 +08:00
Bohan Jiang
9455310c0c fix(realtime): invalidate per-issue caches on WS reconnect (#3992)
* fix(realtime): invalidate per-issue caches on WS reconnect (MUL-3189)

Per-issue caches (timeline, reactions, subscribers, usage, attachments,
tasks) are keyed without wsId, so the issueKeys.all(wsId) prefix in
invalidateWorkspaceScopedQueries never reached them. With the
staleTime: Infinity default they rely entirely on WS events for
freshness, so a comment:created event lost during a disconnect (e.g.
macOS sleep) left the timeline stale until a full view reload — the
inbox showed the agent's new comment while the issue's comment area
stayed empty.

Add *All prefix helpers for the per-issue key families and invalidate
them in the reconnect / WS-instance-change recovery path. Inactive
caches are only marked stale and refetch on next mount; the mounted
issue refetches immediately, matching its existing useWSReconnect
behavior, so this does not reintroduce the MUL-1941 memo thrash.

Fixes #3953

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

* refactor(core): define issueKeys.tasks via tasksAll prefix helper

Review nit on #3992 — keep the per-issue key families consistently
defined in terms of their *All prefix helpers. No behavior change.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 15:36:09 +08:00
Naiyuan Qing
34c68e1e4c fix(comments): enforce single resolution per thread (#3984)
A thread could hold multiple resolved comments at once: ResolveComment
was a plain per-row setter that never cleared the prior resolution, and
"replacing" one was a display-only illusion (deriveThreadResolution
picks the max resolved_at). The stale rows stayed resolved in the DB and
the optimistic update flashed the new resolution, then reverted.

Make single-resolution-per-thread a write invariant:

- ClearOtherThreadResolutions: thread-scoped clear via a RECURSIVE CTE
  (root + descendants of the target, id <> target), returns each cleared
  row.
- ResolveComment handler runs the clear + set in one tx so the replace
  is atomic. It emits comment:unresolved per cleared sibling (granular
  realtime consumers patch a single comment in place and would otherwise
  keep showing the stale resolution). Target keeps its COALESCE
  idempotency and the re-resolve event suppression.
- Frontend optimistic update mirrors the invariant: resolving clears
  every other resolution in the same thread, so the cache never shows
  two at once. Unresolve still only clears its own row.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 14:20:39 +08:00
Naiyuan Qing
e15df22e98 feat(autopilots): show creator in autopilot detail properties (MUL-3139) (#3983)
* feat(autopilots): show creator in autopilot detail properties (MUL-3139)

The autopilot creator was already persisted end-to-end (created_by_type /
created_by_id on the autopilot table, exposed via AutopilotResponse and the
frontend Autopilot type) but never rendered. Add a "Created by" field to the
detail page Properties section, mirroring the existing assignee field and the
issue-detail creator row, reusing ActorAvatar + getActorName.

Creator may be a member or an agent (the HTTP create path stamps member today,
but backend logic also writes created_by_type=agent), so the display resolves
both actor types and does not assume member. List rows are intentionally left
unchanged, matching the issue convention (creator lives in detail, not lists).

Adds the field_created_by label to all four locale bundles (en/zh-Hans/ja/ko);
locale parity test enforces full coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(autopilots): show creator in autopilots list (MUL-3139)

Add a Created by column to the autopilots list, mirroring the detail
page. Secondary columns (creator, mode, last run) are hidden below lg
so small screens keep only name, agent, and status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 14:20:27 +08:00
Bohan Jiang
b1c8eb5f11 feat: support Claude Fable 5 pricing (#3982)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 12:33:27 +08:00
Bohan Jiang
ac75c97797 fix(desktop): disable auto-start/stop toggles for a daemon the app can't control (WSL2) (#3940)
* feat(daemon): report OS in /health response

The desktop app reads daemon liveness over HTTP but starts/stops it via the
native CLI, which acts on the host process namespace. On Windows with the
daemon in WSL2, /health is reachable via localhost forwarding yet the daemon's
process is unreachable — so the app needs a signal to tell a daemon it manages
from one it merely sees. Expose runtime.GOOS as `os` so the desktop can
compare it against its own host OS. MUL-3154, #3916

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

* fix(desktop): disable auto-start/stop for an unmanageable daemon

When the daemon runs in an environment the app can't drive — e.g. Linux in
WSL2 behind a Windows desktop, reachable only via localhost forwarding — the
Auto-start/Auto-stop toggles silently did nothing: the lifecycle CLI acts on
the host process namespace and never reaches the daemon's PID.

Detect it by comparing the daemon's reported OS (new /health `os` field)
against the host OS, and only when a daemon is actually running. When they
differ: disable both toggles with an explanatory note, skip the version-match
restart on auto-start, and skip the no-op stop on quit. Fails safe — a missing
`os` (older daemon) or a matching OS keeps the toggles live, so native
Mac/Windows/Linux daemons are unaffected.

MUL-3154, #3916

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

* fix(desktop): centralize externally-managed guard at the lifecycle boundary

Review follow-up. The first cut only disabled the Settings toggles, but the
same unmanageable daemon (WSL2 etc.) could still be Stop/Restart-ed from the
Runtime card and from automatic lifecycle entries (logout, user switch,
reauth, first-workspace restart) — each of which would shell out to a native
CLI that can't reach the daemon's process.

Move the guard into the main-process lifecycle functions so every entry point
is covered by construction: stopDaemon() and restartDaemon() no-op for an
externally-managed daemon, and ensureRunningDaemonVersionMatches() treats it
as up-to-date (no misleading restart). The per-branch checks in the auto-start
handler and before-quit are removed — the boundary now covers them. The
Runtime card hides Stop/Restart and shows a 'Managed outside the app' hint,
mirroring the Settings tab. Adds a component test for the card's two states.

MUL-3154, #3916

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

* fix(desktop): preflight the lifecycle guard against live /health

Review follow-up. The guard read a cached lastExternallyManaged, which only
fetchHealth() updates — but not every lifecycle entry polls before calling
stop/restart. syncToken()'s user-switch branch calls restartDaemon() directly
after its own fetchHealthAtPort(), without refreshing the cache; on a fresh
launch / account switch (no poll yet) the cache is still the initial false, so
restartDaemon() would shell out to the native CLI and hit the very WSL/native
PID-namespace problem this PR avoids.

Make stopDaemon()/restartDaemon() preflight against a live /health read each
call instead of trusting the poll cache. The decision is extracted to a pure
daemonLifecycleUnreachable(readDaemonOS, hostOS) so a unit test can prove the
*live* value (not a cache) drives it. lastExternallyManaged is removed — the UI
already reads the per-status externallyManaged field, so it had no other
consumer.

MUL-3154, #3916

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 12:27:41 +08:00
Bohan Jiang
72179d1145 refactor(transcript): reuse payload helper + cover coalesce timestamps (MUL-3174) (#3958)
* refactor(transcript): reuse taskMessageToPayload in WS broadcast

The ReportTaskMessages WebSocket broadcast hand-built the payload and
duplicated the created_at formatting that taskMessageToPayload already
does. Reuse the helper with the just-inserted row, which carries the
same redacted values and the DB-assigned timestamp.

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

* test(transcript): cover coalesce created_at behavior

Lock in that coalescing streaming fragments carries the latest
created_at, and falls back to the previous timestamp when the merged
fragment has none.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 12:15:50 +08:00
Bohan Jiang
2e0b0bb776 fix(db): drop FK on agent_task_queue.initiator_user_id (MUL-2645) (#3959)
The initiator_user_id column (added in 117) carried a foreign key to the
"user" table. Adding that FK also locks the hot "user" table at migration
time, which made the ALTER time out on a busy production deploy. The
column only feeds a best-effort name/email lookup at claim time (a stale
id just yields no `## Task Initiator` section), so referential integrity
is not load-bearing.

- Edit 117 to add a plain `UUID` column (no FK). The original timed-out
  deploy never recorded 117, so its retry now runs the FK-free version.
- Add 118 to `DROP CONSTRAINT IF EXISTS` for environments that already
  applied the constraint-bearing 117 (they skip the edited 117 by
  version). All environments converge to a plain, FK-free column.

No code/codegen change: dropping the FK does not affect the Go column
type, so sqlc output is unchanged. Verified locally: 118 drops the FK and
keeps the column; sqlc regen produces no diff; build/vet/tests pass.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 19:59:20 +08:00
Antoine GIRARD
9f21d0b634 feat(transcript): add timestamps to run transcript entries (MUL-3174) (#3951)
Threads the existing task_message.created_at column through the full stack (Go protocol -> REST/WS handlers -> TS types -> transcript dialog) so agent run transcripts show per-entry timestamps, helping users spot stalled runs. Additive, no migration.
2026-06-09 19:53:30 +08:00
Naiyuan Qing
6d646db577 fix(issues): soften comment sticky mask (#3956) 2026-06-09 18:27:49 +08:00
Multica Eve
d9347f0715 MUL-3175: add June 9 changelog entry (#3954)
* docs(changelog): add June 9 release notes

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

* docs(changelog): polish zh release copy

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

* docs(changelog): clarify zh release title

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

---------

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

* fix(issues): remove reply resolve-thread action
2026-06-09 18:02:11 +08:00
Naiyuan Qing
2bdc8344dd fix(issues): align sticky comment header padding (#3952) 2026-06-09 17:17:14 +08:00
shutcode
a2ef95445b MUL-2794 fix(agent): stop Cursor sessions on terminal result (#3165)
Treats Cursor's stream-json terminal `result` event as the protocol completion boundary so a lingering Cursor worker process can no longer hold the daemon task open after the agent has produced its final result.

- Tighten `cmd.WaitDelay` to 500ms (set before `Start()`)
- Set `resultSeen` and `cancel()` on terminal `result`
- Preserve completed/failed status across the cancellation via two `!resultSeen` guards in the post-loop status decision
- Add unix fake-CLI coverage for success and `is_error` terminal results
2026-06-09 16:49:48 +08:00
Naiyuan Qing
c983905d5c feat(issues): per-comment thread resolution with sticky collapse (#3910)
* feat(issues): per-comment thread resolution with sticky collapse

Allow resolving any comment, not just roots. Resolving a root folds the
whole thread into one bar (existing); resolving a reply marks it as the
thread's resolution ("Resolve thread with comment") and folds the other
replies behind a "N comments" bar, with the resolution kept visible and
badged. Which comment is the resolution is a pure frontend derivation
(root wins, else latest resolved reply), so no write-side bookkeeping is
needed and any resolved_at combination renders one resolution.

- backend: drop the "only root comments can be resolved" guard
- views: deriveThreadResolution + reply-resolution rendering, sticky
  collapse/fold bars (overflow-clip on the card so sticky resolves to the
  timeline scroll parent), scroll the folded thread back into view on
  collapse, ListChevronsDownUp icon, locales (en/ja/ko/zh-Hans)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): sticky comment headers for long comments

Pin each comment's header (root + replies) to the timeline's scroll
parent while reading, so a long comment keeps its author + actions
visible instead of scrolling out of reach. Exactly one header is pinned
at a time:

- Reply headers stick within their own CommentRow box (release at the
  reply's end).
- The root header is wrapped in a root-section container so its sticky
  containing block spans only the header + root body — without it the
  containing block is the whole thread and the root header stays stuck
  behind every reply. Replies render outside the wrapper, gated on open.
- Skip the root header sticky whenever a resolution collapse bar already
  owns the top-0 slot (root resolved+expanded, or reply-resolution
  expanded) to avoid two bars stacking at the same offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:32:22 +08:00
LinYushen
70ccbd9bce Revert "MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP) (#…" (#3944)
This reverts commit 8ff68502fc.
2026-06-09 14:50:56 +08:00
Bohan Jiang
998ebe97e4 fix(autopilot): fail create-issue runs on any terminal task failure (#3943)
Generalize SyncRunFromLinkedIssueTask beyond Codex no-progress: any
terminal create-issue task failure with no retry still in flight now fails
the linked autopilot run, so it can no longer hang in issue_created
(invisible to the failure-rate auto-pause monitor).

- fail the linked run for any terminal task failure, gated by the existing
  HasActiveTaskForIssue wait-for-retry guard
- remove the isNoProgressTaskFailure classifier (subsumed; drops duplicated
  pkg/agent marker literals)
- drop the redundant GetIssue/origin lookup; GetAutopilotRunByIssue leads
  and short-circuits ordinary failures in one query
- tests: keep no-progress regression, add agent_error (non-retryable) and
  retry-pending cases

Follow-up to #3927. VEN-661 / VEN-662 / MUL-3164
2026-06-09 14:48:20 +08:00
Naiyuan Qing
90b639888d fix(issues): confine inbox deep-link scroll to the timeline container (#3929) (#3942)
Opening an inbox comment notification on an issue with a running agent
shoved the whole desktop page — header included — off the top, and no
amount of scrolling brought it back; only toggling the right sidebar
(which reflows the panel group) restored it.

Root cause: the deep-link landing uses native
scrollIntoView({block:"center"}) on the target comment. Native
scrollIntoView is spec'd to scroll EVERY scrollable ancestor. On a cold
mount where the timeline is still streaming (is-working) and the sidebar
panel starts collapsed, the inner timeline scroller can't center the
target on its own, so the scroll propagates up and scrolls the desktop
shell's overflow:hidden wrapper (desktop-layout.tsx). That wrapper has no
scrollbar and doesn't auto-clamp, so the page stays shoved up until a
resize reflows it. Desktop-only: web sits in a document-scroll context
with a real scrollbar that self-corrects.

Fix: drive the timeline container's scrollTop directly and re-center
across rAF frames until async heights settle, instead of leaning on the
ancestor scroll. The scroll never touches an ancestor, so the header can
no longer be pushed off-screen.

Tests assert the user-facing contract (lands on + highlights the target
comment) rather than the scroll mechanism, which jsdom can't lay out.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:46:46 +08:00
elrrrrrrr
254ec945f5 fix(agent/codex): shut down gracefully so OTEL telemetry flushes (#3888)
Codex telemetry was never reaching the OTLP collector for tasks run by the
daemon. The per-task config (including the [otel] block) is copied into
CODEX_HOME correctly, but the lifecycle goroutine closed stdin and then
immediately cancelled the run context, which SIGKILLs the app-server. Codex's
OTEL batch exporters only force-flush on a graceful shutdown, so the buffered
spans/metrics/logs were dropped before they could be exported — short tasks
lost everything, long tasks lost the final batch.

Let codex exit on its own after stdin EOF (running its shutdown + flush path)
and only force-cancel after a bounded grace period if it doesn't, so the reader
goroutine still can't block forever. Also set cmd.WaitDelay, matching the other
long-lived backends (claude, copilot, cursor, …).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:46:07 +08:00
Multica Eve
13e9485a3b MUL-3130: persist stable /api/attachments/<id>/download URL in comment markdown (#3937)
* MUL-3130: persist a stable attachment download URL in comment markdown

Comment image attachments rendered as broken placeholders ~30 minutes
after upload because the editor was persisting a short-lived
HMAC-signed URL into the comment body. After PR #3903 (MUL-3132)
hardened /uploads/* with auth, `attachmentToResponse` started signing
`attachment.url` as `/uploads/<key>?exp=<unix>&sig=<HMAC>` for
LocalStorage so token-auth clients could keep loading inline images.
The signature has a 30-min TTL by design — but `useFileUpload` was
returning that signed value as `link` and the editor was writing
`![file](signed-url)` straight into the markdown, so the comment
permanently captured a URL that stopped working as soon as the
signature expired.

The fix is to persist a stable per-attachment URL that the server can
re-sign on every request:

* `useFileUpload` now returns `link = /api/attachments/<id>/download`
  (avatar uploads without an id still fall back to `att.url` so the
  pre-attachment-row code paths keep working).
* `DownloadAttachment` self-resolves the workspace from the attachment
  row instead of reading X-Workspace-Slug / X-Workspace-ID headers,
  and the route is registered under the auth-only group so a native
  browser <img>/<video> resource load (which cannot attach those
  headers) succeeds. Membership is checked inside the handler with
  a 404 deny shape so the route does not act as an IDOR oracle.
* A new `GetAttachmentByIDOnly` SQL query supports the workspace-
  derivation step.
* `AttachmentDownloadProvider` now extracts the attachment id from
  the stable URL when matching markdown refs to attachment records,
  with a fallback to the existing url-equality check for legacy
  comments (and S3/CloudFront markdown that points straight at the
  CDN).
* `contentReferencesAttachment` covers both URL shapes for the
  composer / standalone-list dedup paths so an attachment uploaded
  before the fix and one uploaded after both deduplicate cleanly.

Tests:
- New unit tests for the URL helpers (16 tests, packages/core).
- Backend regression test: bare `<img src>`-style request without
  workspace headers now succeeds for a member (200) and 404s for a
  non-member, replacing the previous "400 without workspace context"
  contract.
- Existing TestDownload*, TestServeLocalUpload*, TestAttachmentTo
  Response* and the 1220 frontend views tests all pass.

Refs: MUL-3130, GitHub issue #3891
Co-authored-by: multica-agent <github@multica.ai>

* MUL-3130: address PR review — split markdown link from upload link, swap render src

Two follow-ups from GPT-Boy's review on PR #3937.

(1) Don't reroute every upload consumer through the workspace-gated
    download endpoint.

    The previous change made `useFileUpload`'s `link` field unconditionally
    return `/api/attachments/<id>/download` whenever the upload had an id.
    But `useFileUpload` is also used by avatar / logo pickers
    (account-tab, workspace-tab, agents/avatar-picker, squads/squad-detail-page)
    that persist `result.link` directly into `avatar_url`. Avatars are
    referenced cross-workspace (mention chips, member lists, inbox
    items), so binding their URL to a workspace-membership-gated
    endpoint would silently break cross-workspace avatar visibility.

    The fix splits the URL into two semantically distinct fields:

      - `link`         — same as `att.url` (legacy contract). Avatar /
                          logo callers continue to use this and remain
                          on whatever URL semantics the storage backend
                          dictates.
      - `markdownLink` — the stable per-attachment URL
                          `/api/attachments/<id>/download`. Only the
                          editor's markdown-persisting flow consumes
                          this. Falls back to `link` for the
                          no-workspace upload branch (where there is
                          no attachment-row id to address).

    `editor/extensions/file-upload.ts` switches `image.src` and
    `fileCard.href` to `markdownLink ?? link` so comment markdown gets
    the stable shape while avatar callers stay on `link` unchanged.

(2) Make the render-time img src loadable for token-mode clients.

    Persisting the stable `/api/attachments/<id>/download` URL fixes the
    expiry problem but the path itself sits behind `middleware.Auth`,
    which expects either a `multica_auth` cookie or a Bearer token in
    `Authorization`. Native `<img>`/`<video>` resource loads from
    token-mode clients (Electron's default mode, the mobile app,
    legacy-token web sessions) cannot attach the Authorization header,
    so the bare URL would 401 immediately rather than 30 minutes later.

    `Attachment.normalize` now runs the resolved record through a new
    `pickInlineMediaURL` helper that returns:

      - `record.download_url` when it's an absolute URL with a
         recognised CDN signature query (CloudFront-signed
         `Signature` / `Expires` / `Key-Pair-Id`, or
         `X-Amz-Signature` for raw S3 presigns) — these load as
         native resource src in any client.
      - else `record.url`, which on the LocalStorage backend carries
         a freshly-minted `/uploads/<key>?exp&sig` query whose
         signature IS the auth (token-mode-loadable). On non-CF S3
         backends this is the raw stored URL — same behaviour as
         today.
      - else the original input URL (legacy / unresolved markdown
         keeps its existing path).

    This gives the same effect for both `kind: "record"` and
    `kind: "url"` attachment inputs: once a record is in hand, the
    rendered media src is whichever URL the current backend exposes
    a working signature on.

Tests:

  - New `file-upload.test.ts` regression pinning that `markdownLink`
    is what lands in the markdown body when the upload result returns
    both a short-lived storage URL and a stable download path.
  - Updated `attachment.test.tsx` to reflect the new render-time
    swap (the rendered img src now follows the freshly signed URL,
    not the raw storage URL) and added a record-mode regression
    pinning the LocalStorage default — when `download_url` is the
    bare /api/attachments/<id>/download path, the renderer must fall
    through to the signed `record.url`.
  - Updated `chat-input.test.tsx` makeUpload helper for the new
    `markdownLink` UploadResult field.
  - 1222 frontend views tests + 507 core tests + typecheck across
    @multica/{core,ui,views} all pass.

Refs: MUL-3130, GitHub issue #3891. Builds on a740f7a35.
Co-authored-by: multica-agent <github@multica.ai>

* MUL-3130: chat upload map keys on persisted markdownLink, not the short-lived link

GPT-Boy's second-round review on PR #3937 caught a chat-only blocker
left over from the previous fix.

After the previous commit split `UploadResult.link` into `link`
(legacy avatar/logo URL) and `markdownLink` (stable per-attachment
URL persisted into markdown), the comment editor's image src + file
card href correctly switched to `markdownLink ?? link`. But chat
input still kept the upload-map key on the old `link`:

  uploadMapRef.current.set(result.link, result.id)
  …
  if (content.includes(url)) activeIds.push(id)

In the LocalStorage backend `link` is the short-lived
`/uploads/<key>?exp=&sig=` URL. The editor persists the stable
`/api/attachments/<id>/download` URL into the message body, so
`content.includes(url)` never matches and the send call drops
`attachment_ids`. The attachment ends up bound only to the chat
session, not to the message — agents reading message-level metadata
see no attachments.

Fix: key the upload map on the same value the editor actually wrote
into the markdown body (`markdownLink || link`). The
`content.includes(url)` check then matches and the attachment id is
correctly forwarded on send.

Tests:

- Updated the chat-input mock editor to insert `markdownLink || link`
  into its value, mirroring the real editor's persisted-URL choice
  (uploadAndInsertFile in editor/extensions/file-upload.ts). Without
  this the mock would silently paper over the bug.
- Added a regression test where the upload result returns a
  short-lived `link = /uploads/...?exp&sig` and a stable
  `markdownLink = /api/attachments/<id>/download`. Asserts (a) the
  message body carries the stable URL and never the signed query,
  and (b) the bound `attachment_ids` includes the attachment id.

All 1223 frontend views tests pass (was 1222, +1 new regression).
Typecheck and 507 core tests still green.

Refs: MUL-3130, PR #3937 review by GPT-Boy. Builds on f66a522d0.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 14:26:36 +08:00
stevenayl
ee6200de25 fix(autopilot): fail no-progress issue runs (#3927)
Fail create-issue autopilot runs that hang in issue_created after a Codex
no-progress / semantic-inactivity task failure, so they surface as failed
and count toward the failure-rate auto-pause monitor.

- route failed create-issue issue tasks (no direct autopilot_run_id) into linked run sync
- fail linked runs only for Codex no-progress / semantic-inactivity failures
- wait when an active retry task still exists for the issue
- add classifier coverage + a DB-backed listener regression

VEN-661 / VEN-662 / MUL-3164
2026-06-09 14:25:04 +08:00
Chenyu-24601
8b94764c47 feat(daemon): configurable OpenClaw binary path / state dir via CLIConfig.Backends (MUL-3157)
Summary:
- Add CLI config schema for OpenClaw backend binary path and state dir overrides.
- Apply those overrides during daemon LoadConfig using the existing env-var based probe/spawn path.
- Cover backward compatibility, precedence, partial overrides, and fail-soft config loading.

Verification:
- go test ./internal/cli ./internal/daemon
- go vet ./internal/cli ./internal/daemon
- GitHub CI passed
2026-06-09 14:05:37 +08:00
Bohan Jiang
42251b42fc fix(cli): honor MULTICA_SERVER_URL in setup self-host (#3912) (#3938)
* fix(cli): honor MULTICA_SERVER_URL in setup self-host

`multica setup self-host` resolved the backend URL only from the
--server-url flag, falling back to http://localhost:8080 when the flag
was absent. It never consulted MULTICA_SERVER_URL, even though that env
var is documented on the root --server-url flag and in `multica --help`,
and is honored by every other command via resolveServerURL. A self-host
user who set the env var instead of the flag still hit localhost and got
"Server at http://localhost:8080 is not reachable".

Route server-url and app-url through cli.FlagOrEnv so the documented env
vars (MULTICA_SERVER_URL / MULTICA_APP_URL) are honored when the matching
flag is not set, with the flag still taking precedence. userProvided now
reflects flag-or-env, so an env-sourced remote URL still triggers the
explicit app_url prompt. Not platform-specific despite the report.

Fixes GitHub #3912.

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

* fix(cli): normalize MULTICA_SERVER_URL in setup self-host

MULTICA_SERVER_URL is documented as a ws:// daemon address
(ws://localhost:8080/ws) and every other command normalizes it via
NormalizeServerBaseURL before use. setup self-host consumed the resolved
value raw and probed <url>/health, so a self-hoster who set the
documented ws:// form would still fail the reachability check.

Run the flag/env value through normalizeAPIBaseURL (ws->http, wss->https,
strip /ws) so the documented form works and the stored server_url stays a
clean http(s) base. Add a normalization test case and a focused test for
the MULTICA_APP_URL env path (review nit).

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

* docs(self-host): note setup self-host honors MULTICA_SERVER_URL / MULTICA_APP_URL

Document that `setup self-host` reads the env vars when the matching flag
is omitted (flag wins), and that MULTICA_SERVER_URL accepts the ws://…/ws
daemon form. Added to en/zh/ja/ko quickstart for parity.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 14:02:06 +08:00
Bohan Jiang
7dc05d28bc fix(projects): validate project status/priority — return 400 instead of 500 (#3925) (#3939)
* fix(projects): return 400 (not 500) for invalid project status/priority

CreateProject/UpdateProject passed an unvalidated status/priority straight to
the INSERT, so an unknown value (e.g. --status active) tripped the table's
CHECK constraint and surfaced as a blanket 500 'failed to create project'
with no server-side log to diagnose it (#3925).

Pre-validate both enums against the column CHECK lists and return a 400 with
the allowed values. Back it with isCheckViolation -> 400 for any other
constrained column, and log the underlying error on genuine 500s so transient
DB failures are diagnosable.

MUL-3153

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

* fix(cli): validate project --status in create/update

project create and project update forwarded --status to the server without
checking it, while project status already validated. Share a single
validateProjectStatus helper across all three so a typo fails fast with the
valid list instead of a server round-trip.

MUL-3153

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 13:54:53 +08:00
LinYushen
9ff801f926 docs(cli): error-message conventions + sign-in copy (PR3, MUL-3104) (#3900)
* docs(cli): add Error Messages conventions + refine sign-in copy (PR3)

Final pass of the CLI error-message work (MUL-3104).

- CLI_AND_DAEMON.md: new "Error Messages" section documenting the user-facing
  contract — friendly single-line messages, server validation passthrough,
  English default with automatic Chinese on a zh locale, the tiered exit codes
  (0/1/2/3/4/5), --debug / MULTICA_DEBUG for the full chain, and
  MULTICA_HTTP_TIMEOUT.
- cmd_auth.go: clarify three high-frequency sign-in errors so the message
  states what failed and the next step — local login-callback server start
  (hints at port/firewall), access-token creation, and token verification
  (suggests retrying `multica login` and checking the token is valid/not
  expired). All keep %w so exit-code tiering and --debug detail are preserved.

cmd_id_resolver.go is left as-is — its not-found / ambiguous-prefix messages
already point at `list --full-id` and need no change. The user-facing
FormatError layer is unchanged, so its existing PR1/PR2 test coverage still
applies; no test asserted the old verb strings.

Refs MUL-3104. PR3 of 3 (final).

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

* fix(cli): make login failure guidance visible via typed user-message wrapper

Addresses 张大彪's PR3 review: the refined sign-in copy was wrapped with %w,
so FormatError returned the centralized *HTTPError/*NetworkError copy and the
new guidance only appeared under --debug.

- Add cli.UserMessageError + cli.WithUserMessage: a typed wrapper carrying a
  user-facing message that FormatError surfaces by default, recognized before
  the network/http branches. Unwrap() is preserved, so ExitCodeFor still
  classifies by the underlying typed error and --debug still prints the full
  original chain.
- cmd_auth.go: wrap the OAuth access-token-creation and PAT-verification
  failures with WithUserMessage (OAuth copy no longer mentions a passed token,
  since that flow has none), and move the token-specific 'valid / not expired'
  hint to the real Enter your personal access token:  verification site (was the generic
  'invalid token: %w').
- Focused tests: under a wrapped *HTTPError(401) the default FormatError shows
  the login hint, ExitCodeFor returns ExitAuth, and --debug retains the raw
  chain; a wrapped *NetworkError still classifies as ExitNetwork.
- CLI_AND_DAEMON.md: narrow 'every error' to command errors returned to the
  top-level handler, noting commands like setup's fast /health probe bypass it.

Refs MUL-3104, PR #3900.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 13:15:51 +08:00
Multica Eve
8ff68502fc MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP) (#3903)
* MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP)

Closes the open hardening items from the SVG XSS disclosure
(security-findings-2026-06-02). The primary chain (PR #3023 / #3050)
is intact; this PR addresses every remaining recommendation from the
disclosure's hardening list except 'serve uploads from a separate
origin' (a structural change beyond this fix).

Changes:

- /uploads/* now requires authentication. The route is wrapped in
  middleware.Auth so anonymous internet users can no longer fetch
  workspace attachments by guessing the URL. A new ServeLocalUpload
  handler then enforces the second layer:
    - workspaces/{wsID}/* paths require membership in wsID (uses
      MembershipCache for the hot path);
    - users/{userID}/* paths allow any authenticated user (avatars
      are referenced cross-workspace);
    - any other prefix returns 404, so a future feature cannot drop
      content under /uploads/<other-prefix>/ and inherit a relaxed
      policy by accident.
  Non-members see 404 (not 403) so the route does not act as an IDOR
  oracle for workspace IDs.

- Directory listing on /uploads/* is rejected at the storage layer:
  empty keys, trailing-slash keys, and any key that resolves to a
  directory return 404 before http.ServeFile would render an HTML
  index. UUID filenames were obscurity, but enumerating them
  shouldn't be free.

- Every successful /uploads/* response carries
  X-Content-Type-Options: nosniff and a tight per-response CSP
  (default-src 'none'; sandbox; frame-ancestors 'none'), overriding
  the application-wide CSP. This is belt-and-suspenders if a future
  regression weakens the Content-Disposition: attachment path.

- UploadFile rejects HTML-family uploads at the edge (.html, .htm,
  .xhtml, .shtml, .xht, .phtml, plus a content-type denylist for
  text/html and application/xhtml+xml so renamed payloads cannot
  bypass the extension check). SVG and JS remain allowed because
  their existing serve-side defenses neutralize them and source-code
  attachments preview as text/plain via /api/attachments/{id}/content.

Tests:

- storage: TestLocalStorage_ServeFile_RejectsDirectoryListing,
  TestLocalStorage_ServeFile_HardeningHeaders.
- handler: TestIsUploadDenied (pure), TestUploadFile_RejectsHTMLByExtension,
  TestUploadFile_RejectsHTMLByContentType, TestUploadFile_AllowsLegitimateImage,
  and the full ServeLocalUpload matrix (RequiresAuth, MemberCanRead,
  NonMemberDenied, RejectsDirectoryInPath, UnknownPrefixDenied,
  UserPrefixAllowsAnyAuthedUser).
- Full server test suite passes.

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

* MUL-3132: HMAC-signed query auth for /uploads/* (token-auth client compat)

Addresses J's Request Changes review on PR #3903.

Problem: PR #3903 wrapped /uploads/* in middleware.Auth, but native
<img>/<video>/<iframe> resource loads cannot attach Authorization
headers. Token-auth clients (Desktop default, legacy-token Web
sessions, mobile) were breaking on inline attachment rendering even
though the API itself authenticated fine.

Fix: implement HMAC-signed query parameters for /uploads/*, mirroring
S3 + CloudFront presigned URLs.

- storage.SignLocalUploadURL(rawURL, key, secret, expiry) appends
  '?exp=<unix>&sig=<HMAC-SHA256(key|exp)>' query params; signature
  is bound to one specific key, has a TTL matching CloudFront mode
  (defaultAttachmentDownloadURLTTL = 30 min), constant-time compared
  on verify.
- storage.VerifyLocalUploadSignature(key, exp, sig, secret, now)
  rejects expired, tampered, wrong-secret, and key-mismatched
  signatures.
- ServeLocalUpload now has two auth paths: signed-query (no Auth
  middleware needed; signature itself is the authority) and
  Bearer/cookie (membership-gated as before). Partial signed-query
  fails closed.
- The route in router.go dispatches between the two: if both exp+sig
  query params are present, route to inner handler unwrapped; else
  wrap in middleware.Auth.
- attachmentToResponse appends signed query to URL when the storage
  backend is *LocalStorage. CloudFront-signed download URLs and S3
  paths are unchanged.

Tests:
- storage: TestSignAndVerifyLocalUploadURL_RoundTrip,
  TestVerifyLocalUploadSignature_RejectsExpired, _RejectsTamperedSig,
  _BoundToKey, _RejectsWrongSecret,
  TestSignLocalUploadURL_PreservesExistingQuery,
  TestLocalUploadSignatureFromQuery_EmptyOnAbsence (7 pure tests).
- handler: TestServeLocalUpload_{SignedQueryBypassesAuth,
  SignedQueryRejectsExpired, SignedQueryRejectsTampered,
  SignedQueryBoundToOneKey, PartialSignedQueryFailsClosed},
  TestAttachmentToResponse_LocalStorageMintsSignedURL.

Full server test suite passes.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 11:59:00 +08:00
Jiayuan Zhang
072404d912 fix(issues): header chip shows 'is queued' when no agent is running (#3923)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 03:11:10 +08:00
Jiayuan Zhang
0c80c33c62 feat(issues): add brand border beam to active agent header chip (#3921)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 02:34:22 +08:00
Bohan Jiang
24b162cdbc feat(daemon): surface the real task initiator to the agent runtime (MUL-2645) (#3899)
* feat(daemon): surface the real task initiator to the agent runtime (MUL-2645)

In a multi-person workspace the agent runtime only ever saw the runtime
OWNER identity: the brief's `## Requesting User` is sourced from
runtime.OwnerID and the task-scoped token is owner-bound, so every
requester (whoever commented, @mentioned, or chatted) appeared to the
agent as the owner. Agents that route by initiator for permission,
privacy, or audit all misjudged.

Resolve the real task initiator at claim time and surface it distinctly
from the owner:
- comment / mention trigger -> triggering comment's author (member or agent)
- chat task -> chat session creator (sessions are creator-only)
- on-assign / autopilot / quick-create -> no attributable initiator (omitted)

Adds initiator_{type,id,name,email} to the claim response, the daemon
Task, and TaskContextForEnv, rendered into the brief as a new
`## Task Initiator` section. The section documents the privacy boundary:
the agent's credentials stay owner-scoped, so this is an attested
identity for the agent's own routing/privacy logic, not act-as. No DB
migration — both paths are derivable from existing rows.

Tests: brief rendering (member/agent/omit/sanitize) + email guard unit
tests, and claim-handler tests for the comment and chat paths.

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

* fix(chat): store real sender as task initiator, not chat_session creator (MUL-2645)

Review fix (Niko, PR #3899). v1 resolved the chat task initiator from
chat_session.creator_id at claim time. That is correct for web chat and
Lark p2p (creator == sender), but WRONG for Lark group chats: the group
session creator is deliberately the installer (stable identity across
member churn), not the message sender. So in a Lark group, every member
who triggered the agent showed up in the brief as the installer/owner —
the exact bug this issue is about, still live at that entry point.

Capture the real sender at enqueue time instead of deriving it from the
session creator at claim time:

- migration 117: agent_task_queue.initiator_user_id (FK user, ON DELETE
  SET NULL); NULL for non-chat and pre-migration rows.
- EnqueueChatTask now takes an explicit initiatorUserID. Web chat passes
  the authenticated request user; the Lark dispatcher threads the inbound
  sender (binding.MulticaUserID) through scheduleRun -> flushChatRun. The
  debouncer keeps the latest scheduled flush per session, so in a multi-
  sender silence window the LATEST sender wins (documented + tested).
- claim handler resolves the initiator from task.initiator_user_id and
  drops the creator_id fallback entirely.

The Lark group session creator stays the installer (unchanged) — only the
task initiator is corrected, keeping the two concepts cleanly separate.

Tests: dispatcher group regression (initiator = sender, not installer),
latest-sender-wins, p2p initiator assertion; the chat claim handler test
now sets creator != initiator and asserts the stored sender wins.

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-08 19:29:57 +08:00
chyax98
26ca943d45 feat(lark): add typing indicator lifecycle for inbound messages (#3860)
When a message is successfully ingested, send a Typing reaction to
the user's message. When the agent replies (EventChatDone) or fails
(EventTaskFailed), clear the reaction before the reply is visible.

- Add AddMessageReaction / DeleteMessageReaction to APIClient
- Implement reaction HTTP calls in httpAPIClient
- Introduce TypingIndicatorManager for per-session state tracking
- Wire into Hub (add on ingest) and Patcher (clear before reply)
- Skip typing for messages older than 2 minutes (WS replay guard)

Co-authored-by: miaolong001 <miaolong@xd.com>
2026-06-08 19:27:08 +08:00
Naiyuan Qing
139cd755e2 Revert "MUL-3127: reuse submit button in reply input (#3901)" (#3906)
This reverts commit d434f038c9.
2026-06-08 17:54:47 +08:00
Naiyuan Qing
51a214b4c0 MUL-3134: restore header agent popover (#3905)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 17:47:56 +08:00
Multica Eve
54dbb57aef MUL-3138: add June 8 changelog entry (#3904)
* docs: add June 8 changelog entry

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

* docs: simplify June 8 changelog titles

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-08 17:37:31 +08:00
Naiyuan Qing
f6999a9dcb MUL-3134: simplify issue agent header chip (#3902)
* MUL-3134: simplify issue agent header chip

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

* MUL-3134: remove execution log event count

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 17:29:34 +08:00
liujianqiang-niu
5be7d1bc17 MUL-3136 fix(openclaw): parse config path from last non-empty line of CLI output
Fix OpenClaw config discovery when `openclaw config file` prints Doctor warning UI before the actual config path. The daemon now uses the last non-empty stdout line as the path while preserving the existing tilde expansion, absolute-path validation, stat checks, and fail-closed behavior.

Tests: go test ./internal/daemon/execenv
2026-06-08 17:22:02 +08:00
Naiyuan Qing
d434f038c9 MUL-3127: reuse submit button in reply input (#3901)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 16:40:33 +08:00
LinYushen
b83b41ff44 feat(cli): per-status error copy with actionable hints (PR2, MUL-3104) (#3897)
* feat(cli): refine per-status error copy with actionable hints (PR2)

Builds on PR1's translation layer. Each HTTP-status message now carries an
actionable next step, in both English and Chinese:

- 401: run `multica login`; plus a self-hosted / non-OAuth fallback telling
  the user to ask their administrator for valid credentials
- 403: check the workspace / ask an admin to grant access
- 404: check the ID or run the matching `list` command
- 409: re-fetch the latest state and retry
- 422: check values / run with --help
- 429: wait and retry; reduce call frequency if it persists
- 5xx: retry, contact support, and re-run with --debug for the raw response

Also adds ErrorKind.String() (stable snake_case identifiers) and uses it in
--debug output instead of the raw int, and clears the pre-existing gofmt dirt
Eve flagged in cmd_config.go, cmd_version.go, and help.go.

Tests: TestErrorKindString (all kinds + uniqueness + out-of-range fallback)
and TestFormatErrorActionableHints (locks the per-status hints in EN and ZH).

Refs MUL-3104. PR2 of 3.

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

* test(cli): cover validation (400/422) actionable hint

TestFormatErrorActionableHints omitted KindValidation, so deleting the 400/422
hint would have gone unnoticed. Add 400 and 422 cases (no server message, so
the generic validation copy is used) asserting EN contains --help / expected
format and ZH contains --help / 格式 / 参数.

Refs MUL-3104, PR #3897.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 16:02:09 +08:00
LinYushen
28de8b8bde feat(cli): central error translation layer (PR1, MUL-3104) (#3892)
* feat(cli): add central error translation layer (PR1)

Introduce server/internal/cli/errors.go, a single user-facing error
translation layer that collapses raw transport errors, HTTP status
errors, and internal verb-wrapped chains into clear, localized messages.

- ErrorKind classification (network timeout/DNS/refused/TLS/offline,
  401/403/404/409/400+422/429/5xx, unknown)
- NetworkError wraps transport errors and strips the raw URL from the
  user-facing message; classifyNetworkError categorizes via errors.As/Is
  with string fallbacks
- HTTPError.Kind() maps status codes onto ErrorKind
- FormatError: bilingual output (English default, auto-switch to Chinese
  on a zh LC_ALL/LC_MESSAGES/LANG locale), validation errors surface the
  server message; --debug / MULTICA_DEBUG appends the full raw chain
- ExitCodeFor: tiered exit codes (network=2, auth=3, 404=4, validation=5,
  other=1)
- client.go: default HTTP timeout 15s -> 30s, overridable via
  MULTICA_HTTP_TIMEOUT; wrap every transport Do() error as *NetworkError
- main.go: route errors through FormatError + ExitCodeFor, add persistent
  --debug flag

Unit tests cover every ErrorKind, classification, language detection,
exit codes, server-message extraction, and timeout parsing.

Refs MUL-3104. PR1 of 3; PR2/PR3 (status-code copy refinement and
per-command customization) follow separately.

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

* fix(cli): address review — unify command timeouts and classify all helper errors

Must-fix 1: command-level contexts no longer truncate MULTICA_HTTP_TIMEOUT.
Added cli.APITimeout/AtLeastAPITimeout/APIContext (budget = transport timeout
+ small grace, honoring MULTICA_HTTP_TIMEOUT) and replaced the hardcoded 15s
context.WithTimeout in every API command (14 files, 92 sites) with
cli.APIContext. The issue-create/comment path now uses APITimeout() with a
60s floor for attachment uploads.

Must-fix 2: all API helpers now return *HTTPError on status >= 400. Added a
shared newHTTPError(method, path, resp) and routed GetJSON, GetJSONWithHeaders,
PostJSON, PutJSON, PatchJSON, DeleteJSON, DeleteJSONWithBody, UploadFile,
UploadFileWithURL, DownloadFile (and HealthCheck) through it, so issue
update/status/metadata (PUT), comment list (GetJSONWithHeaders), project/label/
comment delete (DELETE) and agent/workspace/autopilot update (PUT/PATCH) all
get HTTPError.Kind() classification, friendly copy, and the tiered exit code
instead of the raw string + exit 1.

Tests: new errors_integration_test.go drives the real helpers against a fake
server and asserts FormatError copy + ExitCodeFor for 401/403/404/422/500
across all 10 helpers, plus a slow-server test proving the command context
does not cancel before the transport timeout. Updated the UploadFileWithURL
assertion to check for *HTTPError.

Refs MUL-3104, PR #3892.

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

* fix(cli): make remaining fixed-timeout API commands honor MULTICA_HTTP_TIMEOUT

Closes out the timeout work: the last API command paths still used a
hardcoded context deadline that capped MULTICA_HTTP_TIMEOUT. Converted them
to cli.AtLeastAPITimeout(<original floor>) so the env override scales them up
while preserving each original lower bound:

- cmd_autopilot.go  autopilot trigger      30s -> AtLeastAPITimeout(30s)
- cmd_attachment.go attachment download    60s -> AtLeastAPITimeout(60s)
- cmd_agent.go      avatar upload           60s -> AtLeastAPITimeout(60s)
- cmd_skill.go      skill import / search    60s -> AtLeastAPITimeout(60s)
- cmd_runtime.go    runtime update         150s -> AtLeastAPITimeout(150s)
- cmd_login.go      workspace-creation poll 10s -> AtLeastAPITimeout(10s)

The login poll keeps a short 10s floor to stay responsive within its 5-minute
loop, but it is NOT a silent exception: AtLeastAPITimeout means it still scales
with MULTICA_HTTP_TIMEOUT. Documented in code and covered by a new subtest in
TestAPITimeoutRespectsEnv.

Refs MUL-3104, PR #3892.

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

* style(cli): gofmt cmd_attachment.go to unblock backend CI

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 15:34:59 +08:00
Bohan Jiang
1ddf89a8f2 feat(daemon): enable Antigravity (agy) per-agent model selection (MUL-3125) (#3894)
* feat(daemon): wire agy --model and model discovery for Antigravity

agy 1.0.6 added a --model flag and an `agy models` catalog command, which
were the #1 blocker in the earlier agy-backend review (MUL-3125). The
antigravity backend already shipped but deliberately dropped opts.Model
because agy 1.0.1 had no way to select a model.

- buildAntigravityArgs now passes --model <display name> when opts.Model is
  set; the value is the exact `agy models` display string (spaces + parens),
  passed as a single exec arg so no shell quoting is needed.
- Block --model in custom_args so it can't override the managed value.
- ListModels("antigravity") enumerates via `agy models` (no static fallback:
  agy silently no-ops on unrecognised models, so a stale guess would turn a
  typo into a successful empty run).
- ModelSelectionSupported now returns true for every built-in provider; the
  hook stays for any future model-less runtime.
- Daemon probe reads MULTICA_ANTIGRAVITY_MODEL for the daemon-wide default.

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

* docs(providers): mark Antigravity model selection as supported

Antigravity gained --model in agy 1.0.6 (MUL-3125). Update the provider
matrix + prose (en/zh/ja/ko) from "managed internally / no --model" to
dynamic discovery via `agy models`, and refresh the now-stale picker
comments. Flag the display-string (not slug) shape and agy's silent no-op
on unrecognised values.

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

* fix(daemon): reject unknown Antigravity model at spawn (MUL-3125)

agy exits 0 with empty output on an unrecognised --model, so a stale/typo'd
value would surface as a 'completed' but empty task. Validate opts.Model
against the `agy models` catalog in Execute before spawning: a non-empty
model the CLI does not advertise fails fast with an actionable error listing
the real choices. opts.Model is the single funnel for agent.model and the
MULTICA_ANTIGRAVITY_MODEL default, so this one check covers every source
(UI free-text, API, persisted value, env) — addressing Elon's review that a
UI-only guard is bypassable.

Validation is fail-OPEN: if the catalog can't be discovered we pass the
value through and let agy resolve it, so a discovery hiccup never blocks a
run. Pure antigravityModelError() is unit-tested (valid / unknown / near-miss
/ empty-model / empty-catalog); verified live against real agy 1.0.6.

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-08 15:32:53 +08:00
Bohan Jiang
3389e887e0 MUL-2614: randomize self-host Postgres password (#3893)
* fix: randomize self-host postgres password

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

* docs: sync self-host password guidance

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-08 15:26:47 +08:00
0xMomo
ef75f80d9d fix(daemon): clean stale agent branches during repo gc (MUL-2550) (#3039)
* fix(daemon): 清理陈旧 agent 分支

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

* fix(daemon): 串行化 bare repo gc

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

* test(daemon): adapt health repo cache mock

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

* fix(daemon): gate gc maintenance on stale-branch deletion

Address review feedback on the bare-repo GC change:

- Only run `reflog expire` + `git gc --prune=30.days` when we actually
  deleted a stale agent branch this cycle. Previously the heavy step
  ran every GC tick on every cached repo even when there was nothing
  to reclaim, turning a stale-ref cleanup into a periodic full-repo
  maintenance job under the per-repo lock.
- Split git command timeouts: `gc --prune=30.days` now gets a
  10-minute budget instead of sharing the 30s ceiling that was scoped
  for the original `worktree prune` call. Light commands stay at 30s.
- Drop the redundant `gc --auto` — `gc --prune=30.days` already
  performs the maintenance `gc --auto` would have triggered.
- Narrow the agent-namespace ref query from `refs/heads/agent` to
  `refs/heads/agent/` so the pattern can't surface a literal
  `agent` branch outside the daemon namespace.

Tests:
- New TestPruneWorktree_IgnoresLiteralAgentBranch pins the trailing-
  slash narrowing.
- New TestPruneWorktree_SkipsMaintenanceWhenNothingDeleted uses an
  unreachable, backdated loose object as a sentinel to verify that
  `gc --prune` runs only when a stale agent branch was reaped.

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

---------

Co-authored-by: 0xNini Code Dev <agent@multica.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: 0xNini <0xnini@iMac-Pro.local>
Co-authored-by: J <j@multica.ai>
2026-06-08 15:25:14 +08:00
Qiang Zhang
1abd0e33a6 fix(transcript): close dialog on desktop navigation (#2903) 2026-06-08 15:15:44 +08:00
Bohan Jiang
3808049361 fix(codex): set semantic thread names (#3887)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 14:53:31 +08:00
Naiyuan Qing
a02b3dfb4a feat(issues): move agent live signal into the issue-detail header (#3879)
* feat(issues): move agent live signal into the issue-detail header

Replace the in-body sticky "agent is working" card (AgentLiveCard) with a
compact chip in the issue-detail header, so the live signal sits in one
fixed place and never competes with sticky banners in the content column.

- New IssueAgentHeaderChip: avatar(s) + live-ticking blue elapsed time;
  click opens a popover listing every active task.
- Popover reuses ExecutionLogSection's ActiveTaskRow (now exported) so the
  popover and the right panel are literally the same row — no duplication.
- PopoverContent gains an optional keepMounted so the row's confirm dialog
  survives the popover closing on Stop.
- Running rows in ExecutionLogSection drop the blue spinner for a
  live-ticking blue elapsed timer (panel + popover share this).
- Source the chip from the workspace agent-task snapshot filtered by issue
  (same source as board/list indicators, zero extra network); delete the
  old AgentLiveCard + its test and its heavy per-issue WS machinery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): live event count on the agent chip + execution-log rows

Show a live "N events (elapsed)" on running agents, consistent across the
header chip, its popover rows, and the right-panel execution log.

- Read the shared per-task message cache (taskMessagesOptions, kept live by
  useRealtimeSync's global task:message handler) instead of a bespoke
  subscription — one source of truth, deduped across chip / popover / panel /
  transcript, no extra WS wiring.
- Extract <RunningStat> (event count in info-blue + elapsed in muted parens)
  so all surfaces render the running stat identically.
- ExecutionLogSection running rows now show the same "N events (elapsed)";
  the transcript opened from them streams live from the shared cache.
- Chip: single running shows events (elapsed); multiple shows "N working".
- i18n: add agent_live.event_count (4 locales).

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-08 14:51:20 +08:00
Bohan Jiang
dfc159e1aa feat: skip agent triggering on /note-prefixed comments (MUL-3115, #3649) (#3885)
* feat(comments): skip agent triggering on /note-prefixed comments

A comment whose first token is the reserved /note prefix (case-insensitive)
is stored like any other comment but never wakes an agent. The guard sits at
the top of triggerTasksForComment, the single chokepoint, so it covers all
three trigger paths — assignee, squad leader, and @mentioned agents. Gating
only shouldEnqueueOnComment (as originally proposed) would still let
"/note @agent ..." through the mention path.

Lets members leave human-only tips/notes on agent-assigned issues without
burning an agent run. MUL-3115, closes #3649.

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

* feat(editor): add /note built-in slash command to comment composer

Enable the `/` menu in the issue comment and reply composers in a new
"command" mode that lists fixed built-in commands instead of the chat
skill picker. Currently one command, /note, which marks a comment as a
human-only note that won't trigger the assigned agent.

Selecting it inserts the plain-text "/note " prefix (not a rich node), so
a menu pick and a hand-typed command are byte-identical and the backend
detects either with a simple prefix match. The command menu renders nothing
on a non-matching `/` (hideOnEmpty) so typing a date like 6/8 isn't noisy.
The chat skill picker is unchanged. MUL-3115.

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

* refactor(editor): match /note by label prefix and localize its description

Address PR review feedback:
- buildBuiltinCommandItems now matches the command label as a prefix only,
  dropping the description substring match copied from the skill picker. With
  one command this keeps the menu predictable (/no surfaces note; /deploy or a
  description word like /agent shows nothing) and avoids Enter selecting note
  unexpectedly.
- The command description is now a localized UI string: added
  slash_command.commands.note to all four editor locales (en/ja/ko/zh-Hans)
  and the menu renders it via the typed translator. The /label itself stays
  literal since it's the typed token the backend matches.

MUL-3115.

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

* fix(editor): shorten /note command description to avoid truncation

The slash menu item is single-line (truncate, w-72), so the longer copy was
cut off. Shorten to "won't trigger any agents" across all four locales — also
more accurate, since /note skips assignee, squad leader, and @mentioned agents,
not just the assigned one.

MUL-3115.

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-08 14:50:52 +08:00
xiaoyue26
10076ae773 MUL-3123 fix(realtime): support X-Forwarded-Host in WebSocket checkOrigin 2026-06-08 14:43:20 +08:00
Bohan Jiang
7b453ff604 fix: show assignee avatar in command search (#3889)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 14:29:45 +08:00
Bohan Jiang
f5db77340f feat(web): native notification banners for the web app (MUL-3116) (#3883)
The in-app inbox (sidebar badge, real-time WS updates, settings, inbox
page) was already shared and worked on web. The only Desktop-only piece
was the native OS banner: handleInboxNew called desktopAPI.showNotification,
which is undefined on web, so no banner fired for new inbox items while the
app was unfocused.

Add the browser equivalent, keeping handleInboxNew as the single decision
point (focus + source-workspace mute gating stays shared with desktop):

- packages/core/platform/system-notification.ts: browser Notification engine
  (showWebNotification) + permission helpers + a click-handler registry. Lives
  in core (the caller does) but injects the click-routing decision so core
  stays headless.
- handleInboxNew: branch desktopAPI (unchanged) → else showWebNotification.
- apps/web WebNotificationBridge: registers click routing to the source
  workspace's inbox (?issue=…), mirroring desktop's DesktopInboxBridge.
- Settings → Notifications: web-only opt-in to grant browser permission
  (hidden on desktop / where the API is unavailable); en/zh-Hans/ja/ko.

Permission is an explicit settings opt-in (no auto-prompt on load, per
browser best practice). Tests cover the engine and the web path in
handleInboxNew.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 14:12:20 +08:00
Bohan Jiang
0da879ec89 fix(runtime): pause autopilots inside the runtime-delete teardown transaction (#3880)
DeleteAgentRuntime paused autopilots for the runtime's archived agents
just outside the teardown transaction, so a pause that succeeded before a
later delete failed (and rolled back) left autopilots paused while the
runtime survived. Move ListArchivedAgentIDsByRuntime +
PauseAutopilotsByAgentAssignees inside the tx via qtx and treat a pause
error as a hard failure, matching ArchiveAgentsAndDeleteRuntime.

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 14:04:13 +08:00
NanamiKite
2e34016f1f fix(daemon): interrupt local agent on server-side terminal task states (#3878)
shouldInterruptAgent now treats every terminal task status (completed/failed/cancelled, via isAgentTaskTerminal) plus a 404 task-not-found as an interruption signal, so the daemon stops a local agent once the backend has finalized the task — e.g. the runtime offline sweeper flipping running -> failed during a disconnect/reconnect. Previously only `cancelled`/404 interrupted, so the agent ran to completion and its CompleteTask call failed against a non-running row, wasting compute and adding log noise.

Closes #3877
2026-06-08 14:00:30 +08:00
yuhaowin
bcc7cd3688 fix(projects): add backdrop-blur to compact list header (#3783)
The sticky header in the Projects compact list was missing backdrop-blur,
causing underlying content to bleed through the semi-transparent bg-muted/30
background when scrolling. Matches the DataTable header pattern used in the
Agents module.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 13:45:59 +08:00
LinYushen
b89b9cb4d6 test(migrate): concurrent migration race test using real Postgres (MUL-2956) (#3712)
* test(migrate): add concurrent migration race test using real Postgres (MUL-2956)

Follow-up to MUL-2923 / #3658, which added a Postgres advisory lock to
serialize the migration loop across concurrent runners (multi-replica
backend startup, scale-up, manual `migrate up` overlap). That PR shipped
without a test because cmd/migrate/ had no harness; this commit adds it.

Refactor: extract runMigrations(ctx, pool, runOptions) from main(), with
the lock key, the bookkeeping table, and the file list now injectable.
main() behavior is unchanged. Identifier interpolation goes through
pgx.Identifier{}.Sanitize so callers can pass "schema.schema_migrations"
safely.

Tests (cmd/migrate/migrate_concurrent_test.go) — every case isolates
itself in a unique throwaway schema and a unique lock key, so they
never touch the real schema_migrations table or block real production
runners that share the database. Skip cleanly when DATABASE_URL is
unreachable, matching the pattern already used in
internal/handler/handler_test.go and internal/metrics/business_sampler_pgsleep_test.go.

  - TestRunMigrationsConcurrentPending: 16 goroutines apply 5
    deliberately non-idempotent migrations (bare CREATE TABLE +
    ALTER TABLE ADD COLUMN). Without the lock, concurrent CREATE TABLE
    races trip "duplicate key value violates unique constraint
    pg_type_typname_nsp_index" — proving the lock is doing its job.
  - TestRunMigrationsConcurrentAlreadyApplied: 16 goroutines hit the
    EXISTS no-op path against a pre-populated bookkeeping table; the
    state must be unchanged.
  - TestRunMigrationsAdvisoryLockSerializes: an external connection
    holds the same advisory lock; we assert that zero of the 16
    runners complete during a 1 s observation window, then release
    the side lock and let them all finish. Catches the original
    MUL-2923 bug where the lock got attached to a random pooled
    connection.
  - TestRunMigrationsConcurrentMixedPoolStress: same pending case but
    with a deliberately small pool (runners/2), forcing pgxpool.Acquire
    contention to overlap with pg_advisory_lock contention.

Verified locally: `go test -race -count=10 ./cmd/migrate/` passes in
~15 s. Mutation test (lock acquire/release replaced with `SELECT 1`)
confirms the pending and lock-serializes tests both fail loudly,
catching the regression they were written to detect.

go.mod tidy promotes golang.org/x/sync to a direct dependency
(now imported by the test for errgroup) and incidentally fixes a
stale `// indirect` annotation on prometheus/client_model, which is
already imported directly by internal/metrics/testutil.go.

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

* test(migrate): gofmt + address review nits (MUL-2956)

- gofmt -w cmd/migrate/migrate_concurrent_test.go: fixture struct field
  alignment.
- quoteQualifiedIdentifier: actually reject identifiers with more than
  one dot (the previous version split on the first dot only and would
  silently sanitize "a.b.c" into "a"."b.c", contradicting the comment).
  Inline the splitter via strings.Split now that we explicitly check the
  component count.
- Soften the test's lock-key comment from "never collide" to the
  accurate probabilistic statement (~1 in 2^62 collision odds with the
  production constant).

go test -race -count=10 ./cmd/migrate/ still passes (~15 s).

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

* test(migrate): direction whitelist + tidy go.mod (MUL-2956)

Address two follow-ups from review:

- runMigrations now whitelist-checks opts.Direction up-front and
  returns an error for anything that is not "up" or "down". The
  previous shape relied on `opts.Direction == "up"` and an else branch,
  so a typo or empty string would silently fall through to the
  rollback path. Add TestRunMigrationsRejectsInvalidDirection covering
  the empty string, "UP"/"DOWN" case mismatches, "rollback", and a
  whitespace-padded value; the check fires before any pool work, so
  the test runs without Postgres.
- go mod tidy: promotes google.golang.org/protobuf to a direct
  dependency (it is imported directly elsewhere in the module and was
  stale-marked indirect).

go test -race -count=10 ./cmd/migrate/ green (~15.7 s, 50/50).

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

---------

Co-authored-by: wei-heshang <wei-heshang@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 13:33:16 +08:00
HMYDK
4190de3d64 fix(skills): quote description values in built-in SKILL.md YAML frontmatter (#3852)
Built-in SKILL.md description values contained unquoted ': ' sequences, which strict YAML parsers (e.g. Codex) reject — silently dropping the skill at load.

- Quote all eight built-in skill descriptions.
- ensureSkillFrontmatter() re-synthesizes frontmatter that has a name but fails YAML validation, so malformed imports are repaired instead of dropped.
- Unify frontmatter delimiter parsing into a single frontmatterParts helper.
- Add strict-YAML regression tests over the built-in skills, plus unit tests for the recovery branch and delimiter variants.

Closes #3851.
2026-06-08 13:10:24 +08:00
Thanh Minh
8abdc77961 MUL-2489 fix(runtime): delete archived squads before runtime teardown (#2955)
* fix(runtime): delete squads referencing archived agents before runtime teardown

The DeleteAgentRuntime handler was failing with 500 'failed to clean up
archived agents' because squad.leader_id has an ON DELETE RESTRICT FK on
agent(id). When an archived agent was still referenced as a squad leader
(even on an archived squad), the DELETE FROM agent query was blocked.

Fix: add DeleteSquadsByArchivedAgentsOnRuntime query that removes squads
whose leader_id points to an archived agent on the target runtime, and
call it before DeleteArchivedAgentsByRuntime in the handler.

Closes TMI-85

* test(runtime): cover squad cleanup before archived-agent deletion

Adds four tests around the DeleteSquadsByArchivedAgentsOnRuntime fix:

* TestDeleteSquadsByArchivedAgentsOnRuntime_Query — query-level: deletes
  squads whose leader is an archived agent on the target runtime, leaves
  squads with active leaders or archived leaders on a different runtime
  alone, and is safe to call when nothing matches. Covers the archived-
  squad case that originally hid the FK blocker from `multica squad list`.
* TestDeleteAgentRuntime_RemovesSquadsLedByArchivedAgents — handler
  end-to-end regression for TMI-85. Reverting the handler change makes
  this fail with the exact 500 'failed to clean up archived agents' the
  user reported.
* TestDeleteAgentRuntime_NoSquadsRegression — happy path for runtimes
  whose archived agents were never squad leaders, ensuring the new step
  is a no-op there.
* TestDeleteAgentRuntime_StillBlockedByActiveAgents — preserves the 409
  CountActiveAgentsByRuntime guard so the active-agent contract isn't
  silently regressed by the new cleanup ordering.

Refs TMI-85

* chore: remove internal issue tracker references from test comments

* fix(runtime): keep active squads during runtime teardown

* fix(runtime): block runtime delete on active archived-leader squads

* fix(runtime): make runtime delete 409 path a no-op

---------

Co-authored-by: Kiro <kiro@multica.ai>
2026-06-08 13:08:38 +08:00
Prince Pal
d6e00e0909 fix(daemon): fail loudly when self-restart spawn fails (#2503)
* fix(daemon): fail loudly when self-restart spawn fails

* fix(daemon): surface log reopen failures on restart
2026-06-08 13:02:31 +08:00
Wes
5e7587ad07 Optimize daemon runtime wakeups (#3859) 2026-06-08 12:51:13 +08:00
Antoine GIRARD
4ea295e5fa MUL-2375 fix(docker): pass VERSION, COMMIT, DATE build args to docker build 2026-06-08 12:48:21 +08:00
Naiyuan Qing
3f98ada547 fix(desktop): guard updater events after window destroy (#3871)
* fix(desktop): guard updater events after window destroy

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

* test(desktop): cover updater send destroy race

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 10:23:45 +08:00
Bohan Jiang
83ac61e2a1 fix(mobile): border + className passthrough on WorkspaceAvatar logo branch (#3849)
The logo (resolved avatar_url) branch was missing the border the fallback
tile and web's <img> carry, and didn't thread the className prop. NativeWind
has no cssInterop for expo-image, so className/border on <ExpoImage> is
silently dropped — wrap the logo in an overflow-hidden View that carries
border border-border + className (the same pattern lib/markdown/markdown-image.tsx
uses to border/round an expo-image). Both branches now match web parity.

Follow-up to #3839. MUL-3096

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-06 23:04:49 +08:00
Bohan Jiang
d9e6d7807b fix(runtimes): show each agent's own CLI version in the runtime list (#3850)
The per-agent "CLI" column rendered the shared multica daemon `cli_version`
from each runtime's metadata. That value is the version of the multica
daemon binary and is identical for every agent registered by one daemon, so
Claude / Codex / Gemini / Opencode all displayed the same number (e.g.
v0.3.17) even though each tool has its own version (#3838).

Each runtime already reports its own underlying CLI tool version in
`metadata.version` (e.g. "2.1.5 (Claude Code)", "codex-cli 0.118.0"). The
column now shows that. The multica daemon CLI version and its update prompt
stay where they belong — the machine meta strip and the detail page's
UpdateSection — so the per-row multica update arrow (which compared against
the latest multica release) and its now-unused i18n strings are removed.

MUL-3097

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-06 23:04:25 +08:00
Matt Voska
96dbe88774 fix(mobile): render workspace logos and use English copy in workspace switcher (#3839)
The workspace switcher showed a generic `sf:building.2` glyph for every
workspace and never used `workspace.avatar_url`, and the switch sheet,
confirm dialog, and More-tab entry row shipped hardcoded Chinese strings
(mobile is English-only — no i18n infra yet).

- Add `components/workspace/workspace-avatar.tsx`, mirroring web's
  `packages/views/workspace/workspace-avatar.tsx`: a resolved `avatar_url`
  renders as a rounded-square logo, otherwise the workspace's initial
  letter sits in a muted tile. URL resolution reuses the existing
  `resolveAttachmentUrl` helper (the mobile mirror of core's
  `resolvePublicFileUrl`).
- Use `WorkspaceAvatar` in the switcher list and the More-tab entry row.
- Replace the hardcoded Chinese strings with English.

Co-authored-by: Matt Voska <voska@users.noreply.github.com>
2026-06-06 22:34:53 +08:00
Anderson Shindy Oki
f8fb3fdcd1 fix: swimlane view filters (MUL-3072) (#3645)
* fix: Swinlane view filters

* refactor: Comments
2026-06-06 22:34:15 +08:00
Bohan Jiang
b0246ef18a fix(lark): hide only the Lark (international) connect entry; keep Feishu (#3835)
Mainland Feishu binding works; only the newly-added Lark (international,
open.larksuite.com) install path is unreliable — some Lark installs
complete on Lark's side but never persist a lark_installation row (no WS,
no inbound, no task). Hide just the "Bind to Lark" CTA behind a single
LARK_INTL_CONNECT_ENABLED flag and leave the "Bind to Feishu" entry, the
settings panel, and all existing-installation management untouched.

Flip LARK_INTL_CONNECT_ENABLED back to true to restore the Lark CTA;
nothing else changes. Temporary measure while the Lark install-landing
bug is investigated.

- LarkAgentBindButton: the Lark button is gated by the flag; the Feishu
  button and the Connected badge / Manage / Disconnect are unchanged.
- Tests: the CTA tests assert Feishu shown + Lark hidden; the Feishu
  click-to-begin (region=feishu) test stays; the Lark click test was
  removed (no button) and noted for restore; the dialog polling-error
  tests open via the Feishu CTA.

MUL-3083

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 19:01:13 +08:00
Multica Eve
a3203e9628 docs: clarify Feishu changelog copy (#3834)
* docs: remove Lark notes from June 5 changelog

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

* docs: clarify Feishu changelog copy

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

* docs: clarify June 5 changelog title

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 18:58:08 +08:00
Bohan Jiang
f8bd1d8fc2 feat(lark): show real speaker names in Feishu group context (MUL-3084) (#3828)
* feat(lark): resolve real speaker names in group context (MUL-3084)

The recent-context block (and quoted/forwarded blocks) labeled senders
positionally as "User 1 / User 2", and the agent had no idea who had
@-mentioned it. Add APIClient.BatchGetUsers (contact/v3/users/batch) and,
on the group prefetch path, resolve the surrounding speakers AND the
trigger sender to display names in one batch call. Speakers now render as
"[Alice]: ..." and the user's own message as "[Charlie]: ..." so the
agent knows who addressed it. Unresolved senders (restricted contact
scope, deactivated user) fall back to positional "User N"; resolution is
best-effort and never blocks ingestion. Closes the standing speaker-name
TODO in the enricher.

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

* fix(lark): resolve names for quoted/forwarded senders too (review)

Address the #3828 review: BatchGetUsers only included the recent-window
and trigger senders, so a quoted parent / merge_forward child whose
sender was NOT in the recent window still rendered as "User N".

Restructure Enrich into fetch (Phase 1) -> resolve names (Phase 2) ->
render (Phase 3): quote/forward items are now fetched up front and their
senders folded into the single Contact batch, so every block (recent +
quoted + forwarded) shows real names in group chats. p2p keeps positional
labels. Replaces the fetch+render renderQuoted/renderForwarded with a
render-only renderQuotedBlock plus an inline forward fetch.

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-05 18:32:31 +08:00
Multica Eve
05e38e5d37 feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083) (#3832)
* feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083 follow-up)

The single "Bind to Lark" button began the device flow against
accounts.feishu.cn and relied on a mid-poll tenant_brand="lark" to
auto-switch international users over to accounts.larksuite.com. Lark
users had to scan a QR served from a Feishu domain first, which
surfaced as confusing in real use.

Replace with two explicit CTAs side by side — "Bind to Feishu" and
"Bind to Lark" — and route the device-flow begin straight to the
matching accounts host based on the user's choice. The mid-poll
auto-switch is preserved as a safety net for users who pick the wrong
entry.

Backend
- RegistrationClient.Begin(ctx, namePreset, region): POSTs to
  c.cfg.LarkDomain when region=lark, c.cfg.Domain otherwise. Empty /
  unknown region falls back to Feishu (matches RegionOrDefault).
- BeginInstallParams.Region threads through to the registration session
  and onto runPolling's initial region local. SwitchedDomain still
  flips it on tenant_brand=lark.
- POST /api/workspaces/{id}/lark/install/begin accepts ?region=feishu|lark
  with empty defaulting to feishu for back-compat.

Frontend
- api.beginLarkInstall(wsId, agentId, region) — region now required
  so every call site is forced to pick a cloud explicitly.
- LarkAgentBindButton renders two buttons; dialog state collapsed into
  a single dialogRegion useState so an "open but with no region picked"
  intermediate state can't exist.
- LarkInstallDialog takes region as a required prop and renders
  region-aware copy (title, description, scan hint, link fallback,
  success toast).

i18n
- Add bind_button_{feishu,lark}, install_dialog_{title,description}_*,
  install_scan_hint_*, install_open_link_fallback_*, and
  install_success_toast_* keys across en, zh-Hans, ja, ko. Legacy
  single-region keys are kept for now; nothing in the tree references
  them anymore but a follow-up cleanup can remove them once the dust
  settles.

Tests
- Two new lark.RegistrationClient tests pin region routing in both
  directions (region=lark hits LarkDomain; region=feishu hits Domain).
- Two new lark-tab.test.tsx cases pin that clicking each CTA calls
  beginLarkInstall with the matching region argument. Existing CTA
  tests updated to expect both buttons in place of one.

MUL-3083

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

* fix(lark): bidirectional tenant_brand swap + region-aware badge + link context menu

Addresses Elon's review on PR #3832 plus a separate report that the
"Or tap here to open in Lark" link in the install dialog had no
standard right-click affordances on the desktop app.

Backend (must-fix from review)

The PR's stated 'safety net for users who pick the wrong CTA' only
worked one direction: a Feishu-first begin already swapped to Lark on
tenant_brand=lark, but the new Lark-first begin (added by this same PR)
had no reverse path — a user who picked 'Bind to Lark' but actually
authorized with a Feishu account would carry RegionLark all the way
through finishSuccess and either fail at GetBotInfo or commit a
wrong-region row.

- PollResult now carries SwitchedDomain AND SwitchedRegion in
  lockstep, so the caller never has to re-derive region from the
  domain string.
- Poll() detects tenant_brand=feishu while polling against a non-Feishu
  host symmetrically with the existing tenant_brand=lark check, gated
  on the current host so we don't loop on a brand we already match.
- runPolling reads region from res.SwitchedRegion instead of the
  hardcoded RegionLark — the SwitchedDomain branch now flips both
  feishu→lark and lark→feishu cleanly.
- Tests: updated the existing TestRegistrationClient_Poll_DomainSwitchOnLarkTenant
  to assert SwitchedRegion, added TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant
  for the reverse, and TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost
  (table-driven, both directions) to pin that the gate doesn't loop.

Backend (nit from review)

Handler comment on /lark/install/begin claimed unknown region defaults
to Feishu downstream, but the handler already returns 400 on unknown
values. Updated the comment to match the actual behavior and document
why we 400 rather than silently normalize (so a frontend typo can't
land users on the wrong cloud without telling them).

Frontend (nit from review)

The Agent inspector's Connected badge was hardcoded 'Connected to
Lark' / 'Manage in Lark' (en) and 'Connected to Feishu' / 'Manage in
Feishu' (zh-Hans) — both wrong half the time now that the install
flow can land on either cloud per agent. Made the badge text and
Manage tooltip read from installation.region:

- agent_bot_connected_label_{feishu,lark}
- agent_bot_manage_link_{feishu,lark}
- agent_bot_manage_tooltip_{feishu,lark}

across en / zh-Hans / ja / ko. Legacy single-region keys retained for
safety. Existing badge tests updated: fixtures without 'region' now
expect the Feishu copy; the region: 'lark' test was promoted to also
assert the Lark badge text and link target. 21/21 lark-tab tests pass.

Desktop (separate report)

Right-clicking an <a> in the renderer surfaced only Copy / Cut /
Paste / Select All — no 'Open Link in Browser' or 'Copy Link Address'.
The renderer's <a target="_blank"> click path already routes through
setWindowOpenHandler → openExternalSafely, but discoverability via the
context menu was missing.

context-menu.ts now appends two link-specific items when params.linkURL
is an http(s) URL. Open Link routes through openExternalSafely (reuses
the existing scheme allowlist); Copy Link Address writes to Electron's
clipboard. Labels are localized to the OS preferred language for the
four locales the renderer ships (en / zh-Hans / ja / ko); zh-* variants
all route to zh-Hans, anything else falls back to English. New
context-menu.test.ts pins five cases: link items show for http(s),
not for javascript:/mailto:/etc., not when no link is under the cursor,
zh-CN gets Chinese, fr-FR falls back to English. 198/198 desktop tests
pass.

MUL-3083

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: Jiang Bohan <bhjiang@outlook.com>
2026-06-05 18:30:19 +08:00
Multica Eve
887d7c2a5e docs: add June 5 changelog entry (#3829)
* docs: add June 5 changelog entry

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

* docs: update June 5 changelog title

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

* docs: mention comment composer cleanup in changelog

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-05 17:52:54 +08:00
Naiyuan Qing
ef8dabd35d feat(lark): split agent integration UI into inspector status + tab actions (#3830)
The agent Lark binding surfaced the same connect/disconnect affordance in
two places on one page — the left inspector's INTEGRATIONS section and the
right pane's Integrations tab both rendered the full LarkAgentBindButton,
so the destructive Disconnect lived in two spots.

Split by role:
- Inspector (left): a compact, read-only status row (green dot + region
  chip + "Connected to Lark") that deep-links into the Integrations tab.
  New LarkAgentBotStatusRow, opted into via LarkAgentBindButton's
  onShowConnectedDetails prop.
- Integrations tab (right): keeps the full badge, now the single home for
  Manage / Disconnect. The badge itself is reworked to a two-row layout —
  status (left) + soft `destructive`-variant Disconnect (right) on row 1,
  "Manage in Lark" demoted to a muted secondary link on row 2.

Cross-sibling navigation goes through a one-shot navIntent channel on
AgentOverviewPane that routes via requestTabChange, so the unsaved-changes
guard still fires when jumping from the inspector.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:35:52 +08:00
Bohan Jiang
659ac2d99a Revert "docs: add db-backed scheduler RFC (#3701)" (#3831)
This reverts commit 23a69f7682.
2026-06-05 17:33:22 +08:00
Naiyuan Qing
6a9e07f6d6 fix(issues): remove comment composer expand control [MUL-3086] (#3818)
* fix(issues): remove comment composer expand control

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

* feat(issues): auto-grow composers and highlight reply submit when ready

- Drop max-height cap on comment + reply composers so they grow with content
- Reply send button turns primary when there is submittable text

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:24:27 +08:00
Multica Eve
14f89bc08a Fix Claude control request handling (#3827)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 17:14:33 +08:00
Multica Eve
5586b5d46a feat(lark): unbind affordance on the agent connected badge (MUL-3090) (#3826)
Surface a Disconnect/Unbind action in LarkAgentBotConnectedBadge so
owners and admins can remove a Lark Bot binding directly from the
agent inspector — no detour to Settings. The button sits next to the
existing 'Manage in Lark' link and is intentionally rendered as a
quieter muted-foreground control with a hover-destructive accent so
it doesn't compete visually but stays discoverable.

Confirmation is mandatory: a small AlertDialog reuses the existing
disconnect_confirm_* i18n strings. The action calls
api.deleteLarkInstallation, invalidates larkKeys.installations(wsId)
on success so the parent re-renders the Bind CTA, and toasts
success/failure. Cancel is disabled while the request is in-flight
to prevent racing the close.

Tests cover button visibility, confirm gating, success path (delete
called with correct args, cache invalidated, toast), error path (no
invalidate, toast.error), and Cancel-disabled behaviour.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 17:02:58 +08:00
Xinmin Zeng
270d177475 fix: broken "Add a computer" command on Multica Cloud + two CLI amplifiers (MUL-3087) (#3817)
* fix(server): recognize official cloud by frontend host in daemon setup config

The 'Add a computer' dialog builds its command from /api/config's
daemon_server_url/daemon_app_url, falling back to 'multica setup' when
both are empty. The official cloud is meant to omit them, but the
omission only fired when MULTICA_PUBLIC_URL=https://api.multica.ai. When
that env is unset the server URL defaults to the frontend origin and the
old guard (which required serverURL host == api.multica.ai) didn't match,
so the dialog emitted 'multica setup self-host --server-url
https://multica.ai' — pointing the daemon backend at the frontend (no
/health, no WebSocket proxy).

Identify the official cloud by its frontend host alone (multica.ai /
app.multica.ai) so a missing or misconfigured MULTICA_PUBLIC_URL can no
longer leak the broken self-host command. Regression from #3474.

* fix(cli): probe before persisting self-host config to preserve auth on failure

setup self-host wrote a fresh CLIConfig{ServerURL, AppURL} (a full
overwrite that drops the saved token) and only then probed the server,
returning early on failure. A failed probe therefore logged the user out
and left them unconnected, with no recovery in the same command.

Probe first via persistSelfHostConfigIfReachable: an unreachable server
leaves the existing config — and its token — untouched (failed setup =
no-op). The prober is injected so both branches are unit-tested.

* fix(daemon): serve health before preflight so daemon start readiness is accurate

The CLI's 'daemon start' polls the health endpoint for 15s expecting
status=running, but the daemon only began serving health after
preflightAuth, whose initial workspace sync detects every configured
agent's version by exec'ing it (~20s cold with 8 agents). Health served
too late, so a perfectly healthy daemon printed 'may not have started
successfully'.

Start the health server right after resolveAuth (which still fails fast
on a missing token) and before the slow preflight, so readiness reflects
the daemon core being up rather than agent-version detection finishing.

* fix(daemon): gate /health readiness so daemon start can't report a false start

Serving health before preflightAuth fixed the false-negative (a healthy
daemon printed "may not have started"), but health still returned
status:"running" unconditionally — before preflight (PAT renew + workspace
sync + runtime registration) had completed. `daemon start` and the desktop
treat "running" as ready, so a slow or *failing* preflight could be
misreported as a started daemon: setup prints "connected", then the process
exits or hangs in agent-version detection with no runtime registered. That
is harder to diagnose than the original false-negative.

Split liveness from readiness: bind/serve the health port early (so callers
see a live "starting" daemon instead of connection-refused), but report
status:"starting" until d.ready is set after preflight, then "running".

- daemon.go: add d.ready (atomic.Bool); set it true after the background
  loops launch, before pollLoop.
- health.go: healthHandler reports "starting" until ready, else "running".
- cmd_daemon.go: `daemon start` waits for "running" with a deadline raised
  to 45s (covers cold-start agent detection) and a clearer "still starting"
  message; new daemonAlive() helper treats both "running" and "starting" as
  a live daemon, so the already-running guard, restart, and stop act on a
  starting daemon and don't double-spawn or race its listener; `daemon
  status` shows "starting" distinctly.

Older CLIs/desktop that only know "running" safely treat "starting" as
not-ready (status != "running"), so no boundary break.

Tests: health reports starting-then-running; daemonAlive truth table.
Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): handle daemon "starting" health status in lifecycle

The daemon now reports /health status:"starting" until preflight completes
(liveness/readiness split). That made "starting" a new external contract of
/health, but the Desktop daemon-manager only knew "running", so the readiness
fix would have moved the CLI's false-negative into a Desktop start regression:

- `daemon start` now blocks up to 45s waiting for readiness, but the Desktop
  spawned it via execFile({ timeout: 20_000 }). On a cold start (the ~20s agent
  detection this PR targets) Electron killed the CLI supervisor at 20s and
  reported a start failure, even though the detached daemon child kept booting —
  the UI flashed "stopped" then "running". Raise the timeout to 60s (must exceed
  the CLI's 45s startupTimeout).
- The Desktop treated only raw status === "running" as a live daemon, so a
  daemon that was still "starting" (booting on its own or started via the CLI)
  showed as "stopped", and startDaemon() would spawn a second one — which the new
  CLI rejects as "already running", surfacing as a start error.

Add daemonStatusAlive() (shared, pure, unit-tested) mirroring the Go daemonAlive()
and use it for liveness: fetchHealth() surfaces a daemon-reported "starting" as
state "starting" regardless of our own currentState; startDaemon()'s
already-running guard and the restart-on-user-switch guard treat "starting" as an
existing daemon. version-decision stays gated on "running" (readiness, not
liveness) — unchanged.

Verified: desktop typecheck, eslint, full vitest suite (193 tests) all pass.
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-05 17:01:23 +08:00
Bohan Jiang
6d0b9e3918 feat(lark): prefetch surrounding group context on @-mention (MUL-3084) (#3819)
* feat(lark): prefetch surrounding group context on @-mention (MUL-3084)

In Feishu group chats the Bot only saw the single message that @-mentioned
it — never the surrounding conversation — because the inbound enricher only
inlined context the user explicitly attached (a quoted reply or a
merge_forward), and the API client had no way to list a chat's history.

Add APIClient.ListChatMessages (GET /open-apis/im/v1/messages,
container_id_type=chat, ByCreateTimeDesc, page_size clamped to Lark's 50
cap) and, for a group message addressed to the Bot, prefetch a bounded
window of recent messages and inline them as a <recent_context> block
ahead of the user's own message. The trigger and any quoted parent are
excluded so nothing is duplicated; speakers are labeled positionally
(User 1/2 / Bot); failures degrade to a visible placeholder and never
block ingestion. Window size is configurable via
InboundEnricherConfig.RecentContextSize (<=0 disables); production wires
DefaultRecentContextSize (20). One list call per addressed turn keeps the
fetch within the inbound ACK / EnrichTimeout budget.

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

* feat(lark): anchor group context window to trigger time, default 10

Address review feedback on MUL-3084:
- Anchor the recent-context prefetch to the trigger message's time:
  thread the message create_time through InboundMessage and pass it as
  the list end_time (millis -> seconds), so the window is the
  conversation up to the @-mention rather than whatever is newest when
  the slightly-later prefetch HTTP call runs. end_time is omitted when
  the time is missing/unparseable (falls back to newest N).
- Lower DefaultRecentContextSize from 20 to 10.

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

* docs(lark): clarify recent-context persistence stance and fetch-window semantics

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

* fix(lark): region-aware doJSON for ListChatMessages after rebase

origin/main merged #3815 (Lark dual-region support), which changed
doJSON to take a per-call baseURL resolved via resolveBaseURL(creds).
Adapt the new ListChatMessages call to that signature so the backend
build passes against latest main, and refresh the now-stale
ListMessagesParams comment (EndTime is exposed).

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-05 16:37:49 +08:00
Naiyuan Qing
25104d1855 perf(editor): parse large markdown in chunks to fix O(n²) freeze (#3823)
@tiptap/markdown parses via marked, whose tokenizer is O(n²) in document
length. Opening a large markdown doc (issue description, agent
instructions, …) froze the UI for tens of seconds: a 533KB plain-text doc
took 61.8s to parse while the subsequent ProseMirror setContent was only
40ms. Upgrading marked doesn't help — already on 17.0.5, whose fix only
covers `_`/`*` delimiter runs, not general prose.

Parse large markdown in chunks instead of in one shot: split on blank
lines outside fenced code blocks, parse each chunk independently, then
concatenate the resulting docs. This drops marked's cost to O(n²/k) while
producing a byte-identical document. Applied transparently at
ContentEditor's two parse entry points (mount + WS-driven re-parse), gated
at 50KB so normal small docs stay on the single-parse fast path.

533KB: parse 61.8s -> 0.95s (65x), open 100s -> 3.2s (31x).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:28:41 +08:00
Bohan Jiang
a9a9e93905 fix(core): scope inbox notification mute check to the source workspace (#3821)
Follow-up to #3797. The inbox:new handler keys the notification-preference
query on item.workspace_id, but the request itself still resolved its
workspace from the active-workspace X-Workspace-Slug header. On a cold
cache, a user viewing workspace B who received a workspace-A notification
read B's mute setting and cached it under A's key — so A's banners could
fire while muted (and vice-versa), polluting A's cache.

Add an optional workspaceSlug override to getNotificationPreferences and
notificationPreferenceOptions, and pass the resolved source slug from the
inbox:new handler. When the source slug can't be resolved, read only an
already-warm cache instead of fetching with the wrong workspace. Tests
cover the cold-cache source-slug fetch, source mute suppression, and the
no-fallback guard.

MUL-3062

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 16:05:49 +08:00
Bohan Jiang
6ac8314711 feat(lark): support both Feishu and Lark from one deployment (MUL-3083) (#3815)
* feat(lark): serve Feishu and Lark from one deployment, per installation

The Lark integration was locked to a single open-platform host chosen
deployment-wide (MULTICA_LARK_HTTP_BASE_URL / _CALLBACK_BASE_URL,
defaulting to open.feishu.cn), so one deployment could talk to only the
mainland Feishu cloud OR Lark international — never both. Teams on the
other tenant could not use the integration at all.

Make the host per-installation. The device-flow installer already
auto-detects the tenant (Lark emits tenant_brand="lark" mid-poll); we now
persist that as lark_installation.region, carry it on
InstallationCredentials.Region, and resolve the open-platform host per
call (REST + WS bootstrap) from the region. An explicit cfg.BaseURL
(env / httptest) still overrides every region, so existing tests and
staging/proxy setups keep working.

- migration 116: lark_installation.region TEXT NOT NULL DEFAULT 'feishu'
  CHECK (region IN ('feishu','lark')) — existing rows are all mainland.
- lark.Region enum + OpenPlatformBaseURL/RegionOrDefault helpers.
- registration: thread the detected region into finishSuccess so the
  install-time GetBotInfo hits the right cloud AND the row records it.
- every credential-build site (patcher, replier, WS provider, union_id
  backfill) copies region off the installation row.
- region is part of the WS supervisor fingerprint so a re-install that
  switches cloud restarts the connection.
- API: surface region on the installation listing DTO.

MUL-3083

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

* feat(lark): surface installation region in settings UI

Read the per-installation region off the listings response: build the
"Manage in Lark" dev-console host from it (open.feishu.cn vs
open.larksuite.com instead of a hardcoded mainland host) and render a
Feishu / Lark badge on each connected bot. The field is optional and
defaults to Feishu when an older server omits it (API-compat). Adds the
region_feishu / region_lark labels to all four locales.

MUL-3083

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

* docs(lark): document simultaneous Feishu + Lark support

The cloud each bot belongs to is now auto-detected at install and stored
per installation, so one deployment serves both. Replace the old
"point MULTICA_LARK_HTTP_BASE_URL at larksuite for international tenants"
guidance (now just an optional override) in all four locales.

MUL-3083

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

* fix(lark): repair legacy Lark-international installs on upgrade

Review follow-up (MUL-3083). Migration 116 backfilled every existing
lark_installation to region='feishu', assuming all historical rows were
mainland. But self-host deployments could already run Lark international
via the deployment-wide MULTICA_LARK_HTTP_BASE_URL override, so those
rows are really Lark — clearing the override after upgrade (which the new
docs invite) would route them to open.feishu.cn and break them.

Add a one-shot startup repair, BackfillRegionFromLegacyOverride, fired
off the hot path like BackfillBotUnionIDs: when the deployment's global
base-URL override targets open.larksuite.com, relabel the still-default
'feishu' rows to 'lark'. Gating on the deployment-wide override is what
makes it safe — every pre-existing install on such a deployment was Lark.
Idempotent; no-op on mainland / fresh deployments. Verified end-to-end
against a scratch DB (flip then 0-row idempotent re-run).

Also document that a Lark/飞书 app_id is globally unique across both
clouds, which is what makes the app_id-keyed token cache and the
UNIQUE(app_id) constraint safe across regions (review nit).

MUL-3083

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

* docs(lark): fix ops guidance to match auto per-installation region

Review follow-up (MUL-3083). .env.example and docker-compose.selfhost.yml
still told operators that international Lark requires pointing both base
URLs at open.larksuite.com — now wrong, and it would push a fresh
deployment back into a single-cloud override. Rewrite them: the base
URLs are optional deployment-wide overrides; normal dual-cloud operation
keeps them empty. Document the first-boot auto-relabel for deployments
migrating off the old single-cloud override, across the integration docs
(en/zh/ja/ko).

MUL-3083

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-05 16:03:13 +08:00
Nguyễn Phúc Lương
93b93f58b5 fix(desktop): route inbox notifications to the item's source workspace (#3797)
Resolves the desktop inbox notification slug from the item's own workspace_id, routes the click through the navigation adapter for a real workspace switch, and invalidates the source workspace's inbox cache. Follow-up: mute-preference fetch should also target the source workspace.

Closes #3766
2026-06-05 15:51:58 +08:00
Bohan Jiang
2e50df9a6a perf(analytics): report $pageview at section granularity, drop web query-string churn (#3813)
capturePageview now section-normalizes the path (strip query/hash, collapse
UUID and issue-key resource segments) and dedupes consecutive same-section
views, so navigating between issues/agents/etc. no longer fires a billed
PostHog event per resource. The web tracker keys on pathname only (not
searchParams), removing ~17% pure query-string-churn pageviews and keeping
OAuth code/state out of $current_url.

MUL-3081

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:33:32 +08:00
Multica Eve
905ebbdde1 fix(github): populate connected account name on install [MUL-3078] (#3811)
* fix(github): populate connected account name on install [MUL-3078]

The Settings → GitHub connection card was rendering 'Connected to
unknown' because:

1. fetchInstallationAccount in the setup callback hit GitHub's
   /app/installations/{id} endpoint unauthenticated. That endpoint
   requires App JWT auth; the call returned 401, and the function
   fell through to the 'unknown' placeholder which was persisted as
   account_login.
2. The installation webhook handler did upsert the row with the real
   login when GitHub later delivered installation.created, but it
   never published a github_installation:created event. The frontend
   query stayed stale, so the UI kept showing 'unknown' even after
   the row had been refreshed.

Fix:

- Add optional GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY env vars. When
  set, signGitHubAppJWT mints a short-lived RS256 JWT (back-dated 60s
  for clock skew, capped at 9m to stay inside GitHub's 10m max) and
  fetchInstallationAccount uses it as a Bearer token. The setup
  callback now writes the real org/user name on install.
- When the new env vars are not configured, the call still falls
  through to 'unknown' as before — but the webhook handler now
  publishes EventGitHubInstallationCreated after the upsert, so the
  realtime listener invalidates the installations query and the UI
  converges to the real value within seconds, no manual refresh.

Tests cover JWT signing (claims, signature, malformed PEM, partial
config), fetchInstallationAccount with a JWT-gated httptest mock,
and the webhook refresh + broadcast on a seeded 'unknown' row.

Docs updated for .env.example and github-integration /
environment-variables in en, zh, ja, ko.

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

* test(github): defuse JWT clock-bomb by injecting parser time [MUL-3078]

PR review caught that TestSignGitHubAppJWT_ClaimsAndSignature signed the
token with a fixed 'now' (2026-06-05T12:00:00Z) but parsed it with a
default jwt.Parse, which uses real time.Now() for exp validation. Once
real wall clock crossed the token's exp (now + 9m = 12:09:00Z), the
test would have flipped to a deterministic failure on every CI run.

Inject the same fixed 'now' into the parser via jwt.WithTimeFunc so
both signing and validation share one clock. Verified independently
that without the fix the parser rejects the token as 'expired', and
with the fix it accepts.

Also clarified the fetchInstallationAccount comment to be unambiguous
about what 'do not block' actually means: the HTTP call IS synchronous
(no independent timeout, pre-existing wart), but a failure here just
falls back to the unknown placeholder rather than aborting the
callback.

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-05 15:21:22 +08:00
Bohan Jiang
3708fb0f07 fix(daemon): inactivity-based agent run timeout, no wall-clock guillotine (MUL-3064)
Active long-running sessions are no longer killed by a fixed wall-clock deadline. Liveness is delegated to the idle watchdog (MULTICA_AGENT_IDLE_WATCHDOG, default 30m) with a larger in-flight-tool budget (MULTICA_AGENT_TOOL_WATCHDOG, default 2h). MULTICA_AGENT_TIMEOUT is an opt-in absolute cap (default 0 = no cap). The server-side 2.5h sweeper is unchanged as a coarse backstop.

Fixes #3745.
2026-06-05 15:06:07 +08:00
Bohan Jiang
d6540a1869 fix(clipboard): support copy over http:// via execCommand fallback (#3810)
navigator.clipboard is only exposed in a secure context (https or
localhost). On self-hosted instances served over plain http:// it is
undefined, so every copy / "copy all" / export button silently failed and
left the clipboard empty (GitHub #3781).

Add a shared copyText(text): Promise<boolean> helper in
@multica/ui/lib/clipboard that prefers the async Clipboard API and falls
back to a hidden <textarea> + document.execCommand('copy') for non-secure
contexts. Migrate all direct navigator.clipboard.writeText call sites
(code blocks, agent transcript copy-all, token / webhook / issue-link
copy, etc.) to it, gating success side-effects on the returned boolean,
and remove the now-redundant copyMarkdown wrapper. Secure-context users
keep the native path unchanged.

MUL-3068

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 14:55:23 +08:00
Bohan Jiang
76dbb87762 fix(agent): standardize model-discovery timeouts to 15s, stop caching empty results
Raise pi and cursor model-list discovery timeouts 5s->15s to match opencode/ACP; openclaw stays 30s (sequential multi-spawn). Stop caching empty discovery results so a transient timeout doesn't keep the picker blank for the full TTL. Fixes #3729. MUL-2977.
2026-06-05 14:47:59 +08:00
LinYushen
3913bf9152 docs(self-host): default rollup to in-process scheduler, demote pg_cron/cron/systemd/CronJob to compat paths (#3808)
Rewrite the 'Usage Dashboard Rollup (Required)' section in SELF_HOSTING.md
and apps/docs/content/docs/getting-started/self-hosting.zh.mdx so that:

- The DB-backed in-process scheduler (sys_cron_executions, MUL-2957) is
  documented as the default for fresh self-host installs. The bundled
  pgvector/pgvector:pg17 image works as-is and no operator step is required.
- pg_cron, external cron, systemd timer, and Kubernetes CronJob are demoted
  to a 'Compatibility paths (existing deployments only)' subsection. They
  remain supported (advisory lock 4246 prevents double-writes) but are no
  longer the recommended setup.
- A retirement sequence is added for production environments that already
  have a pg_cron job: confirm in-process SUCCESS rows in sys_cron_executions,
  then cron.unschedule the redundant entry; leave the pg_cron extension
  installed unless other workloads stop depending on it.
- The two upgrade callouts that pointed to the removed
  'Usage Dashboard Rollup -> Option C' anchor are repointed to
  SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup, which already documents
  the auto-hook backfill and the recovery flow.

Refs MUL-3077.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 14:31:14 +08:00
Bohan Jiang
62925b97f1 chore(cli): remove the --from-template flag from agent create (#3805)
* chore(cli): remove the --from-template flag from agent create

The `--from-template` CLI flag was an untaught, immature surface (the
built-in skill's source-map explicitly marked the template path "out of
scope"). It also silently ignored sibling create flags (--custom-env,
--mcp-config, etc.) by short-circuiting before body assembly. Remove the
flag and its runAgentCreateFromTemplate handler from the CLI.

Scope is CLI-only. The agent-template product feature stays intact:
- registry server/internal/agenttmpl/ (embedded curated templates)
- handler server/internal/handler/agent_template.go
- routes GET /api/agent-templates, GET /api/agent-templates/{slug},
  POST /api/agents/from-template
- the onboarding "create from template" flow (packages/views/onboarding)

The onboarding flow calls the API directly and does not depend on the
CLI flag, so removing the flag does not affect it.

Updates the multica-creating-agents source map accordingly.

MUL-3070

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

* fix: correct source-map note on agent-template usage + guard --from-template

Review of #3805 (MUL-3070) flagged a factual error in the source-map note:
it claimed onboarding uses the agent-template backend. It does not.
`packages/views/onboarding/steps/step-agent.tsx` builds four hardcoded
local presets (i18n-resolved) and creates via plain `POST /api/agents`
(`createAgent`), never `POST /api/agents/from-template`. The whole
agent-template stack (registry, handler, routes, `packages/core` client +
query wrappers) is orphaned — the removed CLI flag was its only non-test
caller. Rewrite the note to say so.

Also add a regression test asserting `agent create` exposes no
`--from-template` flag, so it can't be silently re-added.

MUL-3070

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-05 14:21:11 +08:00
Multica Eve
23a69f7682 docs: add db-backed scheduler RFC (#3701)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 14:03:15 +08:00
Multica Eve
631fa015be fix(docs): stabilize localized usage rollup anchor [MUL-2957] 2026-06-05 13:55:00 +08:00
Naiyuan Qing
0dbe9f0a8f Move caret after inserted image uploads (#3796)
* fix editor image upload caret placement

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

* feat(editor): reserve image box via intrinsic dimensions to kill paste layout shift (#3803)

Capture an image's intrinsic width/height on upload and render them as
<img width height> so the browser reserves the box before the image
decodes. Removes the layout shift that pushed the caret out of view after
a pasted-image insert, making the post-insert scrollIntoView correct.

- Add width/height node attrs to ImageExtension (render-only; not
  serialized to markdown, so round-trips stay clean).
- Measure dimensions off-thread via createImageBitmap and patch the node
  after insert. Fire-and-forget so the synchronous-insert contract
  (instant preview) is preserved; degrades to no-box when the API is
  unavailable (jsdom). The src swap keeps width/height via attr spread.
- Thread width/height through ImageView -> Attachment -> <img>.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:48:36 +08:00
LinYushen
3caba86b09 feat(scheduler): DB-backed execution-record scheduler [MUL-2957] 2026-06-05 13:46:26 +08:00
Multica Eve
63b847ee48 Honor agent identity in assignment workflow (#3802)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 13:45:43 +08:00
Bohan Jiang
18a5224fe8 feat(cli): add --mcp-config flags to agent create/update (#3799)
Agents already support an mcp_config field (consumed by the daemon →
provider at task time) and the agent-settings UI exposes an MCP tab, but
the CLI had no way to set it. This adds the missing CLI surface, mirroring
the existing custom-env pattern:

- `agent create` and `agent update` gain --mcp-config / --mcp-config-stdin
  / --mcp-config-file. The stdin/file channels keep MCP server tokens out
  of shell history and 'ps'; the three channels are mutually exclusive.
- The value is validated as a JSON object (or the literal `null` to clear,
  on update), matching the agent-settings MCP tab. Empty stdin/file input
  errors instead of silently clearing a secret-bearing field.
- Unlike custom_env, mcp_config IS settable via `agent update` — it is
  persisted through the generic UpdateAgent endpoint (no dedicated audited
  endpoint), so both create and update expose the flags.

Adds parser/resolver unit tests (incl. secret-leak sanitization) and
updates the multica-creating-agents built-in skill + source map.

MUL-3070

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 13:39:38 +08:00
Bohan Jiang
c9ceaee4d9 fix(agent): stop stripping user-facing CLAUDE_CODE_* config from child env (#3690)
* fix(agent): stop stripping user-facing CLAUDE_CODE_* config from child env

isFilteredChildEnvKey blanket-removed every CLAUDE_CODE_* var from the
spawned Claude Code child's environment. The intent was only to keep the
daemon's internal session markers from leaking, but CLAUDE_CODE_* is also
Anthropic's user-facing config namespace. On Windows this stripped the
user-set CLAUDE_CODE_GIT_BASH_PATH, so Claude Code could not locate
bash.exe, exited immediately, and every task failed with
"write claude input: write |1: The pipe has been ended."

Switch from prefixing the whole CLAUDE_CODE_ namespace to an exact-name
denylist of the internal runtime/session markers (CLAUDECODE,
CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_EXECPATH, CLAUDE_CODE_SESSION_ID,
CLAUDE_CODE_TMPDIR, CLAUDE_CODE_SSE_PORT), still blanket-stripping the
wholly-internal CLAUDECODE_* namespace. Every other CLAUDE_CODE_* var
(GIT_BASH_PATH, USE_BEDROCK, USE_VERTEX, MAX_OUTPUT_TOKENS, ...) now
reaches the child. The internal-marker set was confirmed against the live
runtime, not guessed.

Fixes the whole class, not just git-bash: Bedrock/Vertex/etc. were
silently dropped the same way.

MUL-2940

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

* fix(agent): keep CLAUDE_CODE_TMPDIR in child env

CLAUDE_CODE_TMPDIR is a documented, user-configurable temp-dir override
(public env-vars reference), not an internal per-session marker. Claude
Code creates its own per-session subdir under it, so inheriting it is
harmless — and stripping it would silently break a user's temp-dir
override the same way the broad prefix filter broke CLAUDE_CODE_GIT_BASH_PATH.

Drop it from the internal denylist (which now holds only the undocumented
per-process runtime markers: CLAUDECODE, CLAUDE_CODE_ENTRYPOINT,
CLAUDE_CODE_EXECPATH, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_SSE_PORT) and
assert it reaches the child.

MUL-2940

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-05 12:29:55 +08:00
Naiyuan Qing
4779e24816 fix editor image markdown roundtrip (#3790)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 10:40:58 +08:00
Alex
4da43b383f fix: selfhost env does not accept LARK related env (MUL-3060) (#3771)
* fix: selfhost docker compose env does not accept LARK related env

* fix(selfhost): pass through MULTICA_LARK_CALLBACK_BASE_URL for international Lark

The inbound long-conn callback bootstrap reads MULTICA_LARK_CALLBACK_BASE_URL
(server/cmd/server/router.go buildLarkConnectorFactory ->
HTTPConnectionTokenFetcher), which defaults to open.feishu.cn with no
fallback to MULTICA_LARK_HTTP_BASE_URL. Without it forwarded into the
backend container, international Lark tenants can send (outbound HTTP via
MULTICA_LARK_HTTP_BASE_URL) but never receive messages — the bootstrap
still hits the mainland host.

Forward the var in docker-compose.selfhost.yml and document all three
Lark knobs in .env.example so operators can discover them from the
standard 'cp .env.example .env' onboarding path.

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-04 19:49:50 +08:00
Bohan Jiang
f3ab29cdfc fix(lark): publish lark_installation:created at row-commit, not on status poll (#3770)
The agent Integrations tab's "已连接到飞书" connection badge only updated after
a manual page refresh. lark_installation:created had a single emit site — the
status-poll handler GetLarkInstallStatus — so it only fired while a browser was
actively polling the install dialog to success. Every other surface (a second
admin, the inspector sidebar, the Settings panel, or the installer whose dialog
closed before the success poll) never received the invalidation frame, and under
the QueryClient defaults (staleTime: Infinity) the installations cache stayed
stale until a full page refresh.

Publish the event from RegistrationService.finishSuccess at the row-commit point,
mirroring the already-correct revoke path, so every workspace client refreshes
the moment the install lands. Wire the bus via an optional SetEventBus (keeps the
constructor and its validation tests untouched, nil-safe) and remove the now-
redundant poll-handler emit.

MUL-3059

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 19:29:01 +08:00
Multica Eve
5b69331ad2 docs: add June 4 changelog entry (#3762)
* docs: add June 4 changelog entry

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

* docs: refine June 4 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-04 17:51:48 +08:00
Naiyuan Qing
b0d479c6e7 fix: use mentions for chat context (#3755) 2026-06-04 16:45:10 +08:00
Bohan Jiang
0d38288dbd MUL-2926 feat(editor): support markdown checkbox task lists (#3593) (#3657)
* feat(editor): support markdown checkbox task lists (#3593)

Render `- [ ]` / `- [x]` as interactive checkboxes in the issue content
editor, matching GitHub / Notion.

- Register TaskList + a patched TaskItem in the shared extension factory.
  Both ship their own markdown tokenizer / renderMarkdown, input rules, and
  a checkbox NodeView; the taskList tokenizer is consulted before marked's
  built-in list tokenizer, so `- [ ]` becomes a task while a plain `- ` still
  falls through to the bullet list.
- Patch TaskItem's keymap to share PatchedListItem's split -> lift Enter
  chain (double-Enter on an empty item exits the list); nested: true enables
  sub-tasks and nested round-trips.
- Add a "Task list" entry to the bubble-menu list dropdown (+ i18n for en /
  zh-Hans / ja / ko).
- Style task lists in prose.css for both the editor ([data-type="taskList"])
  and the readonly remark-gfm output (.contains-task-list); completed items
  render muted.

Readonly already rendered task lists via remark-gfm; this brings the editable
view to parity. Adds markdown round-trip and readonly checked-state tests.

MUL-2926

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

* fix(editor): keep readonly nested task lists block-laid-out (#3593)

The shared `display: flex` rule on task-list items broke nested task lists in
the readonly view. remark-gfm renders a task item as
`<li><input> text <ul>…</ul></li>` — no body wrapper — so a nested list is a
direct sibling of the checkbox and text, and flex pulled it onto the same row.
The editor's Tiptap NodeView wraps the body in a `<div>`, so it was unaffected.

Split the task-list CSS into separate editor and readonly blocks: the editor
keeps the flex row; readonly stays a block list item with an inline checkbox so
a nested `<ul>` drops below and indents under its parent. Adds a readonly test
that pins the nested DOM shape (nested `<ul>` inside the parent `<li>`), so a
future remark-gfm change that wraps the body fails loudly.

MUL-2926

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

* feat(editor): convert `- [ ] ` typing into a task list (#3593)

TaskItem's built-in input rule only converts `[ ] ` / `[x] ` typed at the start
of a plain paragraph. When the user types the GitHub-style `- [ ] ` the leading
`- ` first turns the line into a bullet, and the built-in rule no longer fires —
so `[ ]` stayed as literal text and nothing became a checkbox.

Add an input rule on PatchedTaskItem that catches the checkbox token when it is
the entire content of a freshly-typed list item (bullet or ordered) and converts
just that item into a task item (deleteRange → liftListItem → toggleList). The
anchored regex means it only fires on an item whose whole content is `[ ] ` /
`[x] `, so sibling items in the same list are left untouched.

Adds typing-level tests (real input-rule simulation) covering `[ ] `, `[x] `,
`- [ ] `, `- [x] `, the mixed-list split case, and the plain-bullet no-op.

MUL-2926

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-04 16:36:46 +08:00
LinYushen
5e1a6c4853 fix(cli): degrade 'issue metadata list' to {} on /metadata 404 (#3757)
Self-hosted backends without the per-issue metadata route (older builds,
unapplied 105_issue_metadata migration, or proxy/ingress misroutes) reply
404 to GET /api/issues/:id/metadata. The agent runtime bootstrap calls
'multica issue metadata list <issue> --output json' best-effort, but a
non-zero exit was being escalated by Hermes into a failed agent run even
when the rest of the work succeeded.

This makes only the 'list' verb best-effort: a 404 from /metadata now
prints {} (or an empty table) and exits 0. Other status codes (401, 500,
etc.) keep real error semantics, and 'metadata get / set / delete' are
unaffected — those represent explicit caller intent.

To support the status-code check without changing the user-facing error
string, GetJSON now returns *cli.HTTPError on HTTP failures (the format
'GET <path> returned <code>: <body>' is preserved by HTTPError.Error()).

Refs GitHub issue #3711.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 16:13:27 +08:00
Bohan Jiang
d98fc85088 feat(agents): Integrations tab with Lark Bot bind entry + Lark Bot docs (MUL-2988) (#3751)
* feat(agents): add Integrations tab with Lark Bot bind entry

The agent detail page now has an Integrations tab alongside the inspector's
Integrations section. It reuses the shared LarkAgentBindButton so the
scan-to-bind / already-connected logic stays single-sourced, and adds the
not-configured / coming-soon / members-only states the sidebar has no room
for. The tab only appears once the deployment has Lark configured.

MUL-2988

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

* docs: add Lark Bot integration guide

Covers binding a Multica agent to a Lark Bot (scan-to-install), using it
(DM / @-mention / /issue), management, permissions, and self-host setup.
Added in all four locales under the Integrations nav section.

MUL-2988

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

* fix(agents): show bound Lark state when install_supported is false

install_supported governs only whether NEW scan-installs can complete;
already-installed bots stay manageable when the transport is unwired
(server/internal/handler/lark.go). LarkAgentBindButton checked the
install_supported gate before the existing-installation check, so a bound
agent on such a deployment showed 'coming soon' / nothing instead of
'Connected + Manage in Lark'. Reorder the guard (existing active install →
badge, before the install_supported gate) and mirror it in the new
Integrations tab. Adds regression tests for both surfaces.

MUL-2988

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-04 15:48:22 +08:00
Naiyuan Qing
569b43136c fix(editor): download attachments without blank web tab (#3752)
* fix(editor): download attachments without blank web tab

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

* fix(attachments): preserve workspace in web download URLs

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 15:45:42 +08:00
Naiyuan Qing
7fdec9e6e4 Teach default PR handoff in issue skill (#3753)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 15:27:00 +08:00
Bohan Jiang
c99c2493ae fix(agent): keep resolvable models when CLI discovery exits non-zero
Parse the discovered catalog even when the model-discovery CLI exits non-zero (pi/opencode/cursor/openclaw) instead of discarding it and returning an empty model picker. Filter pi diagnostic lines so stale-pattern warnings don't coin bogus models. Fixes #3729. MUL-2977.
2026-06-04 14:57:30 +08:00
Bohan Jiang
82e9ca401c docs(i18n): backfill SMTP relay TLS docs for ja/ko parity (#3750)
The earlier SMTP_TLS / port-465 work only updated the English (and zh)
docs, leaving the ja and ko translations stale. This brings them to
parity for the SMTP relay section:

- environment-variables.{ja,ko}: correct the SMTP_PORT row (465 is
  supported, not "unsupported") and add the missing SMTP_TLS row.
- self-host-quickstart.{ja,ko}: fix the stale "465 unsupported" intro
  line and add the port-465 implicit-TLS example.
- auth-setup.{ja,ko}: fix the implicit-TLS row in the relay-modes table,
  add the port-465 example, and align the startup-log line.

Docs-only; code blocks kept identical to English. No SMTP_EHLO_NAME
changes (already synced in #3749).

MUL-2984

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 14:53:48 +08:00
Multica Eve
ae27058b0a fix(attachments): unified download endpoint with mode + presign + proxy (MUL-2976) (#3747)
Fix attachment download for self-hosted deployments using private S3-compatible buckets without CloudFront. Closes #3721.

**Server**

- New unified `GET /api/attachments/{id}/download` endpoint that picks CloudFront / S3 presign / server proxy at request time.
- `ATTACHMENT_DOWNLOAD_MODE=auto|cloudfront|presign|proxy` and `ATTACHMENT_DOWNLOAD_URL_TTL` env knobs; `auto` routes Docker hostnames / localhost / private IPs through the proxy and public S3 endpoints through presign.
- `Storage.PresignGet` capability; S3 implementation generates presigned GET URLs.
- `attachmentToResponse` returns the unified relative endpoint instead of leaking raw unsigned S3 URLs when CloudFront is not configured. Proxy path streams via `io.Copy` with `Content-Disposition` / `Content-Length` / `Cache-Control: no-store` / `X-Content-Type-Options: nosniff`.

**Clients**

- CLI / Desktop / Mobile resolve relative `download_url` values against the configured API base. Desktop covers the Electron native download bridge and the media preview modal; Mobile covers `Linking.openURL`, the markdown image RN loader, and the composer's completed non-image file chip.
- Mobile gains a minimal Node-environment vitest lane wired into `mobile-verify.yml`.

**Docs**

- `.env.example`, `docker-compose.selfhost.yml`, `SELF_HOSTING_ADVANCED.md`, and the `environment-variables` doc set updated with the new env keys and the `ATTACHMENT_DOWNLOAD_MODE=proxy` recommendation for Docker / VPC-internal object stores.

**Tests**

- `internal/storage`, `internal/cli`, `internal/handler` (download endpoint, mode selection, proxy header, `/content` non-regression), `cmd/server` (trusted proxy parser).
- `packages/views/editor/use-download-attachment.test.tsx` and `attachment-preview-modal.test.tsx` exercise relative URL resolution + absolute pass-through.
- `apps/mobile/lib/attachment-url.test.ts` covers every helper branch plus the composer non-image chip case.
2026-06-04 14:52:57 +08:00
Bohan Jiang
8db619c1cd fix(email): wire SMTP_EHLO_NAME through self-host config + docs [MUL-2984] (#3749)
* fix(email): wire SMTP_EHLO_NAME through self-host config + docs

Follow-up to #3679, which added SMTP_EHLO_NAME in code but never exposed
it to operators.

- docker-compose.selfhost.yml: pass SMTP_EHLO_NAME through to the backend
  container. The compose env block is an explicit allowlist, so without
  this the override set in .env was silently dropped and never reached
  the process — making the escape hatch unusable on the docker path.
- Document the var alongside its SMTP_* siblings: .env.example,
  SELF_HOSTING_ADVANCED.md, environment-variables.mdx, auth-setup.mdx,
  and self-host-quickstart.mdx (the last two with a strict-relay example).
- email.go: log when os.Hostname() fails instead of silently falling back
  to net/smtp's lazy "localhost" — the exact greeting strict relays reject.
- Add TestNewEmailService_EHLOName covering the env override, trimming,
  and the hostname fallback.

MUL-2984

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

* fix(email): gate EHLO resolution to SMTP mode + sync docs to zh/ja/ko

Addresses review nits on this PR:

- email.go: resolve smtpEHLOName only when SMTP_HOST is set, so the
  Resend / DEV-stdout paths never call os.Hostname() or emit its
  failure log. The EHLO name is only ever used on the SMTP send path.
- docs: add SMTP_EHLO_NAME to the zh/ja/ko variants of
  environment-variables, self-host-quickstart, and auth-setup, in sync
  with the English docs updated earlier in this PR.

Note: the ja/ko self-host-quickstart and auth-setup pages were already
missing the port-465 implicit-TLS example (pre-existing i18n drift from
an earlier SMTP_TLS change, unrelated to this PR); the new EHLO block is
inserted at the correct logical anchor regardless. A full ja/ko re-sync
is left as a separate follow-up.

MUL-2984

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-04 14:44:55 +08:00
Arnaud Dezandee
a9f0739b52 fix(email): set EHLO hostname for SMTP relay compatibility (#3679) 2026-06-04 14:07:47 +08:00
xiaoyue26
49c913a4fa fix(sidebar): hide stale pinned items immediately on workspace switch (MUL-2985) (#3564)
* fix(sidebar): hide stale pinned items immediately on workspace switch

When the user switches workspaces, previously pinned items from the old
workspace were briefly visible until the new workspace data loaded. Reset
the pinned list to an empty array on workspace-id change so the stale
items disappear instantly, eliminating the flicker.

* fix: separate pinnedItems and wsId effects to prevent drag-sort loss

When wsId changes, the combined effect would trigger even if pinnedItems
hadn't changed yet (still old workspace data), overwriting localPinned
and losing any pending drag-sort results.

Split into two independent effects:
- pinnedItems effect: updates localPinned when data changes (respects isDragging)
- wsId effect: updates localPinnedWsId immediately on workspace switch

This ensures workspace switches don't interfere with drag operations.
2026-06-04 14:06:09 +08:00
Naiyuan Qing
b9334dd59f fix: anchor comment triggers to thread roots (#3746)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 13:47:05 +08:00
Bohan Jiang
9beb45b55b fix(chat): deliver every debounced user message to the agent (MUL-2968) (#3744)
The Lark short-window debounce (MUL-2968, #3742) can land several user
messages in a chat session before a single agent run fires. But the
daemon claim built the agent prompt from only the *single most recent*
user message (walk history backward, take first user message, break).

So 「看上海天气」then「还有青岛」debounced into one run, and the agent
received only 「还有青岛」— it answered Qingdao and never saw Shanghai.
The session itself was correct (both messages persisted); the gap was in
what the run delivered to the agent. Before debouncing this was masked
because each message got its own run.

Build the prompt from the whole unanswered set instead: the trailing run
of user messages after the last assistant reply (every completed/failed
run writes an assistant row, so the anchor advances one turn at a time —
the full burst on the first turn, only the new message(s) after a reply).
Attachments are collected from each included message. Extracted the
selection into a pure trailingUserMessages helper with table-driven unit
tests, plus a DB-backed claim test asserting both messages reach the
agent and that a post-reply message delivers alone.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 13:12:32 +08:00
Bohan Jiang
a1a33f91db fix(desktop): surface expired login instead of silently stuck "Starting" daemon (MUL-2973) (#3743)
* fix(desktop): surface expired login instead of silent "Starting" daemon (MUL-2973)

When the local daemon's cached PAT is expired/revoked, the daemon 401s during
startup and exits before it serves /health. The desktop polled /health forever
and kept reporting "starting", so the runtime sat at "Starting…" with no hint
that re-login was the fix (GitHub #3512).

Detect this in the layer that owns the daemon's credential: when a start fails
to reach "running", probe the token against GET /api/me. A 401 (or missing
token) surfaces a new "auth_expired" daemon state; a 2xx means the token is
fine (non-auth failure) and a network error stays inconclusive — so a network
blip is never misclassified as expired login.

The desktop then shows a "Sign-in expired · Sign in again" prompt on the
runtimes card and a banner in Daemon settings. The action drops the stale
cached PAT, re-mints a fresh one from the current session, and restarts the
daemon; if minting also 401s (the session token is dead) it falls back to the
standard re-login flow. No daemon/CLI behavior change.

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

* fix(desktop): only force re-login on a real 401 during daemon reconnect (MUL-2973)

Review feedback: the reconnect helper treated any failure from clearToken /
syncToken / restart as "session is dead" and logged the user out. A transient
failure (mint 5xx, network blip, config write error, restart hiccup) would
wrongly sign them out.

Move the failure classification into the main process, where the real HTTP
status is available: mintPat now tags its error with the response status, and a
new daemon:reauthenticate handler returns a structured ReauthResult — `ok`,
`session_invalid` (a genuine 401 → the session token itself is dead), or
`transient`. The renderer only calls logout() on `session_invalid`; transient
failures keep the user signed in and show a retryable toast. An unexpected IPC
error is also treated as transient, never as logout.

Add tests locking the classifier (401 → auth, 5xx/network/IO → not auth) and the
renderer behavior (transient failure and IPC throw do NOT log out).

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-04 13:08:12 +08:00
Bohan Jiang
6e004149a8 feat(lark): debounce inbound run trigger per chat session (MUL-2968) (#3742)
A forwarded transcript plus a follow-up note arrive as two separate Lark
messages, each of which synchronously called EnqueueChatTask — so the bot
ran twice (once on the bare forward, before the note arrived). The chat
task already reads the whole session history at run time, so the messages
never needed stitching; only the run TRIGGER did.

Introduce pendingBatcher: a per-chat_session debouncer that collapses a
burst into one agent run on a 3s silence window. Each message is still
appended, deduped, and ACKed synchronously and individually; step 8 of the
dispatcher now schedules a debounced flush instead of enqueuing inline.

Because EnqueueChatTask's agent-offline / agent-archived verdict is now
only known at flush, the dispatcher emits that notice itself via an
injected FlushReply (wired to OutcomeReplier.Reply) rather than returning
it synchronously to the hub. Infra failures are logged, not surfaced — the
inbound frame was ACKed long ago. The hub drains the batcher on graceful
shutdown so a normal restart does not drop a pending window.

Out of scope (owner-aligned): group-chat multi-speaker batching, restart
recovery for the in-process window, and forwarded-sender real-name
resolution.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 12:48:02 +08:00
Bohan Jiang
5eba94ee25 feat(lark): inbound context enrichment — post / merge_forward / quoted-reply (MUL-2951) (#3724)
Expand an inbound Lark bot message's body before dispatch with the context
a user explicitly attached, so the agent sees a semantically complete
conversation instead of a bare "@bot 总结一下".

- post: flatten rich-text (title + paragraphs, links, @-mentions) to plain
  text synchronously in the decoder.
- merge_forward: inline the forwarded transcript via a single GetMessage —
  GET /open-apis/im/v1/messages/{id} returns the forward sentinel plus the
  bundled children. (The issue's container_id_type=merge_forward query is
  undocumented; this avoids it and also handles a forwarded quoted parent.)
- quoted reply: prepend the parent_id message as a <quoted_message> block;
  a parent that is itself a forward nests a <forwarded_messages> block.
- new InboundEnricher runs in the WS connector between decode and emit,
  bounded by EnrichTimeout and degrading to "[unable to fetch]" placeholders
  so it never blocks the ~3s long-conn ACK budget.

/issue stays parseable on a quote-reply by parsing the command from the
user's own text (CommandBody) rather than the enriched body.

Short-window debounce batching (issue item #4) is tracked as a follow-up.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 11:58:16 +08:00
Multica Eve
945d684afd MUL-2965 fix(metrics): harden business sampler quality (#3738)
* fix(metrics): harden business sampler quality (MUL-2965)

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

* fix(metrics): alert on sampler acquire failures

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-04 11:21:51 +08:00
Naiyuan Qing
26971e7e45 fix(views): left-align picker item rows (#3736)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 10:43:57 +08:00
Naiyuan Qing
2840ebb308 feat(chat): add explicit context picker (#3735)
* feat(chat): add explicit context picker

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

* fix(chat): address context picker review

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 10:22:31 +08:00
Naiyuan Qing
aaefa53ea7 feat(chat): add searchable agent picker (#3709)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 09:30:18 +08:00
Bohan Jiang
b92c3fbc93 chore(analytics): stop shipping operational events to PostHog (MUL-2967) (#3720)
* chore(analytics): stop shipping operational events to PostHog (MUL-2967)

Operational / execution-lifecycle telemetry dominated PostHog event volume
and drove the bill: runtime_offline alone was ~54% of ~22.6M events/mo, and
~99% of events were billed at the higher identified-event rate. These signals
already have Prometheus counters (Grafana), so the PostHog copies were
redundant cost.

- Add analytics.IsMetricsOnly; metrics.RecordEvent now skips the PostHog
  Capture for runtime_* and autopilot_run_* while still incrementing their
  Prometheus counter (their analytics.Event constructors are retained to feed
  the metric label set via IncForEvent).
- Remove the agent_task_* PostHog path entirely: drop captureTaskEvent and the
  AgentTask* constructors/constants. Their Prometheus side is unchanged via the
  typed BusinessMetrics.RecordTask* methods. Also remove the now-dead
  taskDurationMS / willRetryTask helpers.
- Update the pairing lint test (no agent_task allow-list, no naked-Capture
  exception), add a RecordEvent skip test + IsMetricsOnly test, and update
  docs/analytics.md (taxonomy, per-event banners, reconciliation).

Product/funnel events (signup, onboarding, issue_created, issue_executed,
chat_message_sent, agent_created, autopilot_created, etc.) are unchanged and
still ship to PostHog.

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

* docs(analytics): correct agent_task Prometheus metric contract (MUL-2967)

Address PR review: the agent_task_* "Prometheus-only" banner claimed the old
PostHog event properties (task_id, agent_id, duration_ms, error_type,
will_retry, ...) were the metric label set. They are not — the real labels are
only source/runtime_mode/provider/terminal_status/failure_reason.

- Replace the agent_task_* sections with the actual metric names and labels
  (multica_agent_task_*; see business.go / labels.go), and explain that
  completed/failed/cancelled are terminal_status values on
  multica_agent_task_terminal_total, with wall-clock in the *_seconds
  histograms.
- Tighten the runtime_*/autopilot_run_* banners so id properties aren't
  mistaken for labels.
- Drop the stale AgentTask allow-list reference from the pairing lint test
  header comment.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 00:48:17 +08:00
Bohan Jiang
6330286c7e fix(lark): use named import for react-qr-code to survive electron-vite interop (#3718)
* fix(lark): use named import for react-qr-code to survive electron-vite interop

Clicking Bind on the agent detail page white-screened the desktop app at
the QR step:

  Element type is invalid: expected a string or a class/function but got:
  object. Check the render method of `LarkInstallDialog`.

react-qr-code is a CJS package. `import QRCode from "react-qr-code"`
relies on the bundler's __esModule default-interop to unwrap `.default`.
Next.js (web) unwraps it correctly; electron-vite's dep-optimizer handed
back the whole module namespace object `{ default, QRCode, __esModule }`
instead of the component, so React got an object where it expected a
component the moment <QRCode> mounted — desktop-only white screen, web
unaffected.

Switch to the named import `{ QRCode }`, which maps straight to
`exports.QRCode` and doesn't depend on the flaky default-interop path.
Resolves correctly under both bundlers; the package's own .d.ts exports
both the named class and the default, so it typechecks unchanged.

Not a backend / Lark-config issue — purely a frontend CJS interop bug.

* test(lark): expose named QRCode export in react-qr-code mock

Follow-up to the named-import switch in lark-tab. The test stubbed
react-qr-code with only a `default` export; now that the component
imports `{ QRCode }`, the named binding resolved to undefined and the
3 QR-rendering tests failed with "No QRCode export is defined on the
react-qr-code mock". Return the stub as both `QRCode` and `default`,
defined inside the factory (vi.mock is hoisted above top-level vars).
2026-06-03 20:17:21 +08:00
Bohan Jiang
598a6c51f2 refactor(server/lark): collapse HTTP_ENABLED + WS_ENABLED into the SECRET_KEY gate (MUL-2671) (#3717)
MULTICA_LARK_HTTP_ENABLED and MULTICA_LARK_WS_ENABLED were staging
knobs from the multi-PR rollout of the Lark MVP — they let the DB
schema + inbound dispatcher land before the HTTP wire was real, and
before the WS long-conn protocol was wired. Now that the MVP has
shipped end-to-end, "I set SECRET_KEY but I don't want to talk to
Lark" is not a useful production state: setting the at-rest master
key is the operator's opt-in for the integration as a whole.

Collapse the gate down to MULTICA_LARK_SECRET_KEY alone. When the
key is present, wire the real HTTPAPIClient + the real
WSLongConnConnector. CI / integration tests that want stub-style
behaviour can point MULTICA_LARK_HTTP_BASE_URL at a mock server
(already supported) instead of toggling a separate flag. Host
overrides (HTTP_BASE_URL, REGISTRATION_DOMAIN, CALLBACK_BASE_URL)
stay — those are real ops needs for international tenants / staging.

stubAPIClient + NoopConnectorFactory remain exported because the
test suite uses them directly; only the router boot path stops
reaching for them. The connector factory keeps its noop fallback
for the case where the endpoint fetcher fails to construct, so a
malformed MULTICA_LARK_CALLBACK_BASE_URL degrades gracefully
(visible as "connector=noop" in the boot log) instead of panicking
the server.

Lark integration + handler tests still pass; go vet clean.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 19:36:49 +08:00
Bohan Jiang
d6a556bdbf fix(execenv): refresh skills in place on reuse instead of accumulating duplicate dirs (#3716)
Re-dispatching the same agent on the same issue reuses the persistent workdir
via execenv.Reuse(), where the standard-provider skill refresh re-wrote skills
without clearing the prior dispatch's output, so allocateCollisionFreeSkillDir
dodged Multica's own directories into issue-review-multica-N.

On reuse, reclaim the platform-owned managed skill directories the prior
manifest recorded (removeReusedManagedSkillDirs) and roll back the remaining
sidecar files (CleanupSidecars) before refreshing, so each skill lands at its
canonical slug every dispatch. Mirrors the Codex hydrateCodexSkills wipe;
scoped to reuse, which never runs for local_directory tasks.

Fixes #3684 (MUL-2963).
2026-06-03 19:30:42 +08:00
Bohan Jiang
8c98940b79 Lark Bot integration MVP: migration + service boundary (MUL-2671) (#3277)
* feat(db): add Lark integration migration (MUL-2671)

Introduces seven tables for the 飞书 Bot integration MVP — per-agent
PersonalAgent installations, user/chat bindings, inbound dedup +
non-content drop audit, outbound card mapping, and short-lived
single-use member binding tokens.

Schema notes:
- chat_session schema unchanged; Lark routes through a separate
  binding table rather than adding a metadata JSONB column.
- Outbound card mapping is task/message scoped so multiple runs on
  the same session can't stomp each other's cards.
- lark_inbound_audit stores routing / identity / drop_reason ONLY,
  never message body — the audit channel for unbound users and group
  messages that don't address the Bot.
- app_secret stores ciphertext (encryption helper lands in a follow-up
  commit on this branch); DB never sees plaintext.

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

* feat(util): add secretbox AES-256-GCM helper for at-rest secrets

First consumer is lark_installation.app_secret (MUL-2671 §4.4), but
the helper is intentionally generic — future per-tenant secrets that
must not appear in a DB dump can reuse it.

Construction: AES-256-GCM with a per-message random nonce, providing
authenticated encryption. Tampered ciphertext fails Open instead of
silently decrypting to garbage. Master key loaded from a base64 env
var via LoadKey; key rotation is not in scope yet.

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

* refactor(issues): extract IssueService.Create as single create entry (MUL-2671)

Establishes the service-layer boundary mandated by Elon's 二审 of
MUL-2671 §4.8: issue creation no longer lives inside the HTTP
handler. Both the HTTP POST /issues handler and the future Lark
/issue command call into service.IssueService.Create, so duplicate
guard, issue numbering, attachment linking, broadcast, analytics,
and agent/squad enqueue stay aligned.

Handler responsibilities shrink to parsing the HTTP request, doing
actor resolution / validation (transport-specific), and converting
service results into the IssueResponse + 201. The transaction-wrapped
core, attachment link, event publish, analytics capture, and
agent/squad enqueue all move into service.IssueService.Create.

A BroadcastPayload callback on the service keeps the WS broadcast
shape (the full IssueResponse) without forcing the service to
depend on handler-layer response types.

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

* feat(integrations): add Lark package skeleton (MUL-2671)

Establishes the architectural boundaries Elon's 二审 mandated as
first-PR blockers without dragging in OAuth, WebSocket, or
card-patching code (those land in follow-up PRs):

- ChatSessionService interface — channel-aware chat-session entry
  point for Lark, deliberately separate from the HTTP SendChatMessage
  handler. The HTTP handler's single-creator guard (creator_id ==
  request user_id) is correct for the browser client but rejects
  group chat_sessions by construction; Lark needs its own service.
- AuditLogger interface — the only path for recording dropped events.
  Its signature deliberately omits message body, enforcing the
  drop-audit policy (MUL-2671 §4.7) at the type level: unbound users
  and non-addressed group messages can't accidentally end up in
  chat_session.
- Typed IDs (OpenID, ChatID) prevent UUIDs from being conflated with
  Lark-side identifiers at compile time.
- DropReason constants align dashboard/audit queries across callers.

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

* refactor(issues): move parent/project workspace check into IssueService (MUL-2671)

Parent existence and project workspace membership now live inside
IssueService.Create, inside the same transaction as the duplicate guard
and counter increment. The HTTP handler stops re-implementing the
lookup; every future create entry (Lark /issue, MCP, API keys) inherits
the same boundary without copy-pasting the SQL.

Adds two error sentinels (ErrParentIssueNotFound, ErrProjectNotFound)
so transports can translate to their own error shapes. Handler-level
cross-workspace tests guard the boundary against future regressions.

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

* fix(db): harden Lark migration safety底座 — TTL cap + workspace FK (MUL-2671)

Two storage-layer hardenings that move the must-fix line off "the app
layer enforces it" and onto the schema itself, so future write paths or
hand-inserted rows cannot regress the invariants.

1) lark_binding_token TTL cap. The DB CHECK was 1 hour as
   defense-in-depth while the app constant was 15 minutes; the CHECK
   now matches the product cap (15 minutes). Application constant
   docstring updated to reflect that storage enforces the same bound.

2) lark_user_binding workspace membership. The table previously only
   FK'd to workspace / user / installation independently, so a binding
   could exist for a user no longer in the workspace, or claim a
   workspace different from its installation's. Two composite FKs
   close the gap structurally:

   * (installation_id, workspace_id) → lark_installation(id, workspace_id)
     — guarantees a binding's workspace_id always matches its
     installation's workspace_id. A new UNIQUE (id, workspace_id) on
     lark_installation is added as the FK target.

   * (workspace_id, multica_user_id) → member(workspace_id, user_id)
     with ON DELETE CASCADE — when a user is removed from the
     workspace, the binding cascades away in the same transaction.
     There is no longer a path where lark_user_binding outlives
     workspace membership.

These two FKs are the schema-level proof for §4.3's "unbound or
non-workspace members cannot leak content into chat_session" invariant.

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

* feat(integrations/lark): inbound services + /issue dispatcher (MUL-2671)

Lands the inbound service layer for the Lark Bot MVP, sitting on top
of the migration + service-boundary scaffold from the previous commits.
What ships:

- sqlc queries for all seven lark_* tables (idempotent dedup insert,
  CAS WS-lease, single-use binding-token consume, etc.) plus
  GetMostRecentUserChatMessage for the /issue fallback.
- AuditLogger backed by lark_inbound_audit; signature deliberately
  body-free so callers cannot leak content into the drop log.
- ChatSessionService: find-or-create chat_session via the binding
  table (winner-takes-all on the UNIQUE race), append-with-dedup, /issue
  parser, "previous user message" fallback for bare `/issue` invocation.
- Dispatcher orchestrates the inbound pipeline in one place:
  installation routing → group-mention filter → identity check → ensure
  session → append+dedup → /issue → enqueue chat task. Group sessions
  use the installer as creator (stable workspace identity); p2p uses
  the sender. Agent-offline path falls through with OutcomeAgentOffline
  so the WS adapter can reply with the offline notice from §4.6.
- BindingTokenService: random URL-safe token, SHA-256 stored hash,
  15-min TTL pinned at the application AND the DB CHECK; Redeem
  returns the same opaque error for all rejection cases (no timing
  oracle on replay).
- Unit tests for the parser (13 cases), dispatcher (8 cases via fake
  Queries/Chat/Audit/IssueCreator/Enqueuer), and binding-token
  hash/entropy. Real-DB integration tests for OAuth + token redeem
  land alongside the HTTP handlers in the next commit.

Out of scope for this commit (next ones on the same feature branch):
OAuth callback, HTTP routes, WebSocket hub, outbound card patcher,
frontend.

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

* feat(integrations/lark): installation HTTP surface + secretbox-gated wiring (MUL-2671)

Lands the HTTP boundary on top of the inbound services from the
previous commit. What ships:

- InstallationService.Upsert: the only path that writes
  lark_installation. Encrypts app_secret with the secretbox passed in
  at construction time; refuses to fall back to plaintext storage
  (returns an error from the constructor if no Box is supplied), so a
  misconfigured dev environment cannot accidentally land a row with
  cleartext credentials. Revoke flips status without DELETE so audit
  trail survives.

- HTTP handlers under /api/workspaces/{id}/lark/:
  * GET  /installations           — member-visible (Integrations tab
                                    renders for non-admins). Soft 200
                                    with empty list + configured:false
                                    when MULTICA_LARK_SECRET_KEY is
                                    unset, so the tab does not error
                                    on self-host that has not opted in.
  * POST /installations           — admin-only; 503 when not configured.
                                    Re-validates agent_id ∈ workspace
                                    before accepting credentials so a
                                    cross-workspace agent UUID is
                                    rejected.
  * DELETE /installations/{id}    — admin-only; workspace-scoped lookup
                                    so one workspace cannot revoke
                                    another's installation by UUID
                                    guess.

- POST /api/lark/binding/redeem (user-scoped, no workspace context):
  the only path that mints a lark_user_binding row from user action.
  Redeemer identity comes from the session, not the token, so a stolen
  link cannot bind an open_id to an attacker's Multica user. The
  composite FK on lark_user_binding cascades the binding away if the
  user is not (or no longer) a workspace member, so a non-member who
  steals the link gets 403 at the DB layer.

- Two new event-bus types in protocol.events:
  EventLarkInstallationCreated, EventLarkInstallationRevoked.

- Router wiring: MULTICA_LARK_SECRET_KEY drives a conditional
  initialization of h.LarkInstallations + h.LarkBindingTokens. When
  unset, the integration disables itself with an INFO log and the
  rest of the server boots normally.

- Handler tests cover all four not-configured short-circuits.
  Happy-path integration tests (real DB, full create→list→revoke
  cycle and token mint→redeem) ship alongside the WS hub PR.

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

* fix(integrations/lark): close binding-token rebind & typed task errors (MUL-2671)

Two must-fixes from PR review on HEAD 87ad15e1:

1. Binding-token redeem could be used to grab an already-bound Lark
   open_id. Two changes harden the path:
   - lark.sql `CreateLarkUserBinding` now gates ON CONFLICT DO UPDATE
     on `multica_user_id = EXCLUDED.multica_user_id`, so a cross-user
     rebind via a second valid token returns zero rows instead of
     silently switching ownership.
   - `BindingTokenService.RedeemAndBind` consumes the token and writes
     the binding row inside one transaction. A failed bind no longer
     burns the token; a successful bind never leaves a consumed-but-
     unused token. Distinct typed errors: ErrBindingTokenInvalid (410),
     ErrBindingAlreadyAssigned (409), ErrBindingNotWorkspaceMember
     (403). The handler maps each to its own status code.

2. Dispatcher collapsed every `EnqueueChatTask` error to
   `OutcomeAgentOffline`, hiding infra failure and misusing the
   "offline" label for cases (e.g. archived agent) where it doesn't
   fit. Now:
   - `service.EnqueueChatTask` returns `ErrChatTaskAgentNoRuntime` and
     `ErrChatTaskAgentArchived` as sentinel errors; DB / load / insert
     failures stay wrapped as ordinary errors.
   - Dispatcher uses `errors.Is` to map only the productizable cases
     (`OutcomeAgentOffline`, new `OutcomeAgentArchived`); any other
     error is returned to the WS adapter so it can retry or page
     instead of disguising the outage as an offline card.

A daemon that's merely disconnected is still NOT an error — as long
as `agent.runtime_id` is set the chat task enqueues and waits for the
daemon to claim it on next online (returns `OutcomeIngested`).

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

* ci: re-trigger workflow on lark MVP must-fix HEAD

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

* ci: re-trigger workflow on lark MVP must-fix HEAD (retry)

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

* test(integrations/lark): guard binding-token sentinel contract (MUL-2671)

Two unit tests that document and protect the must-fix invariants
without requiring a DB:

1. TestRedeemAndBindRequiresTxStarter — if a future refactor wires
   up BindingTokenService without a TxStarter, RedeemAndBind must
   fail fast with a clear error rather than nil-panic on Begin.
   The atomicity contract (consume + bind commit together) depends
   on that transaction existing.

2. TestBindingErrorSentinelsAreDistinct — the HTTP handler maps
   ErrBindingTokenInvalid → 410, ErrBindingAlreadyAssigned → 409,
   ErrBindingNotWorkspaceMember → 403. Accidentally aliasing them
   (e.g. var ErrBindingAlreadyAssigned = ErrBindingTokenInvalid)
   would silently regress the response codes without any other
   test catching it.

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

* feat(integrations/lark): WS hub orchestrator + outbound card patcher (MUL-2671)

The hub owns one supervisor goroutine per active installation. Each
supervisor acquires the WS lease via the existing CAS query, runs an
EventConnector (interface — real Lark wire protocol lands in a follow-up
behind it), renews the lease on a tighter cadence than the TTL, and
backs off (with jitter) on connector failure. Lease loss tears the
connector down cleanly; revocation is reaped on the next sweep. Per-
process node id satisfies §4.4 multi-replica safety: at most one Hub
globally holds the lease for any installation.

The patcher subscribes to task / chat-done events on the existing
events.Bus and keeps the per-task Lark interactive card in sync
(thinking → streaming → final | error). Card binding is per-task as
required by §4.5; throttled patches via an in-memory last-patched map;
final / error transitions bypass the throttle so the user always sees
the terminal state. The Renderer is plug-replaceable so the product
card template can evolve without touching transport.

The APIClient interface centralizes the Lark Open Platform surface
this package needs (send card, patch card, send binding prompt,
exchange OAuth code). The default stubAPIClient returns
ErrAPIClientNotConfigured for every transport call so a misconfigured
deployment fails loudly instead of dropping cards silently. Real
implementation lands in a follow-up; OAuth callback + frontend entries
land in the next commits on this branch.

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

* feat(integrations/lark): OAuth install start / callback (MUL-2671)

OAuthService builds a signed-state Lark authorization URL the frontend
can render as a QR (or open directly), then on callback verifies the
HMAC-protected state, exchanges the OAuth code for installation
credentials via APIClient.ExchangeOAuthCode, and persists the row via
InstallationService.Upsert (which keeps app_secret encryption inside a
single chokepoint).

State token format: workspaceID.agentID.initiatorID.expiresUnix.nonce.sig
— HMAC-SHA256 over the first five fields with a deployment-level
secret. TTL defaults to 10 minutes (covered by tests). Three failure
modes (invalid state / expired state / missing code) map to typed
errors so the HTTP handler can emit a single lark_error= query param
the frontend uses to pick copy.

Both endpoints degrade cleanly: the at-rest key gate (already in place)
returns 503 from /install/start when the InstallationService is nil,
and the OAuth gate (MULTICA_LARK_OAUTH_APP_ID / _SECRET / _REDIRECT_URI
/ _STATE_SECRET) returns configured:false from /install/start so the
frontend can render "configure manually instead" without an error
banner. /install/callback always finishes with a redirect to
/settings?tab=lark carrying either lark_installed=1 or lark_error=<code>.

Tests cover signed-URL shape, missing-config rejection, tampered state,
expired state, propagated exchange error, and the no-config redirect
path on the HTTP handler.

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

* feat(views/lark): settings tab + agent bind button + /lark/bind redemption page (MUL-2671)

Adds the user-facing Lark surface across the shared packages:

- packages/core/types/lark.ts — wire shapes that mirror server/internal/
  handler/lark.go. Optional fields default to undefined so older desktop
  builds keep parsing if the server adds new keys (CLAUDE.md → API
  Response Compatibility).
- packages/core/lark/{queries,index}.ts — Tanstack Query options keyed
  by workspace id; realtime sync invalidates `installations(wsId)` on
  `lark_installation:*` events.
- packages/core/api/client.ts — listLarkInstallations,
  getLarkInstallURL, deleteLarkInstallation, redeemLarkBindingToken.
- packages/views/settings/components/lark-tab.tsx — Settings → Lark
  panel. Listing is member-visible (matches backend); disconnect is
  admin-only. Empty state points users at the per-Agent bind entry,
  matching the (workspace_id, agent_id) UNIQUE: there is no
  "pick an agent" UI here because the bind URL is per-agent.
- LarkAgentBindButton (same file) is the per-Agent CTA the Agent
  detail page imports. Opens the OAuth URL in a new tab; the callback
  bounces back to /settings?tab=lark with a query param the panel
  reads for inline confirmation copy.
- packages/views/lark/bind-page.tsx — the Bot's "you need to bind"
  destination. Requires session before redeeming, distinguishes the
  410/409/403 backend responses into distinct copy.
- apps/web/app/lark/bind/page.tsx — Next.js route wrapping the shared
  bind page in a Suspense boundary (Next 15 useSearchParams rule).

i18n: all user-facing strings land in en/zh-Hans, settings tab nav
includes a Sparkles-iconed Lark entry, bind-page copy lives under
common.lark_bind so it works pre-workspace-context too. typecheck +
lint clean.

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

* chore(integrations/lark): wire outbound Patcher into server bootstrap (MUL-2671)

Constructs the Patcher next to the existing Installation/BindingToken
wiring in router.go and Register()s it on the event bus. With the stub
APIClient any actual transport call surfaces ErrAPIClientNotConfigured;
once the real Lark client lands, swap NewStubAPIClient for the real
implementation here without touching the Patcher's subscription logic.

doc.go updated to reflect everything the package now contains (Hub,
Patcher, OAuthService, APIClient interface). The Hub itself is NOT
booted here yet — it needs an EventConnector implementation for the
Lark long-connection wire protocol, which lands in a follow-up; the
orchestrator code and its unit tests are in place so that follow-up
can focus on the WS protocol rather than lifecycle plumbing.

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

* fix(integrations/lark): address Elon 二审 5 must-fix items (MUL-2671)

- Hub: renewer cancels run ctx on lease loss so the connector exits
  even if its wire I/O is blocked, keeping the §4.4 ownership
  invariant intact under lease theft.
- Hub: EventEmitter returns (DispatchResult, error) so the real
  connector can post the matching Lark-side card (needs_binding,
  agent_offline, agent_archived) and react to infra failures instead
  of silently logging at the seam.
- Dispatcher: top-level message_id dedup runs before group filter
  and identity check, so a reconnect storm cannot re-fire binding
  prompts or re-spam not_addressed_in_group audit rows; the in-
  AppendUserMessage dedup is removed since the table-level UNIQUE
  is the ultimate backstop.
- OAuth: HandleCallback auto-binds the installer via the new
  InstallerBinder seam (BindingTokenService implements it), so the
  §2.1 "scan to bind, you're done" promise holds end-to-end.
  validateExchangeResult now requires installer open_id; new error
  reason codes wired through the callback redirect.
- Frontend / handler: install_supported listing field + StartLark-
  Install short-circuit on stub APIClient hide install entry points
  (Settings tab + per-agent button) while no real Lark HTTP client
  is wired, so users do not land in an OAuth flow that fails at
  exchange.

Includes tests for each fix (lease-loss cancel, emit error
propagation, dedup ordering, OAuth installer-bind contract, stub-
client install gate) and i18n strings for the new preview state.

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

* fix(integrations/lark): two-phase dedup so infra failures do not swallow messages (MUL-2671)

The pre-fix top-level dedup wrote the lark_inbound_message_dedup row before
EnsureChatSession / AppendUserMessage. An infra error in either step left
the row in place and a WS-adapter retry was mis-classified as a duplicate,
so the user's Lark message was permanently lost without ever landing in
chat_session.

Make dedup two-phase:

  - ClaimLarkInboundDedup acquires an in-flight claim (processed_at NULL).
    Stale claims older than 60 s are re-takeable so a process crash does
    not strand the message_id.
  - MarkLarkInboundDedupProcessed flips processed_at on durable success
    (audit row OR chat_message + session touch).
  - ReleaseLarkInboundDedup deletes the in-flight row on infra failure
    before any durable side effect, so the retry can re-claim immediately.

Dispatcher.Handle now finalizes the claim exactly once based on whether
the inner pipeline reached a durable outcome — chat_message commit being
the transition point (errors past it Mark, errors before it Release).

Regression tests cover the two failure variants Elon flagged plus the
inverse invariants (durable-error Marks, drops Mark, in-flight replays
drop, stale claims re-claim).

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

* fix(integrations/lark): owner-fence dedup claim to close the double-write windows (MUL-2671)

The two-phase Claim/Mark/Release fix from the previous commit closed the
"infra error swallows a replay" gap but left two windows that could still
write a chat_message twice for the same Lark message_id:

  1. Stale-reclaim race. Worker A claims at t=0, runs slowly past the
     60 s staleness TTL but is still alive. Worker B sees the row as
     stale and re-takes the claim. A reaches AppendUserMessage and
     commits a second chat_message.

  2. Mark window. Worker A commits chat_message but the post-pipeline
     MarkLarkInboundDedupProcessed fails (DB hiccup) or the process
     crashes before it runs. 60 s later a retry treats the in-flight
     row as stale, re-claims it, and writes a second chat_message.

Close both with owner fencing + same-tx Mark:

  - lark_inbound_message_dedup now carries a `claim_token` UUID;
    ClaimLarkInboundDedup mints a fresh one on insert and on stale
    re-take, so a reclaim ROTATES the token.

  - MarkLarkInboundDedupProcessed and ReleaseLarkInboundDedup are
    fenced on (message_id, claim_token, processed_at IS NULL) and
    return rowsAffected. Zero means our token is no longer live, and
    the caller treats it as a no-op (not an error).

  - AppendUserMessage invokes MarkLarkInboundDedupProcessed INSIDE its
    chat_message+session tx (qtx). If the token has been rotated by a
    concurrent reclaim, the Mark matches zero rows and the method
    returns ErrClaimLost; the deferred Rollback unwinds the
    chat_message insert, so the other holder is the sole writer. The
    durable write and the Mark therefore commit (or roll back)
    atomically — there is no "committed but not yet Marked" window
    for a crash or retry to exploit.

Dispatcher.processClaimed now returns a tri-state dedupFinalize directive
(none / mark / release): finalizeNone for the in-tx Mark path (and
ErrClaimLost), finalizeMark for audit-drop branches and the defensive
post-Append-success fallback, finalizeRelease for pre-durable infra
errors. ErrClaimLost is translated into OutcomeDropped + DropReason-
Duplicate at the Handle boundary, matching what the WS adapter expects
for a "another worker is the writer" outcome.

Regression tests:

  - TestDispatcher_StaleReclaimRaceDoesNotDoubleWrite injects worker
    B's reclaim via a beforeAppend hook so the claim_token rotates
    between Claim and AppendUserMessage. Asserts worker A's
    AppendUserMessage returns ErrClaimLost (no chat_message
    committed), the dispatcher surfaces a duplicate drop, the token
    rotated to a value distinct from A's original, and a follow-up
    replay still duplicate-drops.

  - TestDispatcher_InTxMarkPreventsPostCommitReclaim verifies the
    "Mark window" case is unreachable: a successful in-tx Mark
    produces exactly one Mark call (no post-finalize duplicate), the
    row is terminal, and a retry with dedupReclaim=true still
    duplicate-drops without re-rotating the token.

  - TestDispatcher_InTxMarkSucceedsAndSkipsPostFinalize pins the
    positive contract: DedupMarked=true must make applyFinalize a
    no-op (no extra Mark, no Release).

fakeQueries gains a fakeDedupRow model carrying (processed, token,
rotations) so the test seam matches production's UPDATE-with-WHERE
semantics; fakeChat gains a beforeAppend hook to inject race timing.

go test ./... and go vet ./... pass.

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

* feat(integrations/lark): real Lark HTTP APIClient for IM v1 send/patch (MUL-2671)

Lands the production Lark Open Platform HTTP APIClient that replaces
the stub for outbound transport. The patcher's "thinking → streaming
→ final | error" card lifecycle and the dispatcher's binding-prompt
card both now reach Lark for real once MULTICA_LARK_HTTP_ENABLED=true.

Scope of this stage:

  - tenant_access_token retrieval via /open-apis/auth/v3/
    tenant_access_token/internal, cached in-process per app_id with a
    60s safety margin against Lark's `expire` value. Sub-2-minute
    expires are clamped to 120s so we never cache an entry that's
    already past its safe window.
  - SendInteractiveCard: POST /open-apis/im/v1/messages?receive_id_type=chat_id
    returning the Lark message_id the Patcher persists in
    lark_outbound_card_message for later patches.
  - PatchInteractiveCard: PATCH /open-apis/im/v1/messages/:id with
    the full re-rendered card body (Lark's update endpoint replaces,
    not deep-merges).
  - SendBindingPromptCard: open_id-targeted interactive card with a
    primary "去绑定" CTA pointing at the redemption URL. Template is
    co-located with the transport so the dispatcher never has to know
    about Lark's card schema.
  - Token-error invalidation: Lark codes 99991663 (expired) /
    99991664 (invalid) drop the cached token so the next call
    refreshes from /tenant_access_token/internal instead of looping
    on a stale entry.

Out of scope (deferred to follow-up stages):

  - ExchangeOAuthCode stays unimplemented behind
    ErrAPIClientNotConfigured. The PersonalAgent install handshake's
    response shape (returning per-installation app credentials in a
    single call) is not yet verified against the production endpoint,
    and a silent mis-fill of OAuthExchangeResult would corrupt
    lark_installation rows past validateExchangeResult. Operators
    continue to use the manual-paste InstallationService path until
    the OAuth stage lands.
  - Inbound WS EventConnector — Hub's ConnectorFactory still needs a
    real wire-protocol implementation.

Wiring:

  - MULTICA_LARK_HTTP_ENABLED=true switches router.go from the stub
    to the real client. MULTICA_LARK_HTTP_BASE_URL overrides the
    default open.feishu.cn host (set to open.larksuite.com for the
    Lark international tenant, or to an httptest URL for integration
    tests).
  - The OAuth handler now also receives the real client (its
    ExchangeOAuthCode still surfaces ErrAPIClientNotConfigured, so
    callback behavior is unchanged until that stage lands).

Tests (19 new cases against an httptest.Server fake):

  - happy path send/patch/binding-prompt round trips, asserting URL
    query params, body shape, Authorization header
  - token cache: 3 sends share one /tenant_access_token/internal hit
  - token refresh after clock-driven expiry
  - sub-margin expire clamping (10s expire → cached for >= safety
    margin of wall-clock)
  - Lark error code surfacing (230001 send, 230002 patch, 10003 auth)
  - token-expired (99991663) invalidates the cache; caller's retry
    re-fetches and succeeds
  - non-2xx HTTP status surfaces "http 500: …"
  - input validation: missing chat_id short-circuits BEFORE auth
    round-trip, missing card json / open_id / bind url all fail
    pre-flight without hitting Lark
  - ExchangeOAuthCode still returns ErrAPIClientNotConfigured
  - binding-prompt template carries the BindURL and the localized
    "去绑定" CTA in valid JSON

go build ./..., go vet ./..., and go test ./internal/integrations/lark/...
pass. Pre-existing handler/router integration tests that require a
real Postgres connection are unaffected by this change.

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

* fix(integrations/lark): split outbound vs OAuth-install capability + card update_multi (MUL-2671)

Address Elon's two must-fix items from the HEAD a09993b1 review:

1. HTTP outbound and OAuth-install are now distinct APIClient
   capabilities. The new SupportsOAuthInstall() reports whether the
   install flow can succeed end-to-end (i.e. ExchangeOAuthCode is
   implemented); the real httpAPIClient still returns IsConfigured()
   = true (send / patch / binding prompt work) but
   SupportsOAuthInstall() = false until the PersonalAgent install-time
   response shape is pinned. Handler-side `install_supported` and
   StartLarkInstall now gate on SupportsOAuthInstall, so a half-wired
   client never reveals the scan-to-bind UI. larkOAuthErrorReason also
   maps ErrAPIClientNotConfigured to a dedicated
   `oauth_exchange_unimplemented` reason so a raw callback hit no
   longer masquerades as `internal_error`.

2. defaultRenderer now emits config.update_multi=true on every Kind.
   Lark refuses to apply PatchInteractiveCard to a card whose initial
   config doesn't declare it shared/updatable, so the absent flag
   would make every patch after the first send silently no-op on the
   wire while the local outbound status row still flipped to
   streaming/final.

Tests cover both halves of each fix:
- TestHTTPClient_SupportsOAuthInstall_FalseUntilExchangeLands +
  TestHTTPClient_StubReportsBothCapabilitiesFalse pin the new
  capability surface.
- TestStartLarkInstall_TransportOnlyClientReportsNotConfigured +
  TestListLarkInstallations_TransportOnlyClientReportsInstallNotSupported
  pin the handler gate at exactly the half-wired state.
- TestLarkOAuthErrorReason_APIClientNotConfigured pins the mapping
  for both the bare sentinel and the fmt.Errorf-wrapped form
  HandleCallback produces.
- TestDefaultRendererConfigCarriesUpdateMulti covers every CardKind.
- TestHTTPClient_(Send|Patch)InteractiveCard_DefaultRendererBodyHasUpdateMulti
  verify the wire body Lark actually receives carries update_multi
  through both send and patch transport paths.

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

* feat(integrations/lark): real OAuth code exchange + agent-detail bind entry (MUL-2671)

Stages the install side of the MVP critical path on top of the real
HTTP outbound work:

- httpAPIClient.ExchangeOAuthCode runs the production Lark v2 OAuth
  flow: POST /authen/v2/oauth/token to swap the authorization code
  for the installer's open_id, then GET /bot/v3/info under the parent
  app's tenant_access_token to fetch bot_open_id. Result feeds
  InstallationParams unchanged so OAuthService.HandleCallback's
  auto-bind step lights up automatically.

- HTTPClientConfig gains OAuthAppID/OAuthAppSecret, read from the same
  MULTICA_LARK_OAUTH_APP_ID/_APP_SECRET env vars the OAuthConfig
  consumes. SupportsOAuthInstall now mirrors that pair so the install
  capability gate is honest: outbound transport without OAuth creds
  reports configured-but-not-install-supported, exactly like before.

- Agent detail inspector wires the LarkAgentBindButton in a new
  Integrations section, viewer-hidden by canEdit. The button still
  self-hides when SupportsOAuthInstall is false, so a deployment
  without OAuth creds renders the section empty rather than CTA-broken.

- Capability wording cleaned across handler / router / lark-tab to say
  "OAuth-install capability" instead of "real APIClient wired", and
  the misleading TransportOnly... test was renamed/refocused on the
  early-return branch it actually exercises (Elon non-blocking note).

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

* fix(integrations/lark): identity-only OAuth + atomic bind (MUL-2671)

Addresses Elon's round-4 must-fix items on PR #3277:

1. OAuth v2 token → user_info chain now matches Lark's official
   user-OAuth shape. `httpAPIClient.ExchangeOAuthCode` POSTs
   /open-apis/authen/v2/oauth/token (RFC 6749: top-level
   access_token, NO open_id), then GETs /open-apis/authen/v1/user_info
   with the user_access_token as Bearer to obtain the installer's
   open_id / union_id. The test fixture now reflects the real
   wire shape (separate user_info handler; no synthetic open_id in
   the token response).

2. `OAuthExchangeResult` is identity-only — drops the synthesized
   shared-parent AppID / AppSecret / BotOpenID return that broke
   the UNIQUE(app_id) constraint and the dispatcher's per-app_id
   routing. `OAuthService.HandleCallback` no longer Upserts an
   installation row: it looks up the lark_installation already
   provisioned via the manual-paste POST /lark/installations route
   and binds the installer onto it. Two new typed errors —
   ErrInstallationNotProvisioned and ErrInstallationRevoked — map
   to `installation_not_provisioned` / `installation_revoked`
   reasons at the HTTP boundary so the UI can guide the admin.
   The PersonalAgent install API (which would deliver
   per-installation bot credentials at scan time) remains a
   follow-up; until it lands the OAuth flow is identity-binding
   only and the agent-detail bind button stays hidden on
   deployments without OAuth env (capability gate unchanged).

3. The installation lookup + installer bind run inside a single
   DB transaction so a concurrent revoke / re-provision between
   the read and the binding insert cannot leak a half-applied
   state. `InstallerBinder.BindInstaller` is renamed to
   `BindInstallerTx` and accepts the OAuth-service-owned
   transaction's qtx; the binding_token redemption path is
   unchanged.

4. `validateExchangeResult` is simplified to require only the
   installer's open_id; the obsolete ErrExchangeMissingAppID /
   AppSecret / BotOpenID sentinels are removed (no caller can
   trip them now). The oauth_test suite is rewritten to use a
   stub failTxStarter so tests covering state-token verification
   and exchange-error propagation remain DB-free, while a new
   TestOAuthCallbackOpensTxAfterValidExchange pins the post-must-fix
   order (state ok + exchange ok ⇒ Begin runs before any lookup
   or bind, and a Begin failure aborts cleanly with no bind).

Verified locally:
  - go build ./... / go vet ./... clean
  - go test ./internal/integrations/lark/... ✓
  - go test ./internal/handler -run 'Lark|Binding|OAuth' ✓
  - go test ./internal/util/secretbox/... ./internal/service/... ✓

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

* feat(integrations/lark): device-flow scan-to-install (MUL-2671)

Replaces the manual paste-credentials install path + identity-only
OAuth callback (rejected in product review: too many steps before a
user sees value) with a true single-step scan-to-install built on
Lark's RFC 8628 device-flow registration endpoint
(POST accounts.feishu.cn/oauth/v1/app/registration) — the same
protocol the official larksuite/oapi-sdk-go/scene/registration
package and zarazhangrui/feishu-claude-code-bridge use.

User journey: admin clicks "Bind to Lark" on the Agent detail page
→ QR dialog opens → admin scans in the Lark app on their phone →
authorizes the new PersonalAgent → dialog auto-closes with the new
installation visible. No app_id / app_secret to copy, no Lark
developer console visit, no Multica-side OAuth env to configure.

Backend (server/internal/integrations/lark):
- registration.go — inline ~280-line RFC 8628 client. Begin posts
  archetype=PersonalAgent / auth_method=client_secret /
  request_user_info=open_id; Poll follows the upstream SDK's
  state machine including the tenant-brand mid-stream domain swap
  to accounts.larksuite.com when a Lark-international account
  authorizes. SDK is NOT vendored — one endpoint isn't worth
  dragging the full oapi-sdk-go + transitive deps.
- registration_service.go — owns the in-process session store
  + background polling goroutine. On success calls APIClient.GetBotInfo
  (the new IM-side endpoint added below) and writes
  lark_installation + the installer's lark_user_binding inside
  one DB transaction so a half-applied install can never land.
  Stable error_reason codes (expired / access_denied /
  lark_protocol_error / bot_info_failed / installation_conflict /
  installer_bind_failed / internal_error) drive the UI copy
  without parsing prose.
- client.go / http_client.go — drops ExchangeOAuthCode and
  SupportsOAuthInstall (no longer applicable: device-flow returns
  identity alongside credentials in one response); adds GetBotInfo
  which mints a tenant_access_token from the freshly-minted
  client_id / client_secret and calls /open-apis/bot/v3/info for
  the bot_open_id. install_supported now gates on IsConfigured()
  (real HTTP client wired) instead of a separate OAuth capability.
- binding_token.go — absorbs InstallerBindParams / InstallerBinder
  (previously in oauth.go), retargets the doc-comment from the
  OAuth caller to the device-flow caller.
- Deletes oauth.go + oauth_test.go entirely.

Handler & router (server/internal/handler, server/cmd/server):
- POST /api/workspaces/{id}/lark/install/begin — opens a new
  registration session, returns {session_id, qr_code_url,
  expires_in_seconds, poll_interval_seconds}. Admin-only.
- GET /api/workspaces/{id}/lark/install/{sessionId}/status —
  polling endpoint, returns {status, installation_id?, error_reason?,
  error_message?}. Workspace-scoped lookup so a stolen session_id
  cannot be polled from another workspace. Admin-only.
- Removes POST /lark/installations (paste form),
  GET /lark/install/start (OAuth-redirect entry), and
  GET /api/lark/install/callback (OAuth redirect target).
- Removes MULTICA_LARK_OAUTH_APP_ID / _APP_SECRET / _REDIRECT_URI /
  _STATE_SECRET / _AUTHORIZE_URL / _SUCCESS_URL env vars. Self-host
  operators no longer need a parent Lark app at all.

Frontend (packages/core, packages/views):
- New types BeginLarkInstallResponse / LarkInstallStatusResponse
  + matching API methods (beginLarkInstall / getLarkInstallStatus);
  drops getLarkInstallURL.
- LarkAgentBindButton opens LarkInstallDialog instead of a
  window.open() to Lark's authorize page. The dialog uses
  react-qr-code (catalog) to render the verification_uri_complete
  inline as SVG (no external CDN image), polls status at the
  server-supplied cadence, auto-closes on success, offers
  "scan again" on terminal failure. Per CLAUDE.md "Enum drift
  downgrades, not crashes", error_reason switch has a default
  fallback so an older desktop build on a newer server still
  renders the generic failure copy.
- Adds the device-flow strings to en + zh-Hans settings.json;
  removes the obsolete OAuth-not-configured copy.

Verified locally:
  - go build ./... / go vet ./... clean
  - go test ./internal/integrations/lark/... — all green
    (existing tests + 15 new registration / GetBotInfo tests)
  - go test ./internal/handler -run 'Lark|Binding' — all green
  - pnpm typecheck — all 6 packages clean
  - pnpm lint — 0 errors (15 pre-existing warnings, none in changed files)
  - pnpm --filter @multica/views test — 859/859 pass

Pre-existing failures in server/internal/middleware (column
"profile_description" missing from local test DB) reproduce against
the parent commit and are unrelated to this change.

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

* fix(integrations/lark): gate bind CTA to workspace admins, terminate QR polling on 4xx (MUL-2671)

Two frontend must-fixes from the PR #3277 二审:

1. LarkAgentBindButton now self-hides for non-admin viewers in addition
   to the existing install_supported check. The agent-detail page mounts
   the button under `canEdit`, which canEditAgent lets agent owners
   through even when they are not workspace admins — but the backend
   gates POST /lark/install/begin and the status poll on owner/admin
   (router.go:478-487), so the previous behavior shipped a CTA that was
   guaranteed to 403. The new gate reads workspace role from the same
   member list the settings tab already uses.

2. The status polling loop now terminates on 404 (session gone — server
   restarted, multi-instance routing, or in-process GC swept it) and
   403/401 (permission revoked mid-session). Previously every error
   path scheduled another setTimeout, which trapped the user on a stale
   QR forever. ApiError gives us the HTTP status verbatim; terminal
   responses set status=error with stable error_reason codes
   (session_lost, forbidden) that flow through the existing dialog
   switch + retry/close affordances. 5xx + network blips still retry.

i18n: new install_error_session_lost / install_error_forbidden in en
and zh-Hans, with default fallback preserved per the enum-drift rule.

Coverage: 6 new vitest cases — admin/owner allow, member deny,
unsupported-install deny, and the two terminal-error polling paths
using fake timers to assert the loop stops scheduling.

Also clears a handful of stale OAuth/manual-install doc comments
flagged in the review (non-blocker cleanup): doc.go's §10 now points
at RegistrationService, installation.go's input-shape doc loses the
OAuth-callback half, and client.go's stubAPIClient comments no longer
reference OAuth callbacks.

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

* docs(integrations/lark): describe gate as device-flow install in agent-detail integrations comment (MUL-2671)

The comment block above the agent-detail Integrations section still
described the capability gate as 'server-side OAuth-install'. The
OAuth path is gone — install is now device-flow per RFC 8628 — so the
comment now reads 'server-side device-flow install capability gate'.

Pure comment change; behavior is unchanged. Cleans up the nit Elon
called out in PR #3277 二审 (MUL-2671).

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

* feat(integrations/lark): wire inbound pipeline + WS Hub at boot (MUL-2671)

Stage 3.a of MUL-2671. Hub class, Dispatcher, ChatSessionService and
AuditLogger have all been implemented and tested in prior PRs but
none of them was constructed at boot, so the in-process plumbing
was never exercised end-to-end. This change wires them together
behind the same `MULTICA_LARK_SECRET_KEY` gate that already gates
InstallationService / RegistrationService, and starts the Hub under
the existing `sweepCtx` so it winds down alongside the other
long-running workers after HTTP drain.

The real long-conn EventConnector is still pending; the factory
hands every supervisor a shared NoopConnector that holds the lease
and emits nothing. That lets staging exercise the lease /
supervisor / shutdown lifecycle against real DB rows without
committing to the Lark wire protocol implementation. Swapping in
the real connector is a single line change in the same router
block; the Dispatcher / ChatSessionService / Hub seams stay frozen.

## Why a noop placeholder, not a stub-or-skip

The Hub's value is mostly its lifecycle: §4.4 ownership lease,
LeaseRenewInterval / LeaseTTL, supervisor reap on revoke, clean
release on shutdown. None of that runs unless the Hub is actually
started. Holding off until the real connector lands means the next
PR has to debut both pieces simultaneously; wiring the supervisor
loop first lets the real connector PR be a focused, reviewable
swap.

## Changes

- `internal/integrations/lark/noop_connector.go` — `NoopConnector`
  implementing `EventConnector`: blocks on ctx until the Hub
  cancels (lease loss / shutdown / revoke), emits no events, logs
  on enter/exit so operators see exactly which installation the
  supervisor is holding the lease for.
- `internal/integrations/lark/noop_connector_test.go` — verifies
  the connector blocks until ctx cancel, returns nil on clean exit,
  never invokes the emit callback, and the factory shares a single
  connector instance across installations.
- `internal/handler/handler.go` — new `LarkHub *lark.Hub` field on
  `Handler`. Nil when the Lark integration is disabled.
- `cmd/server/router.go` — inside the existing Lark wiring block,
  construct `AuditLogger`, `ChatSessionService` (with `*pgxpool.Pool`
  for the in-tx dedup Mark), `Dispatcher` (wiring `h.IssueService`
  and `h.TaskService` so `/issue`-created issues share counter /
  duplicate guard / project boundary / broadcast / analytics with
  the rest of the product), and the `Hub` with the
  `NoopConnectorFactory`. `NewRouterWithOptions` now returns
  `(chi.Router, *handler.Handler)` so main.go can drive Hub
  lifecycle; `NewRouter` discards the handler.
- `cmd/server/main.go` — start the Hub under `sweepCtx` after the
  other background workers, and `Wait` on it after HTTP drain +
  sweep cancel so the lease renewer can issue a final release
  before exit. Skipped entirely when `h.LarkHub == nil`.

## Test plan

- [x] `go build ./...` clean
- [x] `go vet ./...` clean
- [x] `go test ./internal/integrations/lark/...` (new noop tests +
      existing hub / dispatcher / chat_service / registration /
      binding_token / outbound / issue_command suites) — all pass
- [x] `go test ./internal/handler -run 'TestLark|TestRedeemLarkBinding'`
      pass — handler-side Lark surfaces unchanged
- [x] `go test ./internal/service/... ./internal/util/secretbox/...`
      pass
- [x] `pnpm --filter @multica/views exec vitest run settings/components/lark-tab`
      pass (6/6) — frontend lark surfaces unchanged
- [ ] Local broad `go test ./internal/handler/...` still blocked by
      the pre-existing test DB schema drift Elon flagged in the
      previous round (`column "metadata" does not exist`,
      unrelated to this change); CI is the authoritative check.
- [ ] Manual end-to-end deferred until the real long-conn
      EventConnector lands (next stage).

MUL-2671

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

* fix(integrations/lark): bound Hub lease release + shutdown wait (MUL-2671)

Lease release used context.Background(); a stalled DB pool could pin
shutdown indefinitely. Add LeaseReleaseTimeout (5s default) and
ShutdownTimeout (15s default) to HubConfig, route releaseLease through
a bounded context, and expose WaitWithTimeout for main.go so a wedged
supervisor degrades to LeaseTTL expiry on the next replica instead of
blocking process exit. Also correct the LarkHub field comment in
handler.go: the Hub is wired whenever the at-rest secret key is set,
independent of whether the outbound HTTP APIClient is configured.

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

* feat(integrations/lark): real WS long-conn connector + ctx-cancel-breaks-read (MUL-2671)

Replaces NoopConnectorFactory with a production EventConnector that
opens Lark's event-subscription WebSocket. Gated behind
MULTICA_LARK_WS_ENABLED so staging boots stay on the noop path until
operators opt in, and falls back to noop with a warning when the WS
flag is set without MULTICA_LARK_HTTP_ENABLED (the real connector
needs the cached tenant_access_token).

Why this connector exists separately from the Hub: gorilla/websocket
ReadMessage blocks on the underlying TCP socket and does not observe
context. The watchdog goroutine inside WSLongConnConnector.Run closes
the conn the moment ctx fires, so lease loss / shutdown breaks the
blocking read in bounded time — exactly the invariant Hub
renewLeaseUntil's runCancel depends on for the "at most one active WS
per installation across replicas" guarantee. Tests cover this
explicitly (TestWSConnectorRunReturnsOnCtxCancelEvenWhenReadIsBlocked).

The Lark wire surface is split into three swappable seams so the
transport layer stays tested in isolation:

  - EndpointFetcher (POST /event-subscription/v1/connection_token)
    resolves a one-shot wss URL per Run. No caching — replaying a
    one-shot token would look like a Lark outage.
  - FrameDecoder turns one raw JSON envelope into an InboundMessage
    or a "control / heartbeat / drop" verdict. Decoder errors log
    + drop the frame; they do NOT tear down the connection.
  - CredentialsProvider wraps InstallationService.DecryptAppSecret
    so plaintext app_secret lives in memory only during a Run.

Also fixes the handler.go LarkHub comment: it still said "joins on
Wait during graceful shutdown" but main.go has used WaitWithTimeout
(bounded wait) for several commits. Comment now matches.

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

* feat(integrations/lark): align WS to official binary Frame protocol + DispatchResult outbound replies (MUL-2671)

Two must-fix items from Elon's review of PR #3277:

1. WS protocol layer rewritten to match the official Lark Go SDK
   (`larksuite/oapi-sdk-go/v3/ws`):
   - Bootstrap is `POST /callback/ws/endpoint` with AppID/AppSecret
     in the body (no tenant_access_token bearer). Response carries
     wss URL + ClientConfig (PingInterval / ReconnectInterval /
     ReconnectNonce / ReconnectCount).
   - `service_id` is parsed from the wss URL query and used as
     Frame.Service on every outbound frame.
   - Wire envelope is the binary protobuf `pbbp2.Frame` (hand-rolled
     via protowire to avoid pulling the whole SDK in, byte-identical
     field tags). JSON payloads are nested inside Frame.Payload.
   - Inbound data frames are ACKed with a `Response{code:200,...}`
     JSON payload that reuses the inbound headers; infra failures
     produce code=500 so Lark retries.
   - Ping is the app-layer binary `NewPingFrame(serviceID)` at the
     server-supplied cadence; WebSocket protocol PING is removed
     (Lark ignores it). Server-initiated pings get a pong reply.
   - ctx-cancel-breaks-read invariant preserved via the watchdog
     goroutine that closes the conn on ctx.Done; the read loop and
     ping goroutine serialize their writes through a single mutex.

2. `DispatchResult` outbound replies wired via a new `OutcomeReplier`:
   - `OutcomeNeedsBinding` mints a one-shot binding token and sends
     the binding prompt card to the sender's open_id.
   - `OutcomeAgentOffline` / `OutcomeAgentArchived` push a notice
     card into the chat with the agent name + Chinese copy matching
     §4.6.
   - `OutcomeIngested` stays owned by the Patcher; `OutcomeDropped`
     is silent.
   - The replier is best-effort: outbound failures are logged and
     swallowed so a Lark outage cannot stall the inbound pipeline.
   - Hub installs the noop replier by default; router wires the
     production `LarkOutcomeReplier` when APIClient.IsConfigured().

PersonalAgent long-conn risk surfaced (open per Feishu docs:
`长连接模式仅支持企业自建应用`). The implementation works for any
app archetype; the open question is whether `/callback/ws/endpoint`
accepts PersonalAgent credentials in practice. Surfacing the Lark
code+msg verbatim from the bootstrap response so an operator running
the smoke test sees the exact failure rather than a generic timeout.

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

* fix(integrations/lark): byte-compat Frame marshal, chunk reassembly, ACK off reply critical path (MUL-2671)

Three protocol blockers from Elon's review of 9540008a:

1. Frame.Marshal is now byte-identical to oapi-sdk-go/v3/ws/pbbp2.Frame:
   - SeqID/LogID/Service/Method (proto2 req) emit unconditionally even at zero
   - PayloadEncoding/PayloadType/LogIDNew emit unconditionally per gogo
     generated MarshalToSizedBuffer (no zero-guard)
   - Payload uses the SDK's `!= nil` guard (nil omits, []byte{} emits 0-length)
   - ACK payload JSON matches SDK's NewResponseByCode + json.Marshal output
     ({"code":N,"headers":null,"data":null})

   Golden tests pin exact byte sequences for ping/pong/ACK/full/zero
   frames; verified against the real SDK pbbp2.pb.go MarshalToSizedBuffer
   producing identical bytes.

2. Multi-frame events (sum>1) are reassembled via the new chunkAssembler:
   - 5s sliding TTL (matches SDK combine() cache TTL)
   - Lazy GC on admit (no separate sweeper goroutine)
   - Out-of-order seq + duplicate seq idempotent
   - Partial chunks are NOT ACKed (SDK behaviour: only the final chunk's
     ACK confirms the whole event so Lark can retry on partial loss)
   - Connector wires assembler per-Run; state dies with the session

3. OutcomeReplier detached from ACK critical path:
   - HubConfig.ReplyTimeout default 2.5s, strictly under Lark's 3s ACK deadline
   - handleEvent dispatches synchronously (fast DB path), then spawns the
     replier under a fresh background ctx with WithTimeout(ReplyTimeout)
   - Hub.replyWg tracks in-flight replies; Hub.Wait / WaitWithTimeout
     drain them so shutdown is bounded
   - Noop replier short-circuits inline (no goroutine cost when outbound
     APIClient isn't configured)

   Proof tests:
   - TestHubScheduleReplyReturnsImmediately: scheduleReply with a 10s
     slow replier returns in <50ms
   - TestHubReplyTimeoutCancelsHungReplier: hung replier ctx fires at
     ReplyTimeout
   - TestHubWaitDrainsInFlightReplies: Wait blocks until replies finish
   - TestHubACKNotBlockedByOutboundReply: end-to-end through the
     connector — data-frame ACK lands within 500ms even when the
     replier hangs 5s

PersonalAgent real-env smoke remains Bohan's decision; this PR closes
the technical blockers Elon flagged.

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

* docs(service/issue): narrow position concurrency claim to create-create (MUL-2671)

Elon's review of the merge resolution flagged that the comment on the
new NextTopPosition call promised more than the code guarantees:
concurrent manual reorder via UpdateIssue(position) does NOT take the
workspace row lock that IncrementIssueCounter holds, so a create
racing a reorder can still land on the same position. Rewrite the
comment to only claim create-create serialization, which is the
behaviour the lock actually delivers. No code change.

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

* fix(integrations/lark): keep device-flow polling on RFC 8628 HTTP 400 (MUL-2671)

Lark's device-flow polling endpoint returns HTTP 400 with the JSON
body `{"error":"authorization_pending"}` while the user hasn't scanned
the QR yet — this is the RFC 8628 spec, and the upstream oapi-sdk-go
implements the same handling. Our previous doForm treated ANY non-2xx
as a terminal protocol error, so every install session was killed by
the first poll (~5s after begin) and the install dialog appeared
silently empty: the frontend received status=error +
lark_protocol_error before the user could even read the description.

Fix: doForm now decodes the JSON body first; if it parses, the caller
(Begin / Poll) routes on the body's `error` field, where the existing
switch correctly maps authorization_pending / slow_down to "keep
polling" and access_denied / expired_token to terminal failure. Only
unparseable bodies (5xx HTML proxy pages, gateway timeouts) still
surface as a typed http_NNN RegistrationError.

Three regression tests pin the new behaviour:
- HTTP 400 + authorization_pending → res.Status="authorization_pending"
- HTTP 400 + access_denied → res.Err.Code="access_denied" (terminal)
- HTTP 502 + HTML body → http_502 RegistrationError

Verified against the live local env: install/begin -> 200, status
stays "pending" through the first poll cycle, no longer flips to
"error" within seconds.

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

* fix(views/lark): reset closedRef on every mount so StrictMode double-mount renders QR (MUL-2671)

Empty QR dialog body in the dev env: Bohan opened the bind dialog and
got an empty white area where the QR should have been — no QR, no
"starting" placeholder, no error text. Backend was returning the QR
URL correctly; the bug was on the frontend.

Root cause: React 19 / Next.js dev StrictMode mounts every component
twice (mount → cleanup → mount). The component instance is REUSED
across the simulated remount, which means useRef objects are
preserved. The dialog's `closedRef` lifecycle:

  1. Mount #1: closedRef={current:false}, beginSession() kicked off
     (HTTP request still in flight)
  2. Cleanup runs: closedRef.current=true
  3. Mount #2: beginSession() kicked off again, BUT the ref still
     reads {current:true} from step 2
  4. Both promises resolve. Both hit the post-await guard
     `if (closedRef.current) return;` and bail out before setSession().
  5. Result: session stays null forever. Every conditional in the
     dialog body (beginning/session-pending/success/error) is false →
     empty body.

Fix: reset closedRef.current=false at the START of the effect, not
just at component construction. The cleanup-then-mount pair now
re-arms the guard so subsequent setSession calls actually land.

Regression test wraps the dialog in <StrictMode> and asserts the
QR appears within 2s with the correct value — fails closed if anyone
removes the reset.

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

* fix(integrations/lark): drop EventTaskCompleted subscription so the chat reply doesn't get overwritten by "Done." (MUL-2671)

Bohan reproduced on the live dev env: agent replies show only a card
saying "Done." in Lark, even though Multica's own chat panel has the
real "Hello! I'm cc…" reply. Tasks succeed end-to-end, but the user
loses the reply on the Lark side.

Root cause: TaskService.CompleteTask publishes two events for every
chat task IN ORDER:

  1. broadcastChatDone(...)       → ChatDonePayload{Content: "Hello!..."}
  2. broadcastTaskEvent(Completed) → map[string]any{task_id, agent_id,...}
                                     (no `content` key)

The Patcher subscribed to BOTH and routed each to finalize(). The
first patch correctly rendered the reply text, the second
patched the same card with an empty payload — chatDoneContent()
returned "" and the renderer fell back to "Done." (default empty-body
copy). The second patch wins because Lark stores whatever was last
applied.

Fix: stop subscribing to EventTaskCompleted in the Patcher and remove
the corresponding switch arm. EventChatDone is the canonical "agent
finished replying" signal for the Lark card path; EventTaskCompleted
is still emitted to the bus for other listeners (web UI, analytics,
task usage) where the lack of content doesn't matter.

Regression test TestPatcherIgnoresEventTaskCompletedForChatTasks
emits ChatDone followed by TaskCompleted on a streaming card and
asserts: exactly one patch, body contains the agent reply, body does
NOT contain "Done.". If anyone re-adds the EventTaskCompleted
subscription, this fails immediately.

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

* feat(integrations/lark): chat replies as plain text IM messages, not card chrome (MUL-2671)

Bohan reported on the live dev env that even with the agent's reply
shown correctly, every message is wrapped in an interactive card with
the agent name as the header — it feels like a system notification,
not a normal chat reply. He wants the reply to land as a regular Lark
text bubble.

Changes:

- Add APIClient.SendTextMessage backed by Lark's
  /open-apis/im/v1/messages with msg_type=text. JSON-encodes the
  {"text": ...} envelope Lark requires so callers pass raw strings.
- Patcher.Register no longer subscribes to EventTaskQueued /
  EventTaskRunning. There is no more thinking → running → final
  card lifecycle on the success path: it added card chrome without
  buying anything for free-form chat.
- On EventChatDone, the new sendChatReply path posts the assistant
  message content as plain text. Empty content is silently dropped
  rather than rendered as "Done." (the prior fallback that
  confused Bohan).
- Failure path keeps a one-shot error card on EventTaskFailed —
  the visual distinction from a normal reply is genuinely useful,
  and failures are rare enough that the chrome isn't noisy.
- Throttle / lastPatched map / MinPatchInterval / shouldPatch /
  markPatched / loadCardOrSkip are all removed; nothing in the new
  flow patches.

Tests:

- TestPatcherSendsPlainTextOnChatDone pins the new contract: exactly
  one SendTextMessage call, no card sends or patches, content
  matches the ChatDonePayload.
- TestPatcherDropsEmptyChatReply pins the "no more Done. fallback"
  decision — empty content drops, period.
- TestPatcherFailEventSendsErrorCard pins the failure path still
  uses a card (one-shot, no patching).
- TestPatcherIgnoresEventTaskCompletedForChatTasks rewritten for
  text path: ChatDone then TaskCompleted yields exactly one text
  send, no duplicate.
- TestPatcherSkipsWhenNoChatSessionBinding and
  TestPatcherSwallowsInstallationLoadErrors rewritten to drive
  EventChatDone (the new entry point) instead of TaskQueued.
- TestPatcherSendsThinkingCardOnTaskQueued deleted (no more
  thinking card).

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

* feat(integrations/lark): pre-fill PersonalAgent bot name as "<agent> - Multica" (MUL-2823) (#3520)

The device-flow install left the bot at Lark's auto-generated
"{用户姓名}的智能助手". Lark's registration scene supports pre-filling the
name via a `name` query param on the verification/QR URL (mirrors the
upstream SDK's AppPreset.Name) — a user-editable default that rides on
the QR URL, not the begin POST body (which has no name field).

BeginInstall already loads the agent for its ownership check, so we keep
it and thread `<agent.Name> - Multica` through Begin → decorateQRCodeURL.
A blank name degrades to plain "Multica".

There is no post-install rename API (bot/v3 is read-only; no
bot/v3/update), so the install-time pre-fill is the only programmatic
lever; the user can still edit the name on the creation form.

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

* fix(integrations/lark): restore /issue confirmation + pin SendTextMessage wire (MUL-2671)

Two recovered/added contracts off Trump's review of HEAD fe381a07:

1) /issue confirmation in Lark was a casualty of the plain-text
   refactor. The pre-refactor `RenderInput.IssueNumber` field was
   declared but never actually rendered into the card body, so even
   in the original card-based flow the user never saw a "Created
   [MUL-42]" confirmation. Now the OutcomeReplier handles
   OutcomeIngested + IssueID.Valid by sending a plain text message:

     Created MUL-42 — fix login bug
     https://multica.example/issues/MUL-42

   Composed from a new DispatchResult.IssueIdentifier +
   IssueTitle, populated by the Dispatcher from
   workspace.IssuePrefix + issue.Number / issue.Title. Workspace
   lookup is best-effort: a Postgres blip on workspace gets a "#42"
   fallback rather than silently dropping the confirmation.

   The agent's own chat reply (if any) continues to land separately
   via the Patcher on EventChatDone — these are two semantically
   distinct messages and the user benefits from seeing both.

2) SendTextMessage is the wire layer Trump flagged for missing
   coverage. Three new wire tests pin:
   - happy path: POST /open-apis/im/v1/messages?receive_id_type=chat_id,
     msg_type=text, Bearer <tenant_access_token>, double-JSON
     content envelope
   - special-character round trip: newlines, double quotes,
     backslashes, tabs, Chinese + emoji, JSON-lookalike strings.
     The inner {"text": ...} is encoded once at JSON.Marshal time
     and once again when the outer body serializes; losing either
     pass corrupts the message and the bug is invisible without a
     contract pin.
   - Lark error path: non-zero `code` surfaces as a wrapped error
     with the code embedded.

Tests:
- TestDispatcher_IssueCreationFromCommand asserts IssueIdentifier
  ("MUL-42") and IssueTitle propagate through DispatchResult.
- TestDispatcher_IssueIdentifierFallsBackToNumberOnWorkspaceLookupErr
  pins the "#7" degrade-graceful fallback.
- TestLarkOutcomeReplierIssueCreatedSendsConfirmation pins the
  text body (identifier + title + deep link) and asserts no card
  send on this path.
- TestLarkOutcomeReplierOutcomeIngestedSilentWithoutIssue pins
  the silent-on-plain-chat default so we don't accidentally start
  emitting a confirmation for every message.
- TestHTTPClient_SendTextMessage_* covers the wire contract.

Frontend locale parity (en + zh-Hans, 53 tests) is currently green
on this HEAD; no changes needed.

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

* fix(views/locales): add missing ko keys for Lark MVP (MUL-2671)

Trump flagged on PR #3277 review that the ko bundle was missing the
Lark-MVP-only keys that en + zh-Hans both carry. The parity test
caught it cleanly after main was merged in (Korean PR landed on main
between the prior review and this one):

  common.lark_bind.*                       (13 keys)
  settings.page.tabs.lark                  (1 key)
  settings.lark.*                          (45 keys)
  agents.inspector.section_integrations    (1 key)

Korean translations are professional/concise — "Lark" stays as the
brand name (matches how en keeps "Lark" + "(飞书)" parenthetically;
ko/users searching for the product expect "Lark"), and product copy
follows the zh-Hans tone where Multica nouns ("에이전트", "워크스페이스")
are romanized loan words consistent with the rest of the ko bundle.

Slot ordering preserved against EN:
  - page.tabs.lark sits between github and integrations
  - inspector.section_integrations sits right after section_skills

Verified: pnpm exec vitest run locales/parity → 105/105 pass.
Co-authored-by: multica-agent <github@multica.ai>

* fix(integrations/lark): /issue origin_type CHECK + Hub restart on credentials rotation (MUL-2671)

Two live-env bugs Bohan reproduced:

1) /issue command crashed the WS connector. Dispatcher writes
   origin_type='lark_chat' on issues born from `/issue`, but the
   issue_origin_type_check CHECK constraint was last extended in
   migration 060 for quick_create — it doesn't list lark_chat, so
   every Lark /issue tripped SQLSTATE 23514 and bubbled up as an
   infra error. The infra error tore down the WS connector, Lark
   retried the same message, the new connector tripped the same
   constraint and crashed again. Repro in the live env: three
   crashes from the same /issue event over ~40s, each leaving the
   user with no confirmation in Lark.

   Migration 111 extends the CHECK list:
     CHECK (origin_type IN ('autopilot', 'quick_create', 'lark_chat'))

2) Re-scanning an already-bound agent silenced the bot. The device
   flow re-registers with Lark, which mints a brand-new bot (fresh
   app_id + app_secret); RegistrationService.finishSuccess upserts
   into lark_installation by agent_id, so the row's credentials
   rotate in place. But the running supervisor held the OLD inst
   struct by value and kept a WS open against the OLD bot's app_id —
   so all events to the NEW bot went nowhere. Bohan's "claude code
   现在不能在飞书里回复了" symptom maps exactly to this:

      log timeline:
      16:29:57  cc connector connected with app_id=cli_aa9398dd...  (OLD)
      16:34:07  lark registration: install complete                  (rotation)
      → row.app_id is now cli_aa93f36f...                            (NEW)
      → old WS still subscribed to OLD app_id; new app_id receives nothing

   Fix: Hub.sweep now compares each installation row's credentials
   fingerprint (app_id + bot_open_id + sha256(app_secret_encrypted))
   against the snapshot the running supervisor was started with. On
   diff, cancel the old supervisor and start a fresh one inline. A
   monotonic gen counter on the supervisor entry disambiguates the
   old goroutine's deferred cleanup from the new entry the rotation
   path already swapped in.

Tests:
- TestHubRestartsSupervisorOnCredentialsRotation pins the new path:
  starts hub on app_one, rotates the row to app_two, asserts the
  connector factory is called again with the fresh AppID.
- TestHubDoesNotRestartSupervisorOnUnchangedRow pins the negative
  case so an unchanged row doesn't degenerate into a per-sweep
  busy-loop.
- Existing hub tests (lease, supervise, shutdown, ACK timing,
  noop replier) all green.

Verification:
- go test ./internal/integrations/lark/... -race -count=1   ok
- go build ./... clean
- migration applied locally; \d+ issue confirms lark_chat in CHECK

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

* fix(integrations/lark): per-supervisor lease token to fence rotation handoff (MUL-2671)

Elon flagged a race in HEAD be8d4cef's rotation path: both the old
and the new supervisors of the same Hub used the hub-wide nodeID as
their WS lease token, so an old supervisor's post-cancel
releaseLease(nodeID) would CAS-match the lease row the successor had
just acquired with the SAME token and DELETE it. Symptom would be a
silently empty lease row a few hundred ms after every device-flow
re-scan — no replica owning the install, no events delivered, the
"bot goes quiet" pattern Bohan hit the first time but now from the
fencing side rather than the credentials side.

Fix: leaseToken(nodeID, gen) composes "<nodeID>-g<gen>", where gen is
the monotonic counter already attached to each supervisorEntry. The
nodeID prefix keeps cross-replica observability (an operator
inspecting lark_installation.ws_lease_token can still map back to a
process) while the -g suffix makes the OLD supervisor's release
target the OLD row state. Once the rotation path swaps in the new
supervisor, the row's CurrentToken is the new -g(N+1) token, so the
old -gN release's WHERE clause no-ops instead of clobbering.

acquireLease / renewLeaseUntil / releaseLease now take an explicit
token argument; supervise threads its leaseToken through. The
plumbing isn't pretty, but having an explicit argument at every call
site is the only way the rotation invariant survives subsequent
refactors — without it, a future caller could quietly reintroduce
"just use h.nodeID" and the race is back.

Two regression tests:

- TestHubRotationStaleReleaseDoesNotClearSuccessorLease drives the
  fake lease state machine directly:
    1. old acquires(tokenA)
    2. rotation lands; new acquires(tokenB)
    3. old's stale release(tokenA) fires
  Asserts owner ends up still tokenB. Hub-wide-nodeID code would fail
  step 3 by clearing the entry.

- TestHubRotationEndToEndKeepsSuccessorLeased runs the same scenario
  through the live supervise loop: starts hub, rotates the row, waits
  for sup2 to take over with a distinct token, sleeps past sup1's
  unwind, asserts the row is still held by a non-sup1 token. Catches
  the bug even when the goroutine timing is non-deterministic.

Verification: go test ./internal/integrations/lark/... -race -count=1   ok
  go build ./...                                            clean
  go vet ./...                                              clean
Co-authored-by: multica-agent <github@multica.ai>

* fix(integrations/lark): route group @-mentions via union_id, not open_id (MUL-2671)

In a Lark group with multiple Multica bots installed, the bot whose WS
received the event sometimes failed to recognize that it was the @-target
while the OTHER bot's supervisor falsely fired. Bohan's controlled three-
message test (only @A, only @B, @both) hit this: @A and @B alone went
unanswered, @both got picked up by A only.

Root cause: the `mentions[].id.open_id` field Lark puts on the WS event
is structurally INVERSE to `/bot/v3/info`'s `bot.open_id` across the two
WSes. From A's WS perspective, the wire-form open_id for "A was @-ed"
is NOT equal to A's API-side open_id, but IS equal to what B's WS sees
on its side, and vice versa. The decoder's `mention.open_id ==
inst.BotOpenID` match therefore fires on the wrong bot in multi-bot
groups. Only `union_id` (the Lark-tenant-scoped stable identifier) is
consistent across both WSes.

Changes:
- migration 112 adds nullable `lark_installation.bot_union_id`
- sqlc query exposes UpsertLarkInstallation/CreateLarkInstallation
  with bot_union_id, plus a focused SetLarkInstallationBotUnionID for
  the backfill path
- httpAPIClient.GetBotInfo now follows /bot/v3/info with /contact/v3/
  users/{open_id}?user_id_type=open_id and returns both identifiers
  on BotInfo. Soft-fails on contact-scope denial: install still
  succeeds with an empty UnionID, and the decoder falls back to the
  legacy open_id match for single-bot deployments.
- RegistrationService.finishSuccess persists union_id alongside
  open_id during the device-flow finalize.
- ws_frame_decoder.containsMention prefers union_id and only walks
  open_id when the installation row has not been backfilled yet.
- BackfillBotUnionIDs runs once at server boot for installations
  created before migration 112; bounded per-row 10s timeout and a
  pure soft-fail policy so a slow Lark round-trip cannot block
  startup.
- regression tests cover the three decoder paths: union_id match
  wins over open_id mismatch, union_id mismatch overrides open_id
  match, and open_id fallback when union_id is unknown.

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

* chore: drop trailing blank lines at EOF on four files (MUL-2671)

git diff --check origin/main..origin/pr-3277 flagged these as new
blank lines at EOF; clearing so the diff stays clean for review.

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

* fix(views/locales): add missing ja keys for Lark MVP + section_integrations (MUL-2671)

CI frontend job tripped on the ja locale parity check: ja is missing
the lark_bind block in common.json, the lark block + page.tabs.lark
in settings.json, and inspector.section_integrations in agents.json.
The ko fix earlier covered Korean; ja was added separately on main
and the merge surfaced these gaps. Translations mirror the en source
and follow the same voice as the existing ja bundle.

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

* fix(integrations/lark): rewrite @_user_N placeholders into clean body (MUL-2671)

When Lark dispatches a group `im.message.receive_v1`, the message
text contains opaque `@_user_1`, `@_user_2`, … placeholders and the
real identity is in `mentions[]`. We were forwarding the raw text to
the agent, so a Bohan-typed "@Bot ping test" arrived as "@_user_1
ping test" — neither human-readable nor useful as LLM context, and
the agent was paying tokens to figure out which `@_user_N` was even
itself.

The new resolveMentions pass:
  * strips the bot's own mention entirely (the dispatcher already
    routes the event on AddressedToBot; re-emitting @<self> in front
    of every message adds zero signal and pollutes context),
  * substitutes other participants with `@<displayName>` so a
    follow-up "@Alice" reads naturally,
  * collapses horizontal whitespace introduced by the strip while
    preserving original newlines.

Bot identity check uses the same union_id-preferred + open_id
fallback as containsMention, so the rewrite stays consistent with
the routing path. Tests cover the four shapes: bot self-mention,
mixed bot + other-user mention, multi-line body with stripped
mention, and a no-mention body that should be left untouched.

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

* fix(integrations/lark): union_id-first self mention strip + token-aware scan + local whitespace cleanup (MUL-2671)

Three review blockers on the mention rewrite from PR review:

1. isBotMention now mirrors containsMention's union_id-first policy.
   When the installation row knows our union_id, we trust it
   exclusively (open_id is structurally inverted in multi-bot
   groups — matching on it would re-introduce the routing bug we
   fixed two commits ago). open_id fallback fires only when
   union_id is absent. New tests: @-ing both bots in one message
   correctly strips only self and renders the sibling as @<name>;
   open_id-matches-but-union_id-differs does NOT strip.

2. resolveMentions no longer collapses or trims whitespace globally.
   Indentation, tabs, code blocks, tables — all preserved verbatim.
   When the self mention is removed we eat exactly one adjacent
   horizontal space (the one after the placeholder, or, when the
   mention sits at end-of-input, a single space already emitted
   right before it). New test exercises a multi-line indented +
   tabbed body and asserts the whole shape survives.

3. Prefix-collision-safe replacement. A chat with 11+ participants
   exposes both `@_user_1` and `@_user_10`; naive ReplaceAll for
   `@_user_1` would mangle the substring of `@_user_10`. The
   resolver now does a single-pass token scan with the mention
   list sorted longest-key-first, so the longer placeholder always
   wins at any scan position. New test covers the @_user_1 /
   @_user_10 case explicitly.

Also drops the temporary INFO-level diag logging the previous
commit added — root cause was confirmed (union_id swap in the
manual backfill; not a decoder bug).

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

* fix(integrations/lark): scope inbound dedup per (installation_id, message_id) (MUL-2671)

Root cause of the residual "@Cc gets dropped as not_addressed_in_group"
even after the union_id swap landed: lark_inbound_message_dedup was
keyed on `message_id` alone. In a Lark group chat where the workspace
has multiple Multica bots installed, Lark delivers the SAME message_id
to every bot's WS supervisor. Whichever WS claimed first then ran its
own AddressedToBot check; the bot that was actually @-ed lost the dedup
race, found the row already terminal (`processed_at IS NOT NULL`), and
was dropped as `duplicate` BEFORE it could evaluate its own mention.
Net: every @ silently disappeared if Lark happened to route the OTHER
bot's WS first.

The dedup gate's original purpose (idempotency against WS reconnect
replay) is per-installation by definition, so the right key is
composite (installation_id, message_id).

Changes:
- migration 113 drops + recreates lark_inbound_message_dedup with
  installation_id NOT NULL REFERENCES lark_installation(id) ON DELETE
  CASCADE and PRIMARY KEY (installation_id, message_id). The table is
  a 24h transient cache, so dropping existing rows is safe.
- sqlc queries: ClaimLarkInboundDedup / MarkLarkInboundDedupProcessed /
  ReleaseLarkInboundDedup all now take installation_id.
- AppendUserMessageParams carries InstallationID through to the
  in-tx Mark call so the chat_message+dedup atomicity stays intact.
- Dispatcher passes inst.ID to claim + applyFinalize + AppendUserMessage.
- Test fakes key dedup state on (installation_id, message_id) via a
  composite map key; all existing pre-seeded rows use a seedDedupKey
  helper bound to the default activeInstallation fixture so the prior
  staleness / token-rotation / in-tx mark tests still exercise the
  same regression they did before.
- New regression TestDispatcher_DedupIsScopedPerInstallation pins the
  multi-bot invariant: a row pre-seeded for installation A does NOT
  block installation B's first delivery of the same message_id; B
  runs through its own group-filter / identity / ingest pipeline.

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

* feat(integrations/lark): render markdown chat replies via schema-2.0 card (MUL-2671)

The agent's chat replies were going out as msg_type=text, so every
`**bold**`, fenced code block, list, table, and link in the body
showed up as literal markdown characters in Lark — the user saw raw
asterisks, hashes, pipes instead of formatted text. Bohan reported
this and pointed at zarazhangrui/lark-coding-agent-bridge as the
shape to emulate.

The bridge repo uses Lark interactive cards with the schema-2.0
envelope and a `tag: "markdown"` body element; Lark's client
renders that to formatted text (GFM-ish: bold/italic, headings,
lists, links, fenced code blocks, tables, blockquotes). They expose
multiple reply modes (card / markdown-as-post / text) gated by user
config; we go a step simpler — auto-detect markdown syntax in the
agent's body and route accordingly:

- containsMarkdown(): cheap substring + regex pass for fenced code
  blocks, headings, list markers, bold/italic, tables, links,
  blockquotes, horizontal rules, inline code. Biases toward false-
  positive — wrapping prose in a card still renders fine, but
  missing a real markdown block leaves raw characters visible.

- APIClient gains SendMarkdownCard / SendMarkdownCardParams.
  Implementation marshals the schema-2.0 envelope verbatim:
  {schema:"2.0", body:{elements:[{tag:"markdown", content: md}]}}.
  Stub returns ErrAPIClientNotConfigured.

- Patcher.sendChatReply now branches on containsMarkdown:
  markdown → SendMarkdownCard, plain prose → SendTextMessage. A
  one-liner "sure, on it" stays as a normal IM bubble (no card
  chrome); anything with markdown gets the rendered card.

Tests: TestContainsMarkdown pins the heuristic across plain prose
and ten markdown shapes; TestPatcherRoutesMarkdownReplyToCard and
TestPatcherRoutesPlainReplyToText cover the router; new HTTP wire
test TestHTTPClient_SendMarkdownCard_HappyPath contract-pins the
card envelope (msg_type=interactive, schema 2.0, markdown tag,
verbatim body). Full lark suite passes.

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

* fix(service/issue): route analytics.IssueCreated through obsmetrics.RecordEvent (MUL-2671)

CI's TestNoNakedAnalyticsCaptureInHandlersOrServices guard caught the
post-merge analytics call in IssueService.captureCreatedAnalytics
that still used s.Analytics.Capture(...) directly. Main added that
lint to prevent the Prometheus and PostHog sides from drifting — any
new analytics.* event must go through obsmetrics.RecordEvent so the
business-metrics collector and the PostHog client fire from the same
call site.

Fix mirrors how TaskService handles it: IssueService gains a
Metrics *obsmetrics.BusinessMetrics field (router wires it via
h.IssueService.Metrics = opts.BusinessMetrics next to the existing
TaskService line), and the in-service Capture call becomes
obsmetrics.RecordEvent(s.Analytics, s.Metrics, ...). nil-safe by
construction — RecordEvent treats a nil Metrics as PostHog-only.

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

* feat(views/lark): swap Bind CTA for Connected+Manage link when agent already has an installation (MUL-2671)

Bohan reported the agent-detail Bind button keeps inviting the user to
re-scan the QR even when the agent already has an active Lark
PersonalAgent connected — and re-scanning silently upserts the
installation row, leaving the previously-created Lark bot dangling
as a zombie. Frustrating UX and an actual product footgun.

Anti-zombie guard at the only entry point: LarkAgentBindButton now
checks the cached installations listing for an active row pinned to
this agent_id. When one exists, the install CTA is gone — replaced
by a small Connected pill + an "Manage in Lark" link that opens the
Bot's app page in Lark's developer console (open.feishu.cn/app/<app_id>)
in a new tab. That's where scopes, display name, and additional
permission requests actually live; re-scanning never was the right
answer for managing an existing bot.

Scoping is per-agent: an active installation on a DIFFERENT agent
in the same workspace doesn't affect this agent's button, and a
revoked installation falls back to the bind CTA so the user can
re-create. Tests cover all four states (own-active / own-revoked /
other-agent-active / no-installation) and pin the Manage link's
href + target=_blank + noopener.

i18n: three new keys in settings.json (en / zh-Hans / ja / ko):
agent_bot_connected_label, agent_bot_manage_link,
agent_bot_manage_tooltip. Locale parity test still 157/157.

The dev console host is hardcoded to open.feishu.cn — operators
on the Lark international tenant currently get the wrong host;
future-proof fix wants the backend to surface a per-installation
dev_console_url on the listings response, called out in a code
comment.

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

* feat(views/settings): collapse Lark into Integrations + render agent identity (MUL-2671)

Lark was its own top-level workspace settings tab while Integrations sat
empty next to it. As more integrations land, the sidebar would balloon
with one tab per provider. Move the Lark surface into Integrations as
the first hosted integration; the old ?tab=lark URL redirects through
LEGACY_WORKSPACE_TAB_REDIRECTS so bookmarks still resolve.

The Connected bots list was leaking the raw Lark app_id (cli_…) as the
row title with bot_open_id (ou_…) underneath — meaningless to product
users. Since the binding is 1:1 with a Multica Agent, join on agent_id
and render the agent's avatar + name via the workspace-standard
ActorAvatar + useActorName.getAgentName. Deleted agents fall back to
"Unknown Agent" so the row is still actionable for cleanup.

Tests: stub useActorName + ActorAvatar in lark-tab.test.tsx and add
LarkTab connected-bot tests covering the agent identity render and the
deleted-agent fallback. Drop the now-dead integrations.* + page.tabs.lark
+ lark.bot_open_id_label keys across all four locales — parity still
157/157, views suite 1141/1141.

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

* feat(views/settings): wrap Lark in a named section inside Integrations (MUL-2671)

Integrations is meant to host multiple providers (Slack, Linear etc. as
they land), so the Lark content should sit under a Lark heading rather
than fill the tab directly — otherwise the first additional integration
would feel like it broke the IA. Add a "Lark" / "飞书" section heading
above LarkTab using the same h2 chrome the other settings tabs use, and
pin lark.section_title across all four locales (parity 169/169).

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-03 19:12:14 +08:00
Naiyuan Qing
1544e3b68a feat(skills): built-in agent skills (WIP) MUL-2759 (#3456)
* feat(skills): introduce built-in agent skills (WIP)

Inject platform-authored, version-bundled skills into every agent on top of
its workspace-bound skills, so agents learn how to operate Multica correctly
without users needing to know the internals or agents needing to read source.

Mechanism: skills are embedded into the server binary and appended to the
agent payload at task-claim time (handler/daemon.go), reusing the existing
SkillData wire + daemon-side writeSkillFiles. The daemon needs no changes,
and because it travels over an existing wire field, older daemons pick the
skills up the moment the server ships.

First skill: multica-mentioning — how to build a working @mention (look up
the UUID, match type to id source, know what each mention type triggers).

WIP: injection mechanism + first skill only; more skills to follow in
dependency order (skill -> agent -> squad).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skills): make multica-mentioning the standard template + add eval

Add the contract-skill frontmatter the other built-in skills will copy:
user-invocable:false (it triggers from context, not as a slash command)
and allowed-tools fencing it to the multica CLI it teaches. These keys
survive to agent machines untouched (ensureSkillFrontmatter only ever
adds a missing name).

Add a Go eval in builtin_skills_test.go (a _test.go so it never ships to
agent machines via the skill-files walk):
- Enforces the template invariants on every built-in skill, present and
  future: multica- prefix, name+description present, description within
  1024 chars, body within the 500-line L2 budget, no eval file leaking
  into the shipped payload.
- Couples the mentioning skill's documented contract to the real
  util.ParseMentions: its Incorrect examples must parse to nothing (a
  name where a UUID belongs fails silently) and its Correct example must
  fire. A drift in the mention regex now breaks CI instead of silently
  turning the skill into a lie agents act on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(skills): add working-on-issues built-in skill

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

* feat(skills): verify linked PRs in issue workflow skill

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

* feat(skills): add skill import and discovery built-ins

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

* feat(skills): add skill authoring built-in

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

* docs(skills): align builtin skill workflows

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

* docs(skills): use structured skill search

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

* fix(skills): make built-in skill bundle launch-ready

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

* fix(skills): align built-ins with additive skill binding

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

* feat(skills): add creating agents built-in skill

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

* Add built-in squads skill

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

* refactor(skills): rewrite built-in skills as source-traced contracts

Rewrite the built-in agent skills to the inbuilt-skill-authoring standard:
state source-traced product facts with the source-code link logic as the
core, not prescriptive how-to coaching.

- creating-agents: drop the Decision-flow / Do-don't-consequences
  methodology; replace with field/behavior contracts (validation, persisted
  shape, daemon claim-time consumption, env gating, skill binding).
- skill-discovery: stop teaching repo/github_stars as selection signals —
  searchClawHubSkills never populates them (always null); rank by
  install_count + source/url + description. Add file:line citations.
- mentioning: drop the unbacked "member mention sends a notification" claim
  (no such path in the comment handler); state that only agent/squad
  mentions enqueue work. Tighten the parser-failure wording.
- working-on-issues: refresh citations drifted by the main merge; describe
  the PR response `state` enum accurately; trim status coaching.
- skill-importing: correct response type to SkillWithFilesResponse; document
  the reserved SKILL.md supporting-file rule; add line-accurate citations.
- squads: correct the "leader cannot be archived" overstatement (not
  rejected at create/update; fails closed later at routing/dispatch);
  refresh source-map attributions and test list.

Each skill now ships references/<skill>-source-map.md as its evidence layer
(line-accurate citations live there, not pinned in the test, so a future
main merge cannot rot them into stale lies).

builtin_skills_test.go: replace coaching/line-number pins with drift-resistant
contract anchors, forbid the coaching phrasing, and require every skill to
ship its source-map. The ParseMentions behavior coupling is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs(skills): close field-role and citation gaps found in review

Independent review of the rewritten built-in skills surfaced two real gaps and
some citation drift; this fixes them.

- creating-agents: add the three missing field rows (visibility,
  max_concurrent_tasks, mcp_config) to the field-contract table — mcp_config is
  runtime-consumed (TaskAgentData, daemon.go), visibility is access-control
  (default private), max_concurrent_tasks is a scheduler cap (default 6).
  Mark custom_args/runtime_config JSON validation as CLI-side (the server
  marshals as-is). Correct the CLI body-builder note (description/instructions
  use a non-empty check, the rest use Changed). Source-map: fix the env query
  name (UpdateAgentCustomEnv), the conformance test name, and add the new field
  defaults + the McpConfig runtime-payload line.
- mentioning: the @squad mention private gate is canAccessPrivateAgent, not
  canEnqueueSquadLeader (that wrapper is the assignment/child-done path).
- working-on-issues: cite notifyParentOfChildDone at its func def (:51), not the
  doc comment (:15).
- skill-importing: config.origin is set only when the source supplied an origin
  — note it may be absent; cite createSkillWithFiles at its definition
  (skill_create.go:72), not the call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* Add built-in skills for autopilots runtimes and resources

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

* feat(runtime): list skill descriptions in the brief Skills index

The brief's `## Skills` section emitted bare skill names only, discarding
the one-line description that SkillContextForEnv already carries. For
Claude-family providers the frontmatter description is loaded natively;
for providers without native skill discovery (hermes/default) the brief's
list is the only signal they ever see, so a bare name gave them nothing
to decide when to load a skill. Emit `name — description` when a
description is present, falling back to the bare name when it is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(skills): drop CLI-only rule from working-on-issues

The "Platform data goes through the CLI" section duplicated the runtime
brief's `## Important: Always Use the multica CLI` section verbatim (and
the attachment-via-CLI note duplicated the brief's `## Attachments`). The
CLI-only rule is universal and must be known before any skill loads, so
the brief is its single source of truth; the skill copy was pure
redundancy and a drift risk. Remove it and the matching intro clause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(skills): remove discovery guidance from built-ins

* docs(skills): remove stale skill-necessity records

The per-skill necessity records had drifted to 3 of 8 shipped skills plus a
record for `multica-skill-authoring`, which is not a shipped built-in skill.
Per-skill "why it exists / when to use it" already lives co-located with each
skill (frontmatter `description` + `references/<skill>-source-map.md`) where it
cannot drift from the skill, and the doc's methodology duplicated the
workspace's inbuilt-skill-authoring protocol. Remove the file rather than keep a
parallel listing that every new skill has to remember to update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(runtime): add source-authority escape hatch to the brief

The brief already tells agents to run `--help` for command discovery, but
nothing stated the trust precedence when a skill, the brief, or a doc seems to
contradict actual behavior. Add one line to the Available Commands escape-hatch
note: trust the live CLI (`--help`/`--output json`) and the checked-out source
over source-traced prose that can lag the code, and verify on any conflict or
confusion. Kept in the always-on brief (universal, needed before any skill
loads) rather than duplicated into each skill; per-skill source-map pointers
remain the specific layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): scope the source-authority escape hatch to the CLI

The previous version told agents the "checked-out source is the deeper
authority" for verifying behavior. That over-claims: the repos in a task's
brief come from GetWorkspaceRepos + project github_repo resources (per-workspace
config, see daemon.registerTaskRepos), not the Multica platform source. A
generic agent's checked-out source is its own app, not Multica's code, so it
cannot verify a Multica skill/brief claim against it. The only universally
available authority for Multica behavior is the live CLI (`--help` /
`--output json` / observed command behavior). Re-scope the line accordingly and
state plainly that the platform's source is not in the workdir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* revert(runtime): drop the source-authority escape-hatch line

Reverts the brief addition from fdd5e82df and its follow-up cc67b2088. The
`--help` discovery fallback already in the Available Commands note is enough;
the extra trust-precedence sentence was unnecessary. runtime_config.go is now
identical to 6ca27ad74.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs(claude): remind to update built-in skills on CLI/field/behavior changes

Add a Coding Rule: when a change touches a CLI command/flag, API field, or
product behavior that a built-in skill documents, update that skill's SKILL.md
and source-map in the same PR. Lives in the repo dev-guide (read when working in
this repo), not the runtime brief — the runtime brief is injected into every
workspace, where most agents have no Multica skill to update. AGENTS.md is a
pointer to CLAUDE.md, so no mirror needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 18:51:03 +08:00
LinYushen
9c9afd4a66 feat(metrics): BusinessSamplerCollector for active users / queued / runtime gauges (MUL-2947) (#3706)
* feat(metrics): scrape-time BusinessSamplerCollector for active users / queued / runtime gauges (MUL-2947)

Adds an opt-in prometheus.Collector that runs a fixed set of read-only
SQL queries on every /metrics scrape and exposes the results as gauges:

  - multica_active_users{window=5m|1h|24h}
  - multica_active_workspaces{window=...}
  - multica_agent_task_queued{source}
  - multica_agent_task_running{source,runtime_mode}
  - multica_agent_task_stuck_total{source}
  - multica_runtime_online{runtime_mode,provider}
  - multica_runtime_heartbeat_age_seconds{runtime_mode} (histogram)
  - multica_workspace_total

Plus a self-introspection histogram
multica_business_sampler_query_seconds{name=...} and a counter
multica_business_sampler_query_errors_total{name=...} so the sampler's
own behaviour is observable on /metrics.

Production-safety contract per the PR4 brief:
  - every query runs in its own BEGIN READ ONLY tx with
    SET LOCAL statement_timeout = '500ms' (configurable)
  - the sampler takes a dedicated *pgxpool.Pool option so operators
    can isolate it from business traffic
  - successful results are cached for 5–10s (default 8s) to absorb
    concurrent scrapes from multiple Prometheus replicas
  - every SQL has a hard LIMIT 100 fallback
  - all label values flow through the existing BusinessMetrics
    NormalizeTaskSource / NormalizeRuntimeMode / NormalizeRuntimeProvider
    whitelists, so a misbehaving runtime cannot inflate cardinality
  - sampler is OPT-IN via RegistryOptions.BusinessSampler — existing
    callers that only pass Pool keep their current behaviour and never
    start hitting the DB on /metrics

Tests cover: emit shape, TTL cache (one DB call per N scrapes),
bounded cardinality under malicious labels, opt-out (no leakage), and
DB-hang isolation (unreachable host -> /metrics returns within 5s,
query_errors_total advances).

Refs MUL-2947 (depends on PR2 / MUL-2948, merged in #3695).

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

* fix(metrics): address PR4 review — wire sampler in main.go, fix LIMIT bug, add live-DB statement_timeout test

Three fixes from 大彪's review on #3706:

1. main.go was building NewRegistry without the BusinessSampler option,
   so the collector was effectively dead code in prod. Now constructs a
   dedicated 2-conn pgxpool (newSamplerDBPool) from the same DATABASE_URL
   when METRICS_ADDR is set, plumbs it into RegistryOptions.BusinessSampler,
   and defers Close() at shutdown. A pool-build failure logs and disables
   the sampler instead of taking down the server.

2. queryActiveUsers / queryActiveWorkspaces previously wrapped the
   distinct-user/workspace subquery in a 'LIMIT 100', then COUNT(*)'d
   the result — capping the active-user gauge at 100 regardless of
   reality. Removed the inner LIMIT; the COUNT scalar is one row anyway,
   and metric cardinality is bounded by the fixed samplerWindows
   allow-list, not by the SQL shape.

3. The previous DB-hang test only exercised the acquire-fails path. Added
   business_sampler_pgsleep_test.go which connects to a live Postgres
   (skips cleanly when DATABASE_URL is not set), runs SELECT pg_sleep(2)
   inside a sampler-style tx with SET LOCAL statement_timeout = '500ms',
   and asserts:
     - the call returns in well under 1.5 s (proving the server-side
       cancellation, not just our caller-side context)
     - query_errors_total{name=pg_sleep_canary} advances
     - the duration histogram records the cancellation
   Verified locally: 550 ms, SQLSTATE 57014 'canceling statement due to
   statement timeout' — exactly the safety net the PR claims.

Refs MUL-2947 / PR #3706.

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

* test(metrics): assert SQLSTATE 57014 on pg_sleep cancellation

The previous assertion only checked that the query was cut off in well
under the sleep duration, which a caller-side context cancellation
would also satisfy. Capturing the inner pgconn.PgError and asserting
Code == "57014" ("query_canceled") nails down that Postgres itself
cancelled the statement because of the SET LOCAL statement_timeout —
so a regression that drops the SET LOCAL line fails this test loudly
instead of silently passing on context cancellation.

Refs MUL-2947 / PR #3706 review nit.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 17:50:11 +08:00
Bohan Jiang
a8776095bc docs(self-hosting): document WebSocket Origin allowlist requirement (#3704)
The self-hosting docs covered the WebSocket Upgrade-proxy failure mode
but not the backend's Origin allowlist, which rejects WS upgrades from
non-localhost origins with a 403 (websocket: request origin not allowed
by Upgrader.CheckOrigin) unless CORS_ALLOWED_ORIGINS / FRONTEND_ORIGIN is
set to the external origin. Self-hosters hit this as "chat / live updates
only appear after a manual page refresh" (#3677).

- Note CORS_ALLOWED_ORIGINS governs both HTTP CORS and the WS origin check
- Add the env-var requirement to the single-domain Caddy example
- Add an "allowlist the browser origin" callout to the WebSocket
  troubleshooting section, including the exact backend error string

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 17:44:36 +08:00
Multica Eve
2b4fed9144 docs: add June 3 changelog entry (#3708)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 17:44:08 +08:00
LinYushen
de900b2ba6 feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949) (#3698)
* feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949)

PR3 of the Grafana board metrics split (parent MUL-2328).

Adds 23 new Prometheus counter/histogram families to the PR2 BusinessMetrics
collector covering the activation/community/commercial funnels, and binds
every PostHog event emission to a matching metric increment so the two sides
cannot drift.

Funnel: signup, workspace_created, team_invite_sent/accepted, onboarding_*,
cloud_waitlist_joined.
Content: issue_created, chat_message_sent, agent_created, squad_created,
autopilot_created, issue_executed.
Runtime: runtime_registered/ready/failed/offline + ready_seconds histogram,
daemon_ws_message_received_total.
Autopilot: autopilot_run_started/terminal/skipped.
Webhook/GitHub: webhook_delivery_total, github_event_received_total,
github_pr_review_total, github_pr_merge_seconds histogram.
CloudRuntime: cloudruntime_request_total + duration histogram, wired through
a small RequestRecorder interface so the cloudruntime package stays decoupled
from metrics.
Commercial: feedback_submitted, contact_sales_submitted.

The pairing helper metrics.RecordEvent(client, m, ev) emits the PostHog
event AND increments the matching counter via IncForEvent dispatch, reading
labels from the analytics event Properties. Every existing
h.Analytics.Capture(analytics.X(...)) call site has been migrated to the
helper across handler/, service/, and cmd/server/runtime_sweeper.go.

Lint enforcement (server/internal/metrics/business_pairing_test.go):
- TestEveryAnalyticsEventHasPrometheusCounter: every Event* constant in
  analytics/events.go either dispatches via IncForEvent or is in the
  taskMetricEvents allow-list (PR2 typed RecordTask* methods).
- TestNoNakedAnalyticsCaptureInHandlersOrServices: AST-walks handler/
  service/cmd-server for direct Analytics.Capture(...) calls — only
  service/task.go's captureTaskEvent helper is allow-listed.
- TestEveryAnalyticsRecordEventTakesAnalyticsHelper: validates the third
  arg of every metrics.RecordEvent call is built from analytics.*.

Cardinality protection: all new label values pass through fixed allow-lists
in labels_pr3.go; unknown values collapse to 'other'/'unknown'/'error'.

Refs:
- Spec MUL-2328 / MUL-2949.
- Builds on PR2 (MUL-2948) — collectors registered through the same
  BusinessMetrics struct, no separate Registry.
- Uses PR1's taskfailure.Reason (MUL-2946) for runtime_failed's failure_reason
  label via NormalizeFailureReason.

Out of scope: Sampler-class metrics (PR4 / MUL-2947), pr_review_total
emission point (no review event handler exists yet — counter is defined,
TODO to wire up when /api/webhooks/github grows pull_request_review handling).

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

* fix(server): tighten PR3 review items — signup_source bucket, fill platform/kind/form_source enums, onboarding_started server emission, lint scope (MUL-2949)

Addresses 张大彪's review on #3698:

1. signup_source: NormalizeSignupSource added to labels_pr3.go with a
   fixed allow-list bucket (direct/google/twitter/linkedin/.../other).
   Parses JSON cookie payload for utm_source/source/referrer fields,
   strips URL schemes, maps well-known hostnames to channel buckets.
   PostHog event still ships the raw cookie value for analytics; only
   the Prometheus label is bucketed.

2. Filled the unknown/other label gaps:
   - analytics.IssueCreated and analytics.ChatMessageSent now take a
     platform parameter sourced from middleware.ClientMetadataFromContext
     (X-Client-Platform header) at the handler. Autopilot-originated
     issues stamp PlatformServer.
   - analytics.FeedbackSubmitted now takes a kind parameter; CreateFeedback
     reads req.Kind (default "general") so the picker selection lights up
     the metric's kind label instead of long-term "other".
   - analytics.ContactSalesSubmitted now takes a formSource (page /
     onboarding / agents_page); CreateContactSales reads req.Source.
     The metric reads ev.Properties["form_source"] so the analytics
     CoreProperties.Source ("marketing_contact_sales") stays
     backward-compat for PostHog dashboards.

3. analytics.OnboardingStarted helper added; server-side emission lives
   in PatchOnboarding, fired exactly once per user on the first PATCH
   that carries a non-empty questionnaire payload (firstTouch logic
   compares prior bytes against {} / null). Frontend onboarding_started
   keeps firing on page open; the server emission is what guarantees the
   Prometheus counter exists so Grafana can be cross-checked against the
   PostHog funnel without depending on the SDK roundtrip.

4. business_pairing_test.go tightened:
   - TestNoNakedAnalyticsCaptureInHandlersOrServices now allow-lists at
     function granularity (just captureTaskEvent in service/task.go), not
     whole-file. Any future naked Capture in the same file fails CI.
   - TestEveryAnalyticsRecordEventTakesAnalyticsHelper now does def-use
     tracking inside the enclosing FuncDecl: when RecordEvent's third
     arg is an *ast.Ident, the test walks the function body for the
     assignment that defined it and confirms the RHS is an
     analytics.<Helper>(...) call. Bare local idents that didn't
     originate from analytics are now caught.

5. gofmt -w applied across the touched files; gofmt -l clean.

Tests: go test ./internal/metrics/... ./internal/analytics/... pass.
Pre-existing TestClaimTask_/TestWebhook_MergedPR/TestDeleteIssueByIdentifier
failures on origin/main are DB-environment-dependent and not regressions
from this change.

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

* fix(server): normalise onboarding_started platform label + regression test (MUL-2949)

Addresses 张大彪's last review nit:

- IncForEvent's EventOnboardingStarted case now wraps the platform
  property with NormalizePlatform, matching every other platform-bearing
  metric. A misbehaving frontend can no longer leak a raw X-Client-Platform
  header value into the multica_onboarding_started_total{platform=...}
  series.

- New labels_pr3_test.go covers every PR3 normalizer with both a happy-path
  value and an unknown value, asserting the unknown collapses to the
  documented fallback bucket. Includes a focused regression for
  onboarding_started: emits one event with an attacker-shaped platform
  string and asserts the metric only exposes web + unknown label values
  (no raw header bleed).

- testutil.go gains a small GatherForTest helper so the regression test
  can pull the typed MetricFamily map without re-implementing the
  registry-walk dance.

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

* fix(server): NormalizeTaskSource on workspace_created + document lint limitations (MUL-2949)

Final review touch-ups before merge:

- IncForEvent's EventWorkspaceCreated case wraps source through
  NormalizeTaskSource, matching the other source-bearing dispatches
  (issue_created, agent_created, issue_executed). Closes the last raw
  property leak in the dispatcher table.

- business_pairing_test.go inline docstrings now spell out the two
  known limitations of the lint gate that 张大彪 / Eve flagged:
  analyticsBackedIdents matches by ident NAME (not SSA def-use, so a
  nested-scope shadow could pass) and isMetricsRecordEvent hard-codes
  the import alias set. PR description carries a Follow-ups section
  with the same two items so the work is visible after merge.

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

---------

Co-authored-by: 魏和尚 <agent+wei@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 16:39:06 +08:00
LinYushen
24ea169d89 fix(migrate): serialize startup migrations with pg advisory lock (#3658)
cmd/migrate previously ran a check-then-apply loop on a *pgxpool.Pool
with no locking, so two backend pods starting at the same time (multi-
replica Deployment, scale-up, or a manual run overlapping with pod
startup) could both pass the EXISTS check on a pending migration and
race on the DDL or the schema_migrations INSERT, crashing the loser.

Take a single connection from the pool, hold a session-level
pg_advisory_lock for the entire migration loop, and release it on the
way out. We use the blocking variant so a late arriver queues behind
the current runner and then no-ops on the EXISTS checks instead of
crash-looping. The loop deliberately stays outside a transaction so
existing CREATE INDEX CONCURRENTLY migrations keep working.

Also refresh the values.yaml / backend.yaml comments next to
backend.replicas: the chart still ships replicas: 1 by default, but
that is now a recommendation (Recreate strategy, no leader split), not
a correctness requirement.

Refs https://github.com/multica-ai/multica/issues/3647

Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 15:51:03 +08:00
Bohan Jiang
5900d8b637 fix(issues): make start_date/due_date timezone-stable calendar days (#3618) (#3692)
* fix(issues): store start_date/due_date as DATE, not timestamp (MUL-2925)

These fields are calendar days (the pickers offer no time-of-day), but were
stored as TIMESTAMPTZ. A client serializing local midnight via toISOString()
folded its timezone into the instant, so the day shifted by the local offset
(GH #3618). Migrate the columns to DATE and parse/serialize date-only
"YYYY-MM-DD". ParseCalendarDate still accepts legacy RFC3339 (truncated to the
UTC day) so older clients keep working.

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

* fix(issues): render start_date/due_date as timezone-stable calendar days (MUL-2925)

Pickers now emit date-only "YYYY-MM-DD" (local calendar day) instead of
toISOString(), and every read formats via the shared @multica/core/issues/date
helpers with timeZone:"UTC" so the day never shifts with the viewer's offset.
The Gantt's existing UTC bucketing is now correct. Covers web/desktop pickers,
quick-set menu, list/board/detail/activity, and the mobile due-date picker.

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

* fix(issues): address date-only review — loud-fail ambiguous dates, finish display sweep (MUL-2925)

Review follow-ups on #3692:
- ParseCalendarDate no longer silently truncates a legacy non-midnight RFC3339
  to the wrong UTC day; it accepts only YYYY-MM-DD or an exact UTC-midnight
  instant and rejects ambiguous ones loudly. Adds util unit tests.
- migration 112 pins the TIMESTAMPTZ->DATE conversion to UTC explicitly via
  AT TIME ZONE 'UTC' (was session-timezone dependent); down migration too.
- Convert remaining date-change display sites to formatDateOnly: inbox detail
  label (web) and mobile activity + inbox labels (were new Date()+local format).
- CLI --start-date/--due-date help now says YYYY-MM-DD, not RFC3339.

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-03 14:34:01 +08:00
Multica Eve
a72fb020de Add business metrics collectors (#3695)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 14:32:44 +08:00
Multica Eve
10afd1af1b feat(server): introduce pkg/taskfailure classifier and switch in-flight failure_reason writes (MUL-2946) (#3693)
Lift MUL-1949's offline backfill failure_reason taxonomy into a shared
in-flight classifier so the agent_task_queue.failure_reason column is
written with refined values (provider_auth_or_access, context_overflow,
provider_capacity_or_rate_limit, …) at write time rather than waiting on
SQL backfill to re-classify after the fact. PR1 of the Grafana board
plan in MUL-2328 — the upcoming PR2 reuses pkg/taskfailure.AllReasons()
to pre-warm the Prometheus failure_reason label set.

* server/pkg/taskfailure: new package with the canonical 21 Reason
  constants (7 platform-side + 14 agent_error.* sub-reasons),
  AllReasons() returning a defensive copy, IsAgentError() prefix check,
  and Classify(rawError) Reason mirroring the SQL CASE rules from
  MUL-1949 (db-boy's analysis). 100% statement coverage.
* server/internal/daemon/daemon.go: route the 'agent_error' coarse
  fallback paths (StartTask error, runTask early-return error, CompleteTask
  permanent rejection, reportTaskResult default branch) and the
  executeAndDrain default error case (chained after classifyPoisonedError)
  through taskfailure.Classify so blocked / timeout / unknown-status
  results all carry a refined reason on the wire.
* server/internal/service/task.go: FailTask classifies errMsg when the
  daemon-supplied failureReason is empty, eliminating the legacy
  COALESCE(.., 'agent_error') landing.
* server/internal/daemon/poisoned.go: alias FailureReasonIterationLimit
  and FailureReasonAPIInvalidRequest to the canonical taskfailure
  constants. agent_fallback_message and codex_semantic_inactivity are
  pre-existing operational reasons not in the canonical 21 — kept as
  literals for now and revisited in a follow-up PR.

Backfill SQL from MUL-1949 stays as the authoritative offline source of
truth; this PR keeps the in-flight classifier in lock-step with the SQL
CASE expression so historical and future rows share the same taxonomy.
No behavior change for the platform-side reasons (queued_expired,
runtime_offline, runtime_recovery, timeout, etc.) which already align
with the canonical set.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 13:52:56 +08:00
Naiyuan Qing
f2f17e3355 Optimize chat message loading (#3685)
* Optimize chat message loading

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

* Fix chat history cursor pagination

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

* Fix chat session list remount key

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

* fix(chat): fall back to legacy /messages when paged endpoint 404s

Deployment-order compatibility: a backend deployed before the
/messages/page endpoint existed returns 404 for the unknown route.
The cursorless initial page now falls back to the legacy full-list
/messages endpoint and wraps it in a single has_more:false page, so
chat never white-screens regardless of which side deploys first. A 404
on a cursor request still propagates to avoid duplicating the full list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:47:30 +08:00
Bohan Jiang
fcb5099ec5 fix(agent): raise opencode model-discovery timeout to 15s (MUL-2888) (#3689)
Newer opencode (1.15+) syncs its hosted free-model catalog over the
network on `opencode models`, which can take ~6s. The previous 5s cap
killed the command, discoverOpenCodeModels returned an empty list, and
the daemon reported it as a successful empty result — so the runtime
showed online but the model picker was empty ("暂无可用模型").

Fixes #3627

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 12:18:43 +08:00
Naiyuan Qing
0dd30c544c fix(editor): close suggestion popups on outside focus (#3683)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 10:11:26 +08:00
Jiayuan Zhang
0d51614c9c feat(editor): text highlight (==text==) in description & comments [MUL-2934] (#3661)
* feat(editor): support text highlight (==text==) in description & comments

Adds a single-color (yellow) text highlight mark to the shared rich-text
editor, round-tripped through stored Markdown as ==text==.

- HighlightExtension: @tiptap/extension-highlight + @tiptap/markdown hooks
  (markdownTokenizer/parseMarkdown/renderMarkdown) so ==text== <-> <mark>
  round-trips; inner inline formatting preserved via inlineTokens.
- Bubble menu: highlight toggle button (Mod-Shift-H), i18n in 4 locales.
- Read-only renderer: highlightToHtml lowers ==text== -> <mark> (skips code
  and math); rehype-sanitize schema whitelists <mark>. Nested Markdown inside
  a highlight still parses via the existing rehype-raw step.
- prose.css: single yellow <mark> style, legible in light/dark.

Pinned @tiptap/extension-highlight to exact 3.22.1 to match @tiptap/core
(>=3.23 expects a getStyleProperty export core 3.22.1 doesn't have).

Web/desktop only. Mobile (native md4c, no == syntax, no custom renderers)
is tracked as a follow-up. MUL-2934.

Tests: editor round-trip (cross-process serialization protocol), readonly
<mark> rendering + sanitize, and the ==->mark transform incl. code-skip.

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

* fix(editor): align highlight boundary rules across editor & readonly

Addresses two boundary bugs from review (PR #3661):

1. A == inside inline code/math could close a highlight when the opening
   == was outside the literal span (e.g. ==a `b==c` d== wrongly became
   <mark>a `b</mark>c` d==). Both the editor tokenizer's lazy regex and the
   readonly transform only guarded the opening fence, not the closing one.
2. The readonly transform matched across blank lines (==a\n\nb==) while the
   editor lexes those as two literal paragraphs — a storage↔editor↔readonly
   mismatch.

Fix: extract one shared matcher (utils/highlight-match.ts) used by BOTH the
editor tokenizer and the readonly lowering, so the rules can't drift. It skips
fences that fall inside code/math literal ranges (open or close) and caps the
inner span at the first blank line.

Tests: shared-matcher unit tests + both repros covered on the editor
(round-trip/HTML) and readonly (transform + rendered DOM) sides.

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

* fix(editor): handle CRLF in highlight blank-line boundary

BLANK_LINE_RE only matched LF, so a CRLF blank line (==a\r\n\r\nb==) was not
recognized as a block boundary and got highlighted. Widen to \r?\n[ \t]*\r?\n.

Tests: CRLF blank-line (no highlight) + CRLF soft-break (still highlights) on
the matcher, readonly transform, and editor sides.

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-02 17:24:55 +02:00
Bohan Jiang
44feb3d06d fix(skill): canonicalize reserved SKILL.md path check across daemon + API (#3660)
A skill_file row whose path is the skill's own SKILL.md (persisted by
older builds or direct create/update API calls) collides with the
primary content the daemon writes itself, failing task prep with
errPathPreExists on every non-codex local runtime (#3489).

#3526 guarded this with strings.EqualFold(path, "SKILL.md") at the
daemon write site and the three API ingress points, but the stored path
is not canonicalized: "./SKILL.md" or "sub/../SKILL.md" slip past the
exact-match guard while filepath.Join still resolves them onto the same
SKILL.md, so prep can still break.

Extract one canonical helper, skill.IsReservedContentPath, that cleans
the path before the case-insensitive compare, and use it at all four
sites (execenv writeSkillFiles, skill create, update, single-file
upsert). Add a daemon-side regression test for writeSkillFiles ignoring
a bundled SKILL.md (exact + "./" spellings) — the load-bearing fix
previously had only API-layer coverage — plus a unit test for the helper.

Existing poisoned rows are intentionally left in place (skipped at prep)
per the decision on MUL-2928.

MUL-2928
Follow-up to #3526; supersedes #3560.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 18:23:57 +08:00
Multica Eve
48044cc918 docs: add June 2 changelog entry (#3656)
* docs: add June 2 changelog entry

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

* docs: simplify June changelog feature 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-02 18:04:28 +08:00
MSandro
996eb07dc5 fix(daemon): skip duplicate SKILL.md in supporting files to prevent task prep failures (#3526)
Fixes #3489

MUL-2928
2026-06-02 17:53:20 +08:00
Bohan Jiang
888186b183 fix(daemon): make comment-posting guardrail provider-agnostic (MUL-2904) (#3654)
* fix(daemon): make comment-posting guardrail provider-agnostic (MUL-2904)

Agents inlining a backtick-wrapped token into `multica issue comment add
--content "..."` had the shell run it as a command substitution, silently
deleting the token; the stored comment never matched the model's intent, so
it retried forever — spamming OKK-497 with duplicate comments.

The corruption is shell-driven, not provider-driven, so extend the
"never inline --content; use --content-file / quoted-HEREDOC --content-stdin"
rule from Codex-only to ALL providers:

- BuildCommentReplyInstructions: collapse the Linux/macOS non-Codex inline
  branch into the unified quoted-HEREDOC stdin template.
- buildMetaSkillContent: rename "Codex-Specific Comment Formatting" ->
  "Comment Formatting" and emit it for every provider; strengthen the
  Available Commands entry and the assignment step-6 examples to steer away
  from inline --content.
- Windows behavior unchanged (file-only; avoids PowerShell ASCII drop).

Tests: flip the non-Codex Linux reply test into a MUL-2904 regression,
broaden the stdin-emphasis test across providers, and pin the
provider-agnostic guardrail.

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

* fix(daemon): keep Windows assignment brief file-only (address review)

Review catch on #3654: the previous commit added platform-agnostic prose
recommending "--content-file or --content-stdin" in the Available Commands
entry and the assignment-triggered step-6 example. The assignment path has
no BuildCommentReplyInstructions OS override, so on Windows an agent following
step 6 literally would pipe its final comment through PowerShell and drop
non-ASCII bytes (#2198 / #2236 / #2376) — contradicting this PR's own
Windows file-only rule in the ## Comment Formatting section.

Make the platform-agnostic surfaces defer to the OS-aware ## Comment
Formatting section (the single source of truth) instead of naming stdin.
The flag synopsis still lists all three modes.

Add TestInjectRuntimeConfigWindowsAssignmentBriefStaysFileOnly: a Windows
assignment-triggered brief must not contain any prescriptive "... or
--content-stdin" recommendation.

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-02 17:47:09 +08:00
Anderson Shindy Oki
92309cf5e2 fix: autopilot page and modal mobile responsive (MUL-2929) (#3471)
* fix: autopilot page and modal mobile responsive

* fix(autopilots): label icon-only action buttons and keep desktop padding

- Add aria-label to Edit/Run now buttons so they have an accessible
  name on mobile where the text label is hidden via 'hidden sm:inline'.
- Change button padding 'px-2 sm:px-3' -> 'px-2 sm:px-2.5' so the
  size="sm" default (px-2.5) is preserved on desktop (no visual diff).

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

---------

Co-authored-by: J (Multica Agent) <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 17:32:12 +08:00
Naiyuan Qing
e1a5310780 feat(cli): add skill content file and stdin input (#3652)
* feat(cli): add skill content file and stdin input

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

* test(cli): set skill server env for flag validation

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 17:25:37 +08:00
LinYushen
a6247ad714 helm: gate uploads PVC behind backend.uploads.persistence.enabled (#3655)
Adds a value (default true for backward compatibility) that gates the
uploads PersistentVolumeClaim, the backend container's volumeMount, and
the pod-spec volume. Operators who serve uploads from S3 (S3_BUCKET set)
can now set backend.uploads.persistence.enabled=false to drop the PVC
entirely, removing the ReadWriteOnce Multi-Attach barrier on the storage
side for replicas > 1.

Also makes the PVC accessModes configurable (default [ReadWriteOnce]) so
operators with a ReadWriteMany-capable StorageClass can share the
uploads volume across replicas without object storage.

Documentation: values.yaml comments and the SELF_HOSTING.md resource
list are updated to describe the new toggle.

Refs: https://github.com/multica-ai/multica/issues/3646

Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 17:21:26 +08:00
863 changed files with 102985 additions and 9653 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
@@ -95,12 +100,16 @@ RESEND_FROM_EMAIL=noreply@multica.ai
# Required by providers that only offer port 465 and do not advertise
# STARTTLS (e.g. Aliyun enterprise mail). Auto-enabled when SMTP_PORT=465
# and SMTP_TLS is unset.
# SMTP_EHLO_NAME is the EHLO/HELO name announced to the relay. Defaults to the
# machine hostname; set a real FQDN when a strict relay (e.g. Google Workspace
# smtp-relay.gmail.com) rejects the default and the connection drops as an EOF.
SMTP_HOST=
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
SMTP_TLS=
SMTP_EHLO_NAME=
# Google OAuth
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
@@ -108,9 +117,9 @@ SMTP_TLS=
# 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
@@ -118,6 +127,15 @@ 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
# not need direct access to the object store.
AWS_ENDPOINT_URL=
ATTACHMENT_DOWNLOAD_MODE=auto
ATTACHMENT_DOWNLOAD_URL_TTL=30m
CLOUDFRONT_KEY_PAIR_ID=
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
CLOUDFRONT_PRIVATE_KEY=
@@ -188,12 +206,35 @@ CORS_ALLOWED_ORIGINS=
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
GITHUB_APP_SLUG=
GITHUB_WEBHOOK_SECRET=
# Optional: GitHub App identity for App-authenticated REST calls. When set,
# the setup callback enriches the installation row with the real account
# login (org / user name) immediately after install. When unset, the row
# is created with the "unknown" placeholder and the next `installation`
# webhook from GitHub overwrites it — set both to skip that interim flash.
# GITHUB_APP_ID is the numeric "App ID" shown on the App's settings page.
# GITHUB_APP_PRIVATE_KEY is the full PEM block (including BEGIN/END lines)
# generated under "Private keys" on that same page; preserve newlines.
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Lark / Feishu bot integration (Settings → Integrations "Bind to Lark")
# Off until MULTICA_LARK_SECRET_KEY is set — a base64-encoded 32-byte key
# that encrypts each Bot's app secret at rest. Leave empty to disable.
# Generate one with: openssl rand -base64 32
MULTICA_LARK_SECRET_KEY=
# Mainland 飞书 and international Lark are auto-detected per installation
# (at QR scan) and served side by side — LEAVE THESE EMPTY for normal use.
# They are optional deployment-wide overrides that force EVERY installation
# onto one host (a proxy, a mock for tests, or a single-cloud staging
# setup); HTTP drives outbound Open Platform API calls, CALLBACK the inbound
# long-conn bootstrap. NOTE: if you previously ran international Lark by
# setting these to https://open.larksuite.com, the server relabels your
# existing installs to region=lark on first boot after upgrade, so you can
# clear these afterwards. See docs/lark-bot-integration.
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

@@ -13,8 +13,9 @@ name: Mobile Verify
# - pnpm-workspace.yaml — catalog versions
# - turbo.json — turbo task pipeline
#
# Mobile has no vitest suite today; if one lands, add `test` to the turbo
# task list below.
# Mobile's vitest suite is intentionally narrow (Node env, pure-function
# tests under apps/mobile/lib/*.test.ts — see apps/mobile/vitest.config.ts).
# RN component-level rendering is not exercised here.
on:
push:
@@ -61,5 +62,5 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Type check and lint
run: pnpm exec turbo typecheck lint --filter=@multica/mobile
- name: Type check, lint, and test
run: pnpm exec turbo typecheck lint test --filter=@multica/mobile

View File

@@ -176,6 +176,7 @@ make start-worktree # Start using .env.worktree
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
- When you change a CLI command or flag, an API request/response field, or product behavior that a built-in skill documents (`server/internal/service/builtin_skills/*`), update that skill's `SKILL.md` **and** its `references/*-source-map.md` in the same PR. The built-in skills are source-traced contracts shipped to agents — if the code moves and the skill doesn't, it silently teaches stale behavior.
### API Response Compatibility

View File

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

View File

@@ -15,8 +15,9 @@ COPY server/ ./server/
# Build binaries
ARG VERSION=dev
ARG COMMIT=unknown
ARG DATE=unknown
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly

View File

@@ -58,12 +58,17 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
PGPASS=$$(openssl rand -hex 24); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
fi; \
echo "==> Generated random JWT_SECRET"; \
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
@@ -108,12 +113,17 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
PGPASS=$$(openssl rand -hex 24); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
fi; \
echo "==> Generated random JWT_SECRET"; \
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@echo "==> Building Multica from the current checkout..."
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build

View File

@@ -144,7 +144,7 @@ If you already run a Kubernetes cluster, you can deploy Multica there instead of
The chart creates the following resources in the target namespace:
- `multica-postgres``pgvector/pgvector:pg17` backed by a 10Gi PVC
- `multica-backend` — Go API/WS server backed by a 5Gi uploads PVC
- `multica-backend` — Go API/WS server. Backed by a 5Gi `ReadWriteOnce` uploads PVC by default; set `backend.uploads.persistence.enabled=false` when you have configured S3 (`backend.config.s3Bucket`) and don't want the chart to declare the PVC at all.
- `multica-frontend` — Next.js standalone server
- Two `Ingress` resources: one for the web host, one for the backend host
- `multica-config` ConfigMap (rendered from `values.yaml`)
@@ -326,7 +326,7 @@ To roll back if an upgrade goes sideways:
helm -n multica rollback multica
```
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** Same migration guard as the Docker path — see [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule). Run the backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations.
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** As of MUL-2957 the `migrate up` command runs an idempotent monthly-slice backfill automatically before applying migration `103`, so a clean upgrade is a single `helm upgrade` + backend rollout. If you are still on a pre-MUL-2957 binary or the auto-hook fails, run the standalone backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations. See [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup) for the full recovery flow.
### Tearing down
@@ -340,56 +340,52 @@ kubectl delete namespace multica
---
## Usage Dashboard Rollup (Required)
## Usage Dashboard Rollup
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table rather than directly from `task_usage`. Raw `task_usage` rows are written by the backend on every task, but the dashboard only sees data after `rollup_task_usage_hourly()` runs and aggregates them into `task_usage_hourly`.
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. As of MUL-2957 the backend runs this rollup **in-process** on every replica via a DB-backed scheduler (`sys_cron_executions`); a fresh self-host install needs no operator action and the bundled `pgvector/pgvector:pg17` image works without changes — you do **not** need to swap it for an image that ships `pg_cron`, register an external cron job, set up a systemd timer, or run a Kubernetes `CronJob`.
**The bundled `pgvector/pgvector:pg17` image does NOT include `pg_cron`.** If nothing schedules the rollup, the dashboard will stay at zero forever even though `task_usage` is populated. You have three supported options — pick one before relying on the dashboard.
> **Upgrading from `v0.3.4` to `v0.3.5+`** with existing `task_usage` history: migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: …`. Run `backfill_task_usage_hourly` first (Option C below), then re-run the upgrade. **Fresh installs** are exempted by that guard and migrate cleanly — but the dashboard will still stay at zero until you pick Option A or Option B.
### Option A — External cron / systemd-timer (simplest)
Schedule a 5-minute job that calls `rollup_task_usage_hourly()`. It is idempotent and watermark-driven, so a missed tick catches up on the next run.
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Or as a systemd timer + service if you prefer that surface. The function returns the number of (upserted + deleted-empty) rows; it's safe to call concurrently with itself (an advisory lock makes overlapping runs no-op) and safe to call alongside `backfill_task_usage_hourly`.
### Option B — Swap Postgres for an image that ships `pg_cron`
If you'd rather have Postgres schedule itself, replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that bundles both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or your own build of `pgvector/pgvector` with `pg_cron` added and `shared_preload_libraries=pg_cron` set on the server). Then, once:
Multiple backend replicas are safe: each replica ticks every 30 seconds and tries to claim the current 5-minute UTC plan, but the unique key `(job_name, scope_kind, scope_id, plan_time)` means only one wins each plan. Inspect steady-state operation:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;
```
`shared_preload_libraries` requires a Postgres restart to take effect — set it in `postgresql.conf` (or via the image's documented mechanism) before bringing the container up.
Full reference (audit table semantics, advisory lock 4246, the standalone backfill command, flag descriptions, the `v0.3.4 → v0.3.5+` migration auto-hook) lives in [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
### Option C — Backfill history first, then schedule
> **Upgrading from `v0.3.4` to `v0.3.5+`?** As of MUL-2957 the `migrate up` command runs an idempotent monthly-slice backfill automatically right before applying migration `103`, so the upgrade completes in a single invocation — no operator step required. If you are still on a pre-MUL-2957 binary or the auto-hook fails for an environmental reason, run `backfill_task_usage_hourly` against the same database and re-run the upgrade. See [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup) for the recovery flow.
If you're upgrading from `v0.3.4 → v0.3.5+` and already have `task_usage` rows (or you just want the dashboard to show historical data on a fresh install that you've been running for a while), run the bundled backfill command once before scheduling the rollup:
### Compatibility paths (existing deployments only)
```bash
# Backfills task_usage_hourly from all historical task_usage rows and stamps
# the rollup watermark. Idempotent — safe to re-run.
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
External schedulers — **`pg_cron` registered on the database, an external cron job, a systemd timer, or a Kubernetes `CronJob`** — that call `SELECT rollup_task_usage_hourly()` directly were the only option before MUL-2957 and remain a supported compatibility path. They are no longer the recommended setup; new deployments should rely on the in-process scheduler instead. The SQL function holds advisory lock 4246 internally, so the in-process scheduler and any pre-existing external schedule can coexist without ever double-writing the rollup.
On a database with years of data this can scan tens of millions of rows; `--sleep-between-slices=2s` throttles the read pressure. Use `--months-back N` (plus `--force-partial`) if you only want the last N months. Once it finishes, set up Option A or Option B so new buckets keep flowing.
If you already have a `pg_cron` job in production, the safe sequence to retire it is:
After upgrading, re-run `migrate up` (or restart the backend container — migrations run automatically on startup) to apply migration `103` cleanly.
1. Confirm the in-process scheduler is healthy on at least one backend replica — recent SUCCESS rows should be landing in `sys_cron_executions` for `rollup_task_usage_hourly`:
```sql
SELECT plan_time, status, runner_id, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
AND status = 'SUCCESS'
ORDER BY plan_time DESC
LIMIT 5;
```
2. Once SUCCESS rows are arriving on schedule, unschedule the redundant `pg_cron` entry:
```sql
SELECT cron.unschedule('rollup_task_usage_hourly')
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
```
3. Leave the `pg_cron` extension itself installed unless you are sure no other workload depends on it. The bundled `pgvector/pgvector:pg17` image does **not** ship `pg_cron`, so nothing in Multica's default install needs it; uninstalling `pg_cron` from a custom image that other workloads still use is a separate decision.
External cron / systemd timer / Kubernetes `CronJob` setups that call `SELECT rollup_task_usage_hourly()` directly can be retired the same way — once `sys_cron_executions` shows steady SUCCESS rows from the in-process scheduler, the external job is redundant and can be removed.
## Stopping Services
@@ -431,7 +427,7 @@ docker compose -f docker-compose.selfhost.yml up -d
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. Run `backfill_task_usage_hourly` first, then re-run the upgrade. Full instructions in [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule).
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. As of MUL-2957 `migrate up` runs that backfill automatically right before applying `103`, so the upgrade completes in a single invocation. If you are still on a pre-MUL-2957 binary or the auto-hook fails, run `backfill_task_usage_hourly` manually first, then re-run the upgrade. Full instructions in [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
---

View File

@@ -46,6 +46,7 @@ Use this option when your deployment cannot reach the public internet or you alr
| `SMTP_PASSWORD` | SMTP password | - |
| `SMTP_TLS` | TLS mode. `implicit` (aliases `smtps`, `ssl`) forces SMTPS on connect; port `465` auto-enables it. Unset / `starttls` upgrades via STARTTLS | `starttls` |
| `SMTP_TLS_INSECURE` | Set `true` to skip TLS certificate verification (self-signed / private CA certs) | `false` |
| `SMTP_EHLO_NAME` | EHLO/HELO name announced to the relay. Set a real FQDN when a strict relay (e.g. Google Workspace) rejects the default greeting from a public IP | machine hostname |
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is supported and auto-enables implicit TLS; set `SMTP_TLS=implicit` (aliases `smtps`, `ssl`) to force it on a non-standard port.
@@ -93,6 +94,8 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `ATTACHMENT_DOWNLOAD_MODE` | Attachment download behavior: `auto` (default), `cloudfront`, `presign`, or `proxy`. Use `proxy` for private buckets behind Docker/VPC-only endpoints such as `http://rustfs:9000` |
| `ATTACHMENT_DOWNLOAD_URL_TTL` | TTL for CloudFront signed URLs and S3 presigned download URLs (default: `30m`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
@@ -112,7 +115,7 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
| `PORT` | `8080` | Backend server port |
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins. Governs **both** the HTTP CORS allowlist **and** the WebSocket `Origin` check. A browser origin that isn't listed here (and isn't `localhost`) has its real-time WebSocket upgrade rejected with `403`, so live updates stop working until a manual refresh. |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
@@ -181,74 +184,35 @@ cd server && go run ./cmd/migrate up
## Usage Dashboard Rollup
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. The function is **not** scheduled out of the box on the default self-host stack: the bundled `pgvector/pgvector:pg17` image ships without `pg_cron`, and the backend does not run the rollup in-process either. Until something calls it on a schedule, raw `task_usage` rows will keep arriving while the dashboard stays at zero.
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. As of MUL-2957 the backend runs this rollup **in-process** on every replica via a DB-backed scheduler (`sys_cron_executions`); a fresh self-host install needs no operator action — the bundled `pgvector/pgvector:pg17` image works without changes.
Pick one of the supported paths:
### How the in-process scheduler works
### Option A — External cron / systemd-timer
The simplest path. Schedule `SELECT rollup_task_usage_hourly()` every five minutes from any out-of-band timer (host crontab, systemd timer, sidecar container, Kubernetes CronJob). It is idempotent and watermark-driven — overlapping runs are no-ops on an internal advisory lock, and a missed tick catches up on the next run.
Docker Compose:
```bash
# /etc/cron.d/multica-rollup
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Kubernetes (one-off `CronJob`):
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: multica-usage-rollup
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: psql
image: postgres:17-alpine
command:
- psql
- "$(DATABASE_URL)"
- -c
- "SELECT rollup_task_usage_hourly();"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: multica-secrets
key: DATABASE_URL
```
### Option B — Postgres with `pg_cron`
If you'd rather have Postgres schedule itself, swap the bundled image for one that ships both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or a custom build of `pgvector/pgvector` with `pg_cron` added). `pg_cron` requires `shared_preload_libraries=pg_cron` in `postgresql.conf`, which only takes effect on Postgres restart — set it before bringing the container up.
Then register the job once:
Every backend replica ticks every 30 seconds and tries to claim the current 5-minute UTC plan in `sys_cron_executions`. The unique key `(job_name, scope_kind, scope_id, plan_time)` makes the claim a single-winner contest across all replicas, so multi-instance deployments do not double-write. The handler then calls `SELECT rollup_task_usage_hourly()`; the SQL function holds advisory lock `4246` internally, so a stray `pg_cron` job or manual call can run alongside the scheduler without ever colliding on the rollup itself. Inspect the audit table for steady-state operation:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;
```
`pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, point `pg_cron` at it via that GUC or run `cron.schedule_in_database(...)` instead.
### Compatibility — existing `pg_cron` registrations
### Option C — Backfill historical data first
If you previously registered the rollup as a `pg_cron` job (`SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', …)`), it is safe to leave it in place: advisory lock 4246 prevents double-writes, and the loser path no-ops cleanly. To drop the redundant entry once the in-process scheduler is up:
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was scheduled — most commonly when upgrading from `v0.3.4` to `v0.3.5+`, or on a fresh install that has been collecting usage for a while — run `backfill_task_usage_hourly` once to seed historical buckets, then set up Option A or Option B for ongoing rollups.
```sql
SELECT cron.unschedule('rollup_task_usage_hourly')
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
```
External cron / systemd / Kubernetes `CronJob` setups that call `SELECT rollup_task_usage_hourly()` directly are also still valid — they were the only option before MUL-2957 and remain a supported compatibility path. They are no longer the recommended setup; new deployments should rely on the in-process scheduler.
### Standalone backfill command
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was claimed for the first time — most commonly when upgrading from `v0.3.4` to `v0.3.5+` on a database that already has months of usage — you can run `backfill_task_usage_hourly` to seed historical buckets:
```bash
# Docker Compose
@@ -260,7 +224,7 @@ kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the cron path uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with an already-scheduled rollup. Flags:
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the in-process scheduler uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with the scheduler (advisory lock 4246 serialises them). Flags:
| Flag | Description |
|---|---|
@@ -272,17 +236,9 @@ After backfill completes, the rollup-state watermark is stamped to `now() - 5 mi
### `v0.3.4 → v0.3.5+` upgrade order
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. If you run `migrate up` straight through on a database with existing `task_usage` rows, it aborts with:
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. As of MUL-2957 the migrate command runs an idempotent monthly-slice backfill (under advisory lock 4246) **automatically** immediately before applying migration `103`, so v0.3.4 → v0.3.5+ upgrades complete in a single `migrate up` invocation — no operator step is required.
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
Recovery is straightforward: run `backfill_task_usage_hourly` (Option C above), then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and migrations succeed, but the dashboard will still stay at zero until you set up Option A or Option B.
If you are upgrading from a binary that pre-dates MUL-2957 (or the auto-hook fails for an environmental reason), recovery is the manual path: run `backfill_task_usage_hourly` against the database, then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and the in-process scheduler picks up new buckets from the first tick.
## Manual Setup (Without Docker Compose)
@@ -337,6 +293,8 @@ multica.example.com {
}
```
> Even on a single domain, set `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS` to that public origin (e.g. `https://multica.example.com`) on the backend. The backend's default origin allowlist is `localhost` only, so without this it rejects the WebSocket upgrade from the public URL with `403` and real-time updates silently stop working. See [LAN / Non-localhost Access](#lan--non-localhost-access).
**Separate-domain layout** — frontend and backend on different hostnames:
```
@@ -456,6 +414,8 @@ HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
**Also required: allowlist the browser origin.** The two options above fix the WebSocket *upgrade proxying*, but a second, independent setting gates the connection: the backend validates the WebSocket `Origin` header against an allowlist that defaults to `localhost` only. When you open Multica from any other origin — a LAN IP **or a public domain behind a reverse proxy** — set `CORS_ALLOWED_ORIGINS` (or `FRONTEND_ORIGIN`) on the backend to that exact origin and restart, exactly as shown under [LAN / Non-localhost Access](#lan--non-localhost-access) above. Otherwise the upgrade is refused with `403`: the backend logs `websocket: request origin not allowed by Upgrader.CheckOrigin` and the browser console loops `disconnected, reconnecting in 3s`, while HTTP requests (and manual page refreshes) keep working because they are same-origin to the page. The single value covers both HTTP CORS and the WebSocket origin check.
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
## Health Check

View File

@@ -0,0 +1,221 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// Capture every MenuItem the SUT constructs so each test can assert
// on the menu that would appear at popup time without booting an
// actual Electron window. State is created via `vi.hoisted` because
// `vi.mock` factories are hoisted above all top-level statements; a
// plain `const` would be a TDZ ReferenceError when the factory runs.
type CapturedMenuItem = {
label?: string;
role?: string;
type?: string;
click?: () => void;
};
const ctx = vi.hoisted(() => ({
capturedItems: [] as CapturedMenuItem[][],
browserWindowFromWebContents: vi.fn(),
popupSpy: vi.fn(),
clipboardWriteText: vi.fn(),
openExternalSpy: vi.fn().mockResolvedValue(undefined),
preferredLanguagesRef: { current: ["en-US"] as string[] },
}));
vi.mock("electron", () => {
class MockMenu {
items: CapturedMenuItem[] = [];
constructor() {
ctx.capturedItems.push(this.items);
}
append(item: CapturedMenuItem) {
this.items.push(item);
}
popup(opts: unknown) {
ctx.popupSpy(opts);
}
}
class MockMenuItem {
label?: string;
role?: string;
type?: string;
click?: () => void;
constructor(opts: CapturedMenuItem) {
Object.assign(this, opts);
}
}
return {
BrowserWindow: { fromWebContents: ctx.browserWindowFromWebContents },
Menu: MockMenu,
MenuItem: MockMenuItem,
app: {
getPreferredSystemLanguages: () => ctx.preferredLanguagesRef.current,
},
clipboard: { writeText: ctx.clipboardWriteText },
shell: { openExternal: ctx.openExternalSpy },
};
});
import { installContextMenu } from "./context-menu";
type ContextMenuParams = {
selectionText: string;
isEditable: boolean;
linkURL: string;
editFlags: {
canCut: boolean;
canCopy: boolean;
canPaste: boolean;
canSelectAll: boolean;
};
};
type Listener = (event: unknown, params: ContextMenuParams) => void;
// Tiny WebContents stub — we only need the `.on("context-menu", ...)`
// hook the SUT installs and a way to fire it back at our own listener
// list. Everything else (clipboard, link opening, label resolution) is
// mocked above.
function makeWebContents() {
const handlers: Listener[] = [];
return {
on(event: string, fn: Listener) {
if (event === "context-menu") handlers.push(fn);
},
fire(params: ContextMenuParams) {
for (const h of handlers) h({}, params);
},
};
}
const baseEditFlags = {
canCut: false,
canCopy: false,
canPaste: false,
canSelectAll: false,
};
describe("installContextMenu — link items", () => {
beforeEach(() => {
ctx.capturedItems.length = 0;
ctx.popupSpy.mockClear();
ctx.clipboardWriteText.mockClear();
ctx.openExternalSpy.mockClear();
ctx.browserWindowFromWebContents.mockReset();
ctx.preferredLanguagesRef.current = ["en-US"];
});
it("adds 'Open Link in Browser' and 'Copy Link Address' when right-clicking an http(s) link", () => {
// The link case is the one this test file is here to cover —
// before MUL-3083 follow-up, right-clicking an <a> in the
// renderer only surfaced 'copy' (when the user happened to have
// text selected) and gave no way to open the URL externally.
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire({
...baseSelection({ linkURL: "https://multica.ai/welcome" }),
});
const labels = lastMenuLabels();
expect(labels).toContain("Open Link in Browser");
expect(labels).toContain("Copy Link Address");
// The two click handlers must route to the existing
// openExternalSafely allowlist + clipboard.writeText.
invokeByLabel("Open Link in Browser");
expect(ctx.openExternalSpy).toHaveBeenCalledWith("https://multica.ai/welcome");
invokeByLabel("Copy Link Address");
expect(ctx.clipboardWriteText).toHaveBeenCalledWith(
"https://multica.ai/welcome",
);
expect(ctx.popupSpy).toHaveBeenCalledTimes(1);
});
it("does NOT add link items when the cursor is over a non-http(s) URL", () => {
// Only http(s) links are surfaced — we don't promise anything for
// mailto:, javascript:, custom app schemes, etc. Surfacing them
// would shell out via openExternalSafely (which would block the
// call anyway) or write a non-URL string to the clipboard, both
// of which violate user expectations for a "link" item.
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire(baseSelection({ linkURL: "javascript:alert(1)" }));
const labels = lastMenuLabelsOrEmpty();
expect(labels).not.toContain("Open Link in Browser");
expect(labels).not.toContain("Copy Link Address");
});
it("does NOT add link items when there is no link under the cursor", () => {
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire({
selectionText: "hello",
isEditable: false,
linkURL: "",
editFlags: { ...baseEditFlags, canCopy: true },
});
const labels = lastMenuLabelsOrEmpty();
expect(labels).not.toContain("Open Link in Browser");
// Selection-only context still surfaces copy as before — guards
// against a regression where adding the link branch broke the
// base path.
expect(menuItemRoles()).toContain("copy");
});
it("uses zh-Hans labels when the OS preferred language is Chinese", () => {
// Locale fallback is intentionally permissive: every zh-* variant
// routes to zh-Hans so users on zh-CN / zh-TW / zh-HK still see
// Chinese rather than dropping to English. The renderer ships only
// zh-Hans translations, so this matches the rest of the app.
ctx.preferredLanguagesRef.current = ["zh-CN"];
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
expect(lastMenuLabels()).toContain("在浏览器中打开链接");
expect(lastMenuLabels()).toContain("复制链接地址");
});
it("falls back to English when the OS preferred language is something we don't ship", () => {
ctx.preferredLanguagesRef.current = ["fr-FR"];
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
expect(lastMenuLabels()).toContain("Open Link in Browser");
});
});
// --- helpers ---
function baseSelection(over: Partial<ContextMenuParams>): ContextMenuParams {
return {
selectionText: "",
isEditable: false,
linkURL: "",
editFlags: { ...baseEditFlags },
...over,
};
}
function lastMenu(): CapturedMenuItem[] {
const last = ctx.capturedItems[ctx.capturedItems.length - 1];
if (!last) throw new Error("no menu was constructed");
return last;
}
function lastMenuLabelsOrEmpty(): string[] {
const last = ctx.capturedItems[ctx.capturedItems.length - 1] ?? [];
return last.map((i) => i.label ?? "");
}
function lastMenuLabels(): string[] {
return lastMenu().map((i) => i.label ?? "");
}
function menuItemRoles(): string[] {
return lastMenu().map((i) => i.role ?? "");
}
function invokeByLabel(label: string): void {
const item = lastMenu().find((i) => i.label === label);
if (!item) throw new Error(`menu item not found: ${label}`);
item.click?.();
}

View File

@@ -1,12 +1,38 @@
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
import {
BrowserWindow,
Menu,
MenuItem,
app,
clipboard,
type WebContents,
} from "electron";
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
// Electron ships with no default right-click menu, so a user selecting text
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
// menu using `roles`, which keeps i18n + accelerator handling native.
//
// Custom (non-role) link items below are NOT auto-localized by Electron —
// roles like "copy" pull labels from the OS, but a custom MenuItem only
// shows the `label` you give it. We translate by OS-preferred language so
// the link items at least track Chinese / Japanese / Korean speakers
// alongside the English default; everything else falls through to English,
// which matches Chrome's behavior on those locales without app-level
// translation files.
export function installContextMenu(webContents: WebContents): void {
webContents.on("context-menu", (_event, params) => {
const { editFlags, selectionText, isEditable } = params;
const { editFlags, selectionText, isEditable, linkURL } = params;
const hasSelection = selectionText.trim().length > 0;
// params.linkURL is the resolved absolute URL of the anchor under the
// cursor; Electron normalizes relative hrefs against the page URL for
// us, so we only need to gate on the http(s) scheme allowlist
// (mirrors openExternalSafely + the renderer's <a> usage). Empty for
// non-link right-clicks; other schemes (mailto:, javascript:, custom
// app schemes) are intentionally not surfaced — opening them via
// shell.openExternal would route through the OS handler and is
// outside what this menu promises.
const linkIsHttpUrl = !!linkURL && isSafeExternalHttpUrl(linkURL);
const labels = pickLabels();
const menu = new Menu();
@@ -26,8 +52,87 @@ export function installContextMenu(webContents: WebContents): void {
menu.append(new MenuItem({ role: "selectAll" }));
}
// Link items — only when the cursor is over an actual http(s) <a>.
// Without these the renderer's <a target="_blank"> gives users no
// standard right-click affordance ("Open in new window", "Copy link
// address"); the default click handler does forward to
// setWindowOpenHandler → openExternalSafely, but discoverability via
// the keyboard / mouse context menu was missing.
if (linkIsHttpUrl) {
if (menu.items.length > 0) {
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(
new MenuItem({
label: labels.openLink,
click: () => {
// openExternalSafely re-validates the scheme — defense in
// depth in case Electron ever surfaces a non-http linkURL
// we forgot to filter at this layer.
void openExternalSafely(linkURL);
},
}),
);
menu.append(
new MenuItem({
label: labels.copyLinkAddress,
click: () => {
clipboard.writeText(linkURL);
},
}),
);
}
if (menu.items.length === 0) return;
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
menu.popup({ window });
});
}
// Labels for the two link-related menu items in the user's OS-preferred
// language, with English as the fallback. Kept inline because the main
// process has no shared i18n loader (the renderer's i18next is per-window
// and not reachable from here), and pulling one in for two strings would
// be more rope than payload. Matches the four locales the renderer ships.
type ContextMenuLabels = {
openLink: string;
copyLinkAddress: string;
};
const labelsByLocale: Record<string, ContextMenuLabels> = {
en: {
openLink: "Open Link in Browser",
copyLinkAddress: "Copy Link Address",
},
"zh-Hans": {
openLink: "在浏览器中打开链接",
copyLinkAddress: "复制链接地址",
},
ja: {
openLink: "ブラウザでリンクを開く",
copyLinkAddress: "リンクのアドレスをコピー",
},
ko: {
openLink: "브라우저에서 링크 열기",
copyLinkAddress: "링크 주소 복사",
},
};
// pickLabels resolves the OS-preferred language to one of the four
// locales we ship copy for. We say "Open Link in Browser" rather than
// "Open Link in New Window" because the link is opened via
// shell.openExternal — it lands in the user's default browser, not in
// another Multica window — so the wording matches what actually
// happens.
function pickLabels(): ContextMenuLabels {
const preferred = app.getPreferredSystemLanguages()[0]?.toLowerCase() ?? "";
if (preferred.startsWith("zh")) {
// All Chinese variants get the Simplified copy — Multica only
// ships zh-Hans, and zh-Hant users falling through to en would be
// worse than reading Simplified Chinese.
return labelsByLocale["zh-Hans"];
}
if (preferred.startsWith("ja")) return labelsByLocale.ja;
if (preferred.startsWith("ko")) return labelsByLocale.ko;
return labelsByLocale.en;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { classifyAuthProbe, isAuthStatusError } from "./daemon-auth-probe";
describe("classifyAuthProbe", () => {
it("treats a 401 as expired login", () => {
expect(classifyAuthProbe({ status: 401 })).toBe("auth_expired");
});
it("treats a missing token as expired login", () => {
expect(classifyAuthProbe({ noToken: true })).toBe("auth_expired");
});
it("treats a 2xx as a valid token (failure is non-auth)", () => {
expect(classifyAuthProbe({ status: 200 })).toBe("ok");
expect(classifyAuthProbe({ status: 204 })).toBe("ok");
});
// The headline guard: a network failure must never be reported as an auth
// problem — the daemon is just as unreachable for non-auth reasons.
it("does NOT classify a network error as expired login", () => {
expect(classifyAuthProbe({ networkError: true })).toBe("unknown");
});
it("leaves 5xx and other statuses inconclusive", () => {
expect(classifyAuthProbe({ status: 500 })).toBe("unknown");
expect(classifyAuthProbe({ status: 503 })).toBe("unknown");
expect(classifyAuthProbe({ status: 403 })).toBe("unknown");
});
it("is inconclusive when nothing is known", () => {
expect(classifyAuthProbe({})).toBe("unknown");
});
});
describe("isAuthStatusError", () => {
it("is true only for a 401-tagged error (session token is dead)", () => {
expect(isAuthStatusError(Object.assign(new Error("x"), { status: 401 }))).toBe(
true,
);
});
// The reviewer's must-fix: transient failures must NOT be treated as auth
// failures (which would log the user out). A 5xx mint, a thrown fetch, a
// file-write error — none carry status 401.
it("is false for transient / non-401 failures", () => {
expect(isAuthStatusError(Object.assign(new Error("x"), { status: 503 }))).toBe(
false,
);
expect(isAuthStatusError(new Error("network down"))).toBe(false);
expect(isAuthStatusError(new Error("EACCES: write failed"))).toBe(false);
expect(isAuthStatusError(undefined)).toBe(false);
expect(isAuthStatusError(null)).toBe(false);
expect(isAuthStatusError("401")).toBe(false);
});
});

View File

@@ -0,0 +1,58 @@
/**
* Pure classification for the daemon auth probe. Kept free of Electron imports
* so it can be unit-tested in jsdom.
*
* When the local daemon fails to reach "running" shortly after a start, the
* main process probes the daemon's token against the backend (GET /api/me) to
* tell "the daemon can't authenticate" apart from "the daemon is slow / the
* network is down / it crashed for another reason". Misclassifying a network
* blip as an auth failure would be worse than the original silent-Starting bug,
* so the rules below are deliberately conservative: only an explicit 401 (or a
* missing credential) is treated as auth-expired.
*/
export interface AuthProbeOutcome {
/** HTTP status code returned by the probe request, if one completed. */
status?: number;
/** The daemon profile has no token at all — there is nothing to validate. */
noToken?: boolean;
/** The probe request threw (timeout, connection refused, DNS, TLS). */
networkError?: boolean;
}
export type AuthProbeResult = "auth_expired" | "ok" | "unknown";
/**
* Whether an error represents a genuine auth rejection (HTTP 401) as opposed to
* a transient failure (5xx, network, local I/O). Used by the re-authenticate
* flow so that only a real 401 — the session token itself is dead — forces a
* full re-login; transient failures keep the user signed in to retry.
*
* `mintPat` attaches the response status to the error it throws, so a 401
* surfaces here as `{ status: 401 }`. Everything else (no status, 5xx, a thrown
* fetch, a file-write error) is treated as non-auth.
*/
export function isAuthStatusError(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
(err as { status?: unknown }).status === 401
);
}
export function classifyAuthProbe(outcome: AuthProbeOutcome): AuthProbeResult {
// No credential to validate → the user must sign in.
if (outcome.noToken) return "auth_expired";
// Couldn't reach the server → this is a network problem, not an auth one.
// Stay "unknown" so the caller keeps showing "starting"/"stopped" instead of
// wrongly prompting for re-login.
if (outcome.networkError) return "unknown";
// The server explicitly rejected the token.
if (outcome.status === 401) return "auth_expired";
// The token is accepted — the daemon is failing for some other reason.
if (outcome.status !== undefined && outcome.status >= 200 && outcome.status < 300) {
return "ok";
}
// 5xx and everything else are inconclusive about the token's validity.
return "unknown";
}

View File

@@ -17,14 +17,37 @@ import {
import { join } from "path";
import { homedir, hostname } from "os";
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
import { daemonStatusAlive } from "../shared/daemon-types";
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
import { decideVersionAction } from "./version-decision";
import {
daemonLifecycleUnreachable,
isDaemonExternallyManaged,
normalizeHostOS,
} from "./daemon-os";
import {
classifyAuthProbe,
isAuthStatusError,
type AuthProbeResult,
} from "./daemon-auth-probe";
const DEFAULT_HEALTH_PORT = 19514;
const POLL_INTERVAL_MS = 5_000;
const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json");
const LOG_TAIL_RETRY_MS = 2_000;
const LOG_TAIL_MAX_RETRIES = 5;
// How long a start may sit in "starting" (with no /health) before we probe the
// token to find out whether login expired. The daemon's own startup can legitimately
// take a while (it renews the PAT and lists workspaces before serving /health), so we
// wait past the common case to avoid probing healthy-but-slow starts.
const AUTH_PROBE_GRACE_MS = 10_000;
// `multica daemon start` blocks until the daemon reports ready, polling /health
// for up to its own startup timeout (45s in server/cmd/multica/cmd_daemon.go) to
// cover cold-start agent-version detection. This execFile timeout MUST stay
// above that — otherwise Electron kills the CLI supervisor mid-startup and a
// healthy-but-slow start is misreported as a failure (the detached daemon child
// keeps running, so the UI flashes "stopped" then "running").
const DAEMON_START_EXEC_TIMEOUT_MS = 60_000;
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
@@ -48,6 +71,15 @@ let pendingVersionRestart = false;
let targetApiBaseUrl: string | null = null;
let activeProfile: ActiveProfile | null = null;
// Auth-probe state for the current start attempt. When a start fails to reach
// "running", we probe the daemon's token once (after AUTH_PROBE_GRACE_MS) to
// decide whether the cause is an expired/invalid login. `authExpired` is sticky
// until the next start attempt or a successful /health, so the UI keeps showing
// the re-login prompt instead of flapping back to "starting". See #3512.
let startingSince: number | null = null;
let authProbeDone = false;
let authExpired = false;
// Serialize all writes to any profile config file. Multiple paths
// (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers)
// may try to write concurrently; chaining them avoids interleaved writes
@@ -134,6 +166,8 @@ function sendStatus(status: DaemonStatus): void {
interface HealthPayload {
status?: string;
pid?: number;
/** Daemon's runtime.GOOS. Absent on daemons older than the #3916 fix. */
os?: string;
uptime?: string;
daemon_id?: string;
device_name?: string;
@@ -161,6 +195,36 @@ async function fetchHealthAtPort(
}
}
/**
* Validates the daemon profile's token against the backend to find out whether
* a stuck start is an auth problem. Hits the same endpoint `multica auth status`
* uses (GET /api/me) with the exact token the daemon loads from config.json, so
* the verdict matches what the daemon itself would get from the server.
*
* Only the HTTP status is inspected (never the body) so a future change to the
* /api/me response shape can't break this — a 401 means the token is rejected,
* a 2xx means it's fine, and a thrown request means the network is the problem,
* not auth. See classifyAuthProbe for the full rule set.
*/
async function probeTokenValidity(profile: string): Promise<AuthProbeResult> {
if (!targetApiBaseUrl) return "unknown";
const cfg = await readProfileConfig(profile);
const token = typeof cfg.token === "string" ? cfg.token : "";
if (!token) return classifyAuthProbe({ noToken: true });
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4_000);
const res = await fetch(`${targetApiBaseUrl.replace(/\/+$/, "")}/api/me`, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
clearTimeout(timeout);
return classifyAuthProbe({ status: res.status });
} catch {
return classifyAuthProbe({ networkError: true });
}
}
// Desktop owns a dedicated CLI profile named after the target API host, so it
// never reads or writes the user's hand-configured profiles. Profile dir:
// ~/.multica/profiles/desktop-<host>/
@@ -249,12 +313,57 @@ async function fetchHealth(): Promise<DaemonStatus> {
const data = await fetchHealthAtPort(active.port);
if (!data || data.status !== "running") {
// A start that never reaches "running" is the symptom; an expired/invalid
// login is the most common cause and the one with no other signal (the
// daemon exits before it can serve /health, so we can't read the reason
// from it). Probe the token once per attempt, after a grace period, to
// surface a re-login prompt instead of spinning on "starting" forever.
if (
currentState === "starting" &&
!authExpired &&
!authProbeDone &&
startingSince !== null &&
Date.now() - startingSince >= AUTH_PROBE_GRACE_MS
) {
authProbeDone = true;
if ((await probeTokenValidity(active.name)) === "auth_expired") {
authExpired = true;
}
}
// Sticky: once login is known-expired, keep reporting it (even after
// currentState flips away from "starting") until the next start attempt or
// a successful /health clears the flag.
if (authExpired) {
return { state: "auth_expired", profile: active.name };
}
// The daemon binds /health before preflight finishes and self-reports
// "starting" until it's ready. Trust that over our own currentState, so a
// daemon booting on its own — or started via the CLI — surfaces as
// "starting" instead of "stopped".
if (data?.status === "starting") {
return { state: "starting", profile: active.name };
}
return {
state: currentState === "starting" ? "starting" : "stopped",
profile: active.name,
};
}
// A live, authenticated daemon clears any prior auth-failure verdict so the
// re-login prompt disappears once the user reconnects.
authExpired = false;
startingSince = null;
// A running daemon whose OS differs from this host's is one we can't drive
// via the native lifecycle CLI (e.g. Linux-in-WSL2 behind a Windows desktop,
// reachable only over localhost forwarding). Surface it so the UI disables
// the auto-start/auto-stop toggles instead of letting them silently no-op,
// and so before-quit skips a stop that would never land. See #3916.
const externallyManaged = isDaemonExternallyManaged(
data.os,
normalizeHostOS(process.platform),
);
// Safety: if we have a target URL and the daemon on our port reports a
// different server_url, it's not "our" daemon — drop it and re-resolve.
if (
@@ -278,6 +387,7 @@ async function fetchHealth(): Promise<DaemonStatus> {
: 0,
profile: active.name,
serverUrl: data.server_url,
externallyManaged,
};
}
@@ -464,6 +574,15 @@ async function ensureRunningDaemonVersionMatches(): Promise<
> {
const active = await ensureActiveProfile();
const running = await fetchHealthAtPort(active.port);
// Don't try to version-match a daemon we can't restart (e.g. WSL2). Treat it
// as up-to-date — restartDaemon would no-op anyway, and skipping here avoids
// a misleading "restarting daemon" log on every auto-start. #3916.
if (isDaemonExternallyManaged(running?.os, normalizeHostOS(process.platform))) {
pendingVersionRestart = false;
return "ok";
}
const bundled = await getCliBinaryVersion();
const action = decideVersionAction(bundled, running);
@@ -515,7 +634,13 @@ async function mintPat(jwt: string): Promise<string> {
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`);
// Attach the status so callers can tell a genuine auth rejection (401 — the
// session token is dead) apart from a transient failure (5xx, etc.) without
// string-matching the message.
throw Object.assign(
new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`),
{ status: res.status },
);
}
const data = (await res.json()) as { token?: unknown };
if (typeof data.token !== "string" || !data.token.startsWith("mul_")) {
@@ -580,7 +705,10 @@ async function syncToken(
if (userChanged) {
try {
const existing = await fetchHealthAtPort(active.port);
if (existing?.status === "running") {
if (daemonStatusAlive(existing?.status)) {
// Restart whether it's "running" or still "starting" — a booting daemon
// already loaded the old token at startup, so it must be restarted to
// pick up the rotated credentials.
console.log(
"[daemon] user switched — restarting daemon with new credentials",
);
@@ -620,6 +748,52 @@ async function clearToken(): Promise<void> {
await removeProfileUserId(active.name);
}
// Result of a user-initiated daemon re-authentication. The distinction matters:
// only `session_invalid` justifies signing the user out of the whole app; a
// `transient` failure must keep them logged in so they can retry.
export type ReauthResult =
| { ok: true }
| { ok: false; reason: "session_invalid" }
| { ok: false; reason: "transient"; message: string };
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Recover the local daemon from the "auth_expired" state. Drops the stale
* cached PAT, mints a fresh one from the current session token, and restarts
* the daemon so it loads the new credential.
*
* Failures are classified rather than collapsed: a 401 from the mint means the
* session token itself is dead (`session_invalid` → the renderer drives a full
* re-login); anything else — mint 5xx, a network blip, a config write error, a
* restart hiccup — is `transient`, leaving the user signed in so they can retry.
* This mirrors the conservative classification the startup probe already uses.
*/
async function reauthenticate(
token: string,
userId: string,
): Promise<ReauthResult> {
try {
await clearToken();
// syncToken mints a fresh PAT because clearToken just removed any cache.
await syncToken(token, userId);
} catch (err) {
if (isAuthStatusError(err)) return { ok: false, reason: "session_invalid" };
return { ok: false, reason: "transient", message: errorMessage(err) };
}
const restart = await restartDaemon();
if (!restart.success) {
return {
ok: false,
reason: "transient",
message: restart.error ?? "failed to restart daemon",
};
}
return { ok: true };
}
async function withGuard<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
if (operationInProgress) {
return { success: false, error: "Another daemon operation is in progress" };
@@ -651,12 +825,19 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
const active = await ensureActiveProfile();
const existing = await fetchHealthAtPort(active.port);
if (existing?.status === "running") {
if (daemonStatusAlive(existing?.status)) {
// A daemon is already up ("running") or booting ("starting") on this port —
// don't spawn a second one (the CLI rejects that as "already running").
// Let polling track it through to "running".
pollOnce();
return { success: true };
}
currentState = "starting";
// Begin a fresh auth-probe window for this attempt.
startingSince = Date.now();
authProbeDone = false;
authExpired = false;
sendStatus({ state: "starting" });
const args = ["daemon", "start", ...profileArgs(active)];
@@ -665,7 +846,7 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
execFile(
bin,
args,
{ timeout: 20_000, env: desktopSpawnEnv() },
{ timeout: DAEMON_START_EXEC_TIMEOUT_MS, env: desktopSpawnEnv() },
(err) => {
if (err) {
currentState = "stopped";
@@ -683,12 +864,40 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
});
}
/**
* Fresh boundary preflight for stop/restart: read the active profile's CURRENT
* /health and decide whether the daemon runs somewhere the app can't drive
* (WSL2 etc.). Done per call rather than off the poll cache, so a lifecycle op
* never shells out to a CLI that can't reach the daemon's process — even on
* paths that didn't just poll (e.g. restart-on-user-switch in syncToken, which
* calls restartDaemon directly). See #3916.
*/
async function lifecycleBlockedByForeignDaemon(): Promise<boolean> {
const active = await ensureActiveProfile();
return daemonLifecycleUnreachable(
async () => (await fetchHealthAtPort(active.port))?.os,
normalizeHostOS(process.platform),
);
}
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
// Central lifecycle guard: a daemon running in an environment we can't drive
// (e.g. Linux in WSL2 behind a Windows desktop) can't be stopped by the
// native CLI — it would act on the host process namespace and no-op, while
// still flipping our state to "stopped". Bail as a successful no-op so every
// caller (logout, quit, restart, the Runtime card) is covered in one place
// rather than each remembering to check. Preflighted against live /health so
// it holds even when no poll ran first. #3916.
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
const bin = await resolveCliBinary();
if (!bin) return { success: false, error: "multica CLI is not installed" };
const active = await ensureActiveProfile();
currentState = "stopping";
// An explicit stop is a clean reset — drop any pending auth-failure verdict.
authExpired = false;
startingSince = null;
sendStatus({ state: "stopping" });
const args = ["daemon", "stop", ...profileArgs(active)];
@@ -707,6 +916,11 @@ async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
}
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
// Same central, live-preflighted guard as stopDaemon: we can neither stop nor
// start a daemon we don't manage, so don't try (user-switch, reauth,
// first-workspace, and any future restart caller all route through here).
// #3916.
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
const stopResult = await stopDaemon();
if (!stopResult.success) return stopResult;
return startDaemon();
@@ -874,6 +1088,10 @@ export function setupDaemonManager(
(_event, token: string, userId: string) => syncToken(token, userId),
);
ipcMain.handle("daemon:clear-token", () => clearToken());
ipcMain.handle(
"daemon:reauthenticate",
(_event, token: string, userId: string) => reauthenticate(token, userId),
);
ipcMain.handle("daemon:is-cli-installed", async () => {
const bin = await resolveCliBinary();
return bin !== null;
@@ -950,6 +1168,8 @@ export function setupDaemonManager(
isQuitting = true;
event.preventDefault();
try {
// stopDaemon no-ops for an externally-managed daemon (WSL2 etc.), so
// this is safe and instant in that case — the guard lives there. #3916
await stopDaemon();
} catch {
// Best-effort stop on quit

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,11 +13,21 @@ import { installNavigationGestures } from "./navigation-gestures";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import {
RENDERER_ROUTE_CONTEXT_CHANNEL,
sanitizeRendererRouteContext,
type RendererRouteContext,
} from "../shared/renderer-route-context";
import {
createElectronReloadPrompt,
installRendererRecoveryHandlers,
type RendererRecoveryWindow,
} from "./renderer-recovery";
import {
writeFreezeBreadcrumb,
readAndClearFreezeBreadcrumb,
clearFreezeBreadcrumb,
} from "./freeze-breadcrumb";
// Bundled icon used for dock/taskbar branding. macOS/Windows production
// builds let the OS pick up the icon from the .app bundle / .exe resources,
@@ -61,7 +71,15 @@ if (process.platform !== "win32") {
const PROTOCOL = "multica";
// Where the main process parks a freeze/crash breadcrumb until the next
// renderer boot flushes it to telemetry. Lives in userData so it survives a
// force-quit. Resolved lazily — app.getPath is only valid after `ready`.
function freezeBreadcrumbPath(): string {
return join(app.getPath("userData"), "last-client-failure.json");
}
let mainWindow: BrowserWindow | null = null;
let latestRendererRouteContext: RendererRouteContext | null = null;
let runtimeConfigResult: RuntimeConfigResult = {
ok: false,
error: { message: "Runtime config has not loaded yet" },
@@ -165,10 +183,19 @@ function createWindow(): void {
additionalArguments: [`--multica-locale=${systemLocale}`],
},
});
const window = mainWindow;
latestRendererRouteContext = null;
window.on("closed", () => {
if (mainWindow === window) {
mainWindow = null;
latestRendererRouteContext = null;
}
});
// Strip Origin header from WebSocket upgrade requests so the server's
// origin whitelist doesn't reject connections from localhost dev origins.
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
window.webContents.session.webRequest.onBeforeSendHeaders(
{ urls: ["wss://*/*", "ws://*/*"] },
(details, callback) => {
delete details.requestHeaders["Origin"];
@@ -176,8 +203,8 @@ function createWindow(): void {
},
);
mainWindow.on("ready-to-show", () => {
mainWindow?.show();
window.on("ready-to-show", () => {
window.show();
});
// Detect OS language changes while the app is running. Electron has no
@@ -185,24 +212,28 @@ function createWindow(): void {
// catches the common case where users switch System Settings → Language
// and bring the app back. The renderer decides whether to act (it ignores
// the signal when the user has an explicit Settings choice).
mainWindow.on("focus", () => {
window.on("focus", () => {
const current = getSystemLocale();
if (current === lastKnownSystemLocale) return;
lastKnownSystemLocale = current;
mainWindow?.webContents.send("locale:system-changed", current);
window.webContents.send("locale:system-changed", current);
});
mainWindow.webContents.setWindowOpenHandler((details) => {
window.webContents.setWindowOpenHandler((details) => {
openExternalSafely(details.url);
return { action: "deny" };
});
// 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.
mainWindow.webContents.on("before-input-event", (event, input) => {
if (handleAppShortcut(input, mainWindow!.webContents)) {
// 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) => {
const result = handleAppShortcut(input, window.webContents);
if (result === "close-tab") {
event.preventDefault();
window.webContents.send("tab:close-active");
} else if (result) {
event.preventDefault();
}
});
@@ -224,7 +255,7 @@ function createWindow(): void {
// Forward every renderer-side console.* call. The detail object also
// carries source URL + line — included so a thrown stack trace from
// window.onerror is traceable back to a file.
mainWindow.webContents.on("console-message", (details) => {
window.webContents.on("console-message", (details) => {
const { level, message, sourceId, lineNumber } = details;
log(level, `${message} (${sourceId}:${lineNumber})`);
});
@@ -232,7 +263,7 @@ function createWindow(): void {
// Fires when loadURL / loadFile can't reach its target (dev server
// not up yet, network blip, file missing). errorCode is a Chromium
// net error number; -3 = ABORTED is normal during HMR and skipped.
mainWindow.webContents.on(
window.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (errorCode === -3) return;
@@ -245,20 +276,41 @@ function createWindow(): void {
}
installRendererRecoveryHandlers(mainWindow as unknown as RendererRecoveryWindow, {
installRendererRecoveryHandlers(window as unknown as RendererRecoveryWindow, {
isDev: is.dev,
showReloadPrompt: createElectronReloadPrompt((options) =>
dialog.showMessageBox(mainWindow!, 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(mainWindow.webContents);
installNavigationGestures(mainWindow);
installContextMenu(window.webContents);
installNavigationGestures(window);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
window.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
window.loadFile(join(__dirname, "../renderer/index.html"));
}
}
@@ -365,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");
@@ -383,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.
@@ -390,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

@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserWindow, WebContents } from "electron";
type Handler = (...args: unknown[]) => void;
const ctx = vi.hoisted(() => ({
handlers: new Map<string, Handler[]>(),
ipcHandle: vi.fn(),
checkForUpdates: vi.fn(async () => ({
updateInfo: { version: "0.3.18" },
isUpdateAvailable: false,
})),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
getVersion: vi.fn(() => "0.3.17"),
}));
vi.mock("electron-updater", () => {
const autoUpdater = {
autoDownload: false,
autoInstallOnAppQuit: false,
channel: undefined as string | undefined,
on: vi.fn((event: string, handler: Handler) => {
const handlers = ctx.handlers.get(event) ?? [];
handlers.push(handler);
ctx.handlers.set(event, handlers);
return autoUpdater;
}),
checkForUpdates: ctx.checkForUpdates,
downloadUpdate: ctx.downloadUpdate,
quitAndInstall: ctx.quitAndInstall,
};
return { autoUpdater };
});
vi.mock("electron", () => ({
app: {
getVersion: ctx.getVersion,
},
BrowserWindow: class BrowserWindow {},
ipcMain: {
handle: ctx.ipcHandle,
},
}));
import { setupAutoUpdater } from "./updater";
function emitUpdater(event: string, ...args: unknown[]) {
for (const handler of ctx.handlers.get(event) ?? []) {
handler(...args);
}
}
function makeWindow() {
const send = vi.fn();
return {
win: {
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
send,
},
} as unknown as BrowserWindow,
send,
};
}
function makeDestroyedWindow() {
return {
isDestroyed: () => true,
get webContents(): WebContents {
throw new TypeError("Object has been destroyed");
},
} as unknown as BrowserWindow;
}
function makeWindowWithDestroyedWebContents() {
const send = vi.fn(() => {
throw new TypeError("Object has been destroyed");
});
return {
win: {
isDestroyed: () => false,
webContents: {
isDestroyed: () => true,
send,
},
} as unknown as BrowserWindow,
send,
};
}
function makeWindowWithThrowingSend(error: Error) {
const send = vi.fn(() => {
throw error;
});
return {
win: {
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
send,
},
} as unknown as BrowserWindow,
send,
};
}
describe("setupAutoUpdater", () => {
beforeEach(() => {
vi.useFakeTimers();
ctx.handlers.clear();
ctx.ipcHandle.mockClear();
ctx.checkForUpdates.mockClear();
ctx.downloadUpdate.mockClear();
ctx.quitAndInstall.mockClear();
ctx.getVersion.mockClear();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it("forwards update progress to a live renderer", () => {
const { win, send } = makeWindow();
setupAutoUpdater(() => win);
emitUpdater("download-progress", { percent: 42 });
expect(send).toHaveBeenCalledWith("updater:download-progress", {
percent: 42,
});
});
it("skips update progress when the BrowserWindow has already been destroyed", () => {
setupAutoUpdater(() => makeDestroyedWindow());
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
});
it("skips update progress when the BrowserWindow webContents has already been destroyed", () => {
const { win, send } = makeWindowWithDestroyedWebContents();
setupAutoUpdater(() => win);
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
expect(send).not.toHaveBeenCalled();
});
it("skips update progress when webContents.send loses a destroy race", () => {
const { win, send } = makeWindowWithThrowingSend(
new TypeError("Object has been destroyed"),
);
setupAutoUpdater(() => win);
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
expect(send).toHaveBeenCalledWith("updater:download-progress", {
percent: 42,
});
});
it("rethrows non-destroy errors from webContents.send", () => {
const { win } = makeWindowWithThrowingSend(new Error("boom"));
setupAutoUpdater(() => win);
expect(() => emitUpdater("download-progress", { percent: 42 })).toThrow(
"boom",
);
});
});

View File

@@ -1,5 +1,5 @@
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
import { app, BrowserWindow, ipcMain } from "electron";
import { autoUpdater, type UpdateDownloadedEvent } from "electron-updater";
import { app, type BrowserWindow, ipcMain } from "electron";
// Silent background updates: electron-updater downloads on its own as soon
// as `update-available` fires; we only surface UI when the package is fully
@@ -29,6 +29,32 @@ export type ManualUpdateCheckResult =
}
| { ok: false; error: string };
type RendererChannel =
| "updater:update-available"
| "updater:download-progress"
| "updater:update-downloaded";
function isDestroyedObjectError(err: unknown): boolean {
return err instanceof Error && err.message.includes("Object has been destroyed");
}
function sendToLiveRenderer(
win: BrowserWindow | null,
channel: RendererChannel,
payload: unknown,
): void {
if (!win || win.isDestroyed()) return;
try {
const { webContents } = win;
if (webContents.isDestroyed()) return;
webContents.send(channel, payload);
} catch (err) {
if (isDestroyedObjectError(err)) return;
throw err;
}
}
// Single-flight guard around checkForUpdates(). With autoDownload=true the
// startup, periodic, and manual triggers can all kick off downloads, and
// overlapping calls have caused duplicate download warnings in the past
@@ -62,23 +88,20 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
autoUpdater.on("update-available", (info) => {
// Forwarded for renderer-side state tracking only; the notification UI
// does not render an "available" affordance with autoDownload=true.
const win = getMainWindow();
win?.webContents.send("updater:update-available", {
sendToLiveRenderer(getMainWindow(), "updater:update-available", {
version: info.version,
releaseNotes: info.releaseNotes,
});
});
autoUpdater.on("download-progress", (progress) => {
const win = getMainWindow();
win?.webContents.send("updater:download-progress", {
sendToLiveRenderer(getMainWindow(), "updater:download-progress", {
percent: progress.percent,
});
});
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
const win = getMainWindow();
win?.webContents.send("updater:update-downloaded", {
sendToLiveRenderer(getMainWindow(), "updater:update-downloaded", {
version: info.version,
releaseNotes: info.releaseNotes,
});

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,10 +78,22 @@ 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 {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
state:
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found"
| "auth_expired";
pid?: number;
uptime?: string;
daemonId?: string;
@@ -90,6 +109,11 @@ interface DaemonPrefs {
autoStop: boolean;
}
type DaemonReauthResult =
| { ok: true }
| { ok: false; reason: "session_invalid" }
| { ok: false; reason: "transient"; message: string };
interface DaemonAPI {
start: () => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
@@ -100,6 +124,10 @@ interface DaemonAPI {
setTargetApiUrl: (url: string) => Promise<void>;
syncToken: (token: string, userId: string) => Promise<void>;
clearToken: () => Promise<void>;
reauthenticate: (
token: string,
userId: string,
) => Promise<DaemonReauthResult>;
isCliInstalled: () => Promise<boolean>;
getPrefs: () => Promise<DaemonPrefs>;
setPrefs: (prefs: Partial<DaemonPrefs>) => Promise<DaemonPrefs>;

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,16 +171,38 @@ 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 {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
state:
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found"
| "auth_expired";
pid?: number;
uptime?: string;
daemonId?: string;
@@ -176,6 +213,11 @@ interface DaemonStatus {
serverUrl?: string;
}
type DaemonReauthResult =
| { ok: true }
| { ok: false; reason: "session_invalid" }
| { ok: false; reason: "transient"; message: string };
const daemonAPI = {
start: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:start"),
@@ -198,6 +240,11 @@ const daemonAPI = {
ipcRenderer.invoke("daemon:sync-token", token, userId),
clearToken: (): Promise<void> =>
ipcRenderer.invoke("daemon:clear-token"),
reauthenticate: (
token: string,
userId: string,
): Promise<DaemonReauthResult> =>
ipcRenderer.invoke("daemon:reauthenticate", token, userId),
isCliInstalled: (): Promise<boolean> =>
ipcRenderer.invoke("daemon:is-cli-installed"),
getPrefs: (): Promise<{ autoStart: boolean; autoStop: boolean }> =>

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

@@ -16,6 +16,7 @@ import {
X,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
@@ -194,15 +195,12 @@ export function DaemonPanel({
const handleCopy = useCallback(async () => {
const text = filtered.map((l) => l.raw).join("\n");
try {
await navigator.clipboard.writeText(text);
if (await copyText(text)) {
toast.success(
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
);
} catch (err) {
toast.error("Failed to copy", {
description: err instanceof Error ? err.message : String(err),
});
} else {
toast.error("Failed to copy");
}
}, [filtered]);

View File

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

View File

@@ -6,6 +6,8 @@ import {
RotateCw,
Activity,
ScrollText,
LogIn,
Info,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -22,6 +24,7 @@ import {
} from "@multica/ui/components/ui/dialog";
import { toast } from "sonner";
import { DaemonPanel } from "./daemon-panel";
import { reauthenticateDaemon } from "../platform/daemon-reauth";
import type { DaemonStatus } from "../../../shared/daemon-types";
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
@@ -115,9 +118,24 @@ export function DaemonRuntimeActions() {
}
}, []);
const handleReauth = useCallback(async () => {
setActionLoading(true);
await reauthenticateDaemon();
// onStatusChange resets actionLoading on the next status push; reset here
// too in case reauth logged out (unmount) or produced no status change.
setActionLoading(false);
}, []);
const isRunning = status.state === "running";
// The daemon runs somewhere the app can't drive (e.g. inside WSL2): the
// lifecycle CLI acts on the host process namespace and can't reach it. Hide
// Stop/Restart so they don't silently no-op, mirroring the Settings tab. The
// real guard is in the main process (stopDaemon/restartDaemon); this is the
// matching UX. See #3916.
const externallyManaged = status.externallyManaged === true;
const isStopped = status.state === "stopped";
const isCliMissing = status.state === "cli_not_found";
const isAuthExpired = status.state === "auth_expired";
const isTransitioning =
status.state === "starting" || status.state === "stopping";
const isInstalling = status.state === "installing_cli";
@@ -131,24 +149,33 @@ export function DaemonRuntimeActions() {
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
{externallyManaged ? (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Info className="size-3.5 shrink-0" />
Managed outside the app
</span>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
</>
)}
@@ -175,6 +202,23 @@ export function DaemonRuntimeActions() {
</Button>
)}
{isAuthExpired && (
<>
<span className="inline-flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
Sign-in expired
</span>
<Button size="sm" onClick={handleReauth} disabled={actionLoading}>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<LogIn className="size-3.5 mr-1.5" />
)}
Sign in again
</Button>
</>
)}
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />

View File

@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { AlertCircle, Info, LogIn } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
import { reauthenticateDaemon } from "../platform/daemon-reauth";
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
@@ -61,6 +63,7 @@ export function DaemonSettingsTab() {
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [reauthLoading, setReauthLoading] = useState(false);
useEffect(() => {
window.daemonAPI.getPrefs().then(setPrefs);
@@ -69,6 +72,12 @@ export function DaemonSettingsTab() {
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const handleReauth = useCallback(async () => {
setReauthLoading(true);
await reauthenticateDaemon();
setReauthLoading(false);
}, []);
const updatePref = useCallback(
async (key: keyof DaemonPrefs, value: boolean) => {
setSaving(true);
@@ -79,6 +88,12 @@ export function DaemonSettingsTab() {
[],
);
// The daemon runs somewhere the app can't drive (e.g. inside WSL2 behind a
// Windows desktop): /health is reachable but the lifecycle CLI can't reach
// its process. Auto-start/auto-stop can't work, so disable them and say why
// rather than letting the toggles silently no-op. See #3916.
const externallyManaged = status.externallyManaged === true;
return (
<div>
<h2 className="text-lg font-semibold">Daemon</h2>
@@ -86,6 +101,43 @@ export function DaemonSettingsTab() {
Configure how the local agent daemon behaves with the desktop app.
</p>
{status.state === "auth_expired" && (
<div className="mt-4 flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">
Sign-in expired
</p>
<p className="mt-0.5 text-sm text-muted-foreground">
The local daemon couldn&apos;t authenticate, so this device
can&apos;t take tasks. Sign in again to restore it.
</p>
</div>
<Button
size="sm"
className="shrink-0"
onClick={handleReauth}
disabled={reauthLoading}
>
<LogIn className="size-3.5 mr-1.5" />
Sign in again
</Button>
</div>
)}
{externallyManaged && (
<div className="mt-4 flex items-start gap-3 rounded-lg border bg-muted/30 px-4 py-3">
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<p className="min-w-0 text-sm text-muted-foreground">
This device&apos;s daemon runs outside the app for example inside
WSL2 so the app can&apos;t start or stop it. Start or stop it from
that environment with{" "}
<code className="font-mono text-xs">multica daemon start</code> /{" "}
<code className="font-mono text-xs">multica daemon stop</code>.
</p>
</div>
)}
<div className="mt-6 divide-y">
<SettingRow
label="Auto-start on launch"
@@ -94,7 +146,7 @@ export function DaemonSettingsTab() {
<Switch
checked={prefs.autoStart}
onCheckedChange={(checked) => updatePref("autoStart", checked)}
disabled={saving}
disabled={saving || externallyManaged}
/>
</SettingRow>
@@ -105,7 +157,7 @@ export function DaemonSettingsTab() {
<Switch
checked={prefs.autoStop}
onCheckedChange={(checked) => updatePref("autoStop", checked)}
disabled={saving}
disabled={saving || externallyManaged}
/>
</SettingRow>

View File

@@ -1,4 +1,4 @@
import { useEffect, useSyncExternalStore } from "react";
import { useEffect, useRef, useSyncExternalStore } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
@@ -14,6 +14,7 @@ import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
import { useNavigation } from "@multica/views/navigation";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { useDesktopUnreadBadge } from "@multica/views/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
@@ -127,18 +128,30 @@ function useInternalLinkHandler() {
* inbox even if the user has since switched to workspace B. Marking
* the row read is handled by InboxPage's selected-item effect, which
* covers both click-to-select and URL-param-select paths.
*
* The click routes through `useNavigation().push` — NOT the
* `multica:navigate` event, whose handler `openTab`s into the ACTIVE
* workspace's tab group. The navigation adapter detects a cross-workspace
* path and translates it into `switchWorkspace(slug, path)`, so clicking a
* workspace-A notification while B is active performs a real workspace
* switch instead of mounting A's inbox inside B's tab group (#3766).
*/
function DesktopInboxBridge() {
const workspace = useCurrentWorkspace();
useDesktopUnreadBadge(workspace?.id ?? null);
const { push } = useNavigation();
// The adapter identity changes with the active tab's location; the ref
// keeps the main-process subscription stable across navigations.
const pushRef = useRef(push);
useEffect(() => {
pushRef.current = push;
}, [push]);
useEffect(() => {
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
if (!slug) return;
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
);
pushRef.current(inboxPath);
});
}, []);

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

@@ -11,7 +11,14 @@ import type { AgentRuntime } from "@multica/core/types";
* to the desktop preload typings (which live in apps/desktop/src/preload).
*/
interface DaemonStatusLike {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
state:
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found"
| "auth_expired";
daemonId?: string;
}
@@ -25,7 +32,11 @@ interface DaemonStatusLike {
* within 75s.
*/
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
if (status.state === "stopped" || status.state === "stopping") {
if (
status.state === "stopped" ||
status.state === "stopping" ||
status.state === "auth_expired"
) {
return { ...rt, status: "offline" };
}
if (status.state === "running") {

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockGetState, logout } = vi.hoisted(() => ({
mockGetState: vi.fn(),
logout: vi.fn(),
}));
const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() }));
vi.mock("@multica/core/auth", () => ({
useAuthStore: { getState: mockGetState },
}));
vi.mock("sonner", () => ({
toast: { error: toastError },
}));
import { reauthenticateDaemon } from "./daemon-reauth";
const daemonAPI = {
reauthenticate: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
(window as unknown as { daemonAPI: typeof daemonAPI }).daemonAPI = daemonAPI;
mockGetState.mockReturnValue({ user: { id: "user-1" }, logout });
});
describe("reauthenticateDaemon", () => {
it("re-mints + restarts the daemon when signed in, without logging out", async () => {
localStorage.setItem("multica_token", "jwt-abc");
daemonAPI.reauthenticate.mockResolvedValue({ ok: true });
await reauthenticateDaemon();
expect(daemonAPI.reauthenticate).toHaveBeenCalledWith("jwt-abc", "user-1");
expect(logout).not.toHaveBeenCalled();
expect(toastError).not.toHaveBeenCalled();
});
it("logs out only when the session token itself is rejected (401)", async () => {
localStorage.setItem("multica_token", "jwt-abc");
daemonAPI.reauthenticate.mockResolvedValue({
ok: false,
reason: "session_invalid",
});
await reauthenticateDaemon();
expect(logout).toHaveBeenCalledOnce();
expect(toastError).not.toHaveBeenCalled();
});
// The reviewer's must-fix: a non-401 (transient) failure must NOT log the
// user out — they stay signed in and can retry.
it("does NOT log out on a transient failure; shows a retryable toast", async () => {
localStorage.setItem("multica_token", "jwt-abc");
daemonAPI.reauthenticate.mockResolvedValue({
ok: false,
reason: "transient",
message: "mint PAT failed: 503 Service Unavailable",
});
await reauthenticateDaemon();
expect(logout).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledOnce();
});
it("does NOT log out when the IPC call itself throws unexpectedly", async () => {
localStorage.setItem("multica_token", "jwt-abc");
daemonAPI.reauthenticate.mockRejectedValue(new Error("ipc boom"));
await reauthenticateDaemon();
expect(logout).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledOnce();
});
it("routes to login when there is no session token", async () => {
await reauthenticateDaemon();
expect(logout).toHaveBeenCalledOnce();
expect(daemonAPI.reauthenticate).not.toHaveBeenCalled();
});
it("routes to login when there is no signed-in user", async () => {
localStorage.setItem("multica_token", "jwt-abc");
mockGetState.mockReturnValue({ user: null, logout });
await reauthenticateDaemon();
expect(logout).toHaveBeenCalledOnce();
expect(daemonAPI.reauthenticate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,48 @@
import { useAuthStore } from "@multica/core/auth";
import { toast } from "sonner";
/**
* Re-establish the local daemon's credentials after it failed to authenticate
* (daemon state "auth_expired", surfaced by daemon-manager's token probe — see
* #3512).
*
* The desktop owns the daemon's PAT: it mints one from the user's session token
* and caches it per profile. A stale/revoked cached PAT is the common cause (and
* merely restarting the app reuses the same bad PAT), so the main process drops
* the cached token, mints a fresh one, and restarts the daemon.
*
* Failure handling is deliberately conservative — we only force a full re-login
* when the session token itself is rejected (a real 401). A transient failure
* (mint 5xx, network blip, config write error, restart hiccup) keeps the user
* signed in and shows a retryable toast, so a momentary glitch never logs them
* out. The 401-vs-transient classification happens in the main process where the
* real HTTP status is available; here we just act on the verdict.
*/
export async function reauthenticateDaemon(): Promise<void> {
const user = useAuthStore.getState().user;
const token = localStorage.getItem("multica_token");
if (!user || !token) {
// No usable session at all — the standard recovery is the login page.
useAuthStore.getState().logout();
return;
}
try {
const result = await window.daemonAPI.reauthenticate(token, user.id);
if (result.ok) return; // daemon restarting; status flips via onStatusChange
if (result.reason === "session_invalid") {
// The session token itself is rejected (401) — full re-login.
useAuthStore.getState().logout();
return;
}
// Transient failure — keep the user signed in and let them retry.
toast.error("Couldn't reconnect the daemon", {
description: result.message || "Please try again in a moment.",
});
} catch (err) {
// An unexpected IPC error is not an auth failure — never log out on it.
toast.error("Couldn't reconnect the daemon", {
description: err instanceof Error ? err.message : "Please try again.",
});
}
}

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { daemonStatusAlive } from "./daemon-types";
describe("daemonStatusAlive", () => {
it("treats a ready daemon as alive", () => {
expect(daemonStatusAlive("running")).toBe(true);
});
it("treats a still-booting daemon as alive", () => {
// /health binds before preflight and reports "starting" until ready; the
// Desktop must not spawn a second daemon over it (the CLI rejects that as
// "already running").
expect(daemonStatusAlive("starting")).toBe(true);
});
it("treats stopped / unknown / missing as not alive", () => {
expect(daemonStatusAlive("stopped")).toBe(false);
expect(daemonStatusAlive("bogus")).toBe(false);
expect(daemonStatusAlive("")).toBe(false);
expect(daemonStatusAlive(undefined)).toBe(false);
});
});

View File

@@ -4,7 +4,11 @@ export type DaemonState =
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found";
| "cli_not_found"
// The daemon can't start because the server rejected its credentials (the
// cached PAT expired / was revoked, or the session token is dead). Without
// this, an auth failure silently sticks at "starting" forever — see #3512.
| "auth_expired";
export interface DaemonStatus {
state: DaemonState;
@@ -18,6 +22,16 @@ export interface DaemonStatus {
profile?: string;
/** Backend URL the daemon connects to. */
serverUrl?: string;
/**
* True when a daemon is running but in an environment the app can't control
* — its reported OS differs from the desktop host's (e.g. a Linux daemon
* inside WSL2 behind a Windows desktop, reachable only via localhost
* forwarding). The app's start/stop CLI acts on the host process namespace,
* so auto-start/auto-stop can't reach it; the UI disables those toggles
* instead of silently no-op'ing. Only ever set on a running daemon, so it
* never disables the toggles for a normally-managed native daemon. See #3916.
*/
externallyManaged?: boolean;
}
export interface DaemonPrefs {
@@ -32,6 +46,7 @@ export const DAEMON_STATE_COLORS: Record<DaemonState, string> = {
stopping: "bg-amber-500 animate-pulse",
installing_cli: "bg-sky-500 animate-pulse",
cli_not_found: "bg-red-500",
auth_expired: "bg-red-500",
};
export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
@@ -41,6 +56,7 @@ export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
stopping: "Stopping…",
installing_cli: "Setting up…",
cli_not_found: "Setup Failed",
auth_expired: "Sign-in required",
};
export function formatUptime(uptime?: string): string {
@@ -52,6 +68,19 @@ export function formatUptime(uptime?: string): string {
return `${h}${m}`.trim() || uptime;
}
/**
* Whether a raw daemon `/health` `status` value means a live daemon is on the
* port — either fully "running" (ready) or still "starting" (port bound,
* preflight in progress). Mirrors the Go `daemonAlive()` in
* server/cmd/multica/cmd_daemon.go so the Desktop lifecycle agrees with the
* CLI: a "starting" daemon is already there and must not be spawned over (the
* CLI rejects that as "already running"). This is liveness, not readiness —
* version-restart decisions still gate on the stricter "running".
*/
export function daemonStatusAlive(status: string | undefined): boolean {
return status === "running" || status === "starting";
}
/**
* User-facing description for the local daemon's current state. Replaces the
* raw state label ("Running" / "Stopped") with a sentence that answers
@@ -81,5 +110,7 @@ export function daemonStateDescription(state: DaemonState, runtimeCount: number)
return "Setting up the runtime for the first time. Only happens once.";
case "cli_not_found":
return "Setup failed · couldn't download the runtime. Check your network.";
case "auth_expired":
return "Sign-in expired · sign in again to bring this device back online.";
}
}

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

@@ -37,7 +37,7 @@ SMTP 経路は、ほとんどのオンプレミスメールサーバー(特に
|---|---|---|---|
| 匿名内部 relay | `25` | なし — IP / サブネットで送信を信頼 | 伝送経路上はなし(内部セグメント専用) |
| 認証付き送信submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS、自動アップグレード |
| 暗黙的 TLSSMTPS | `465` | — | **まだサポートされていません** — ポート 25 または 587 を使用してください |
| 暗黙的 TLSSMTPS | `465` | 任意(`SMTP_USERNAME` + `SMTP_PASSWORD` | 接続時に TLS ハンドシェイク — ポート `465` で自動的に有効化、非標準ポートでは `SMTP_TLS=implicit` で強制 |
**ポート 25 の匿名 Exchange relay** — 認証情報なしで信頼されたサブネットからのメールを受け入れる、典型的な「internal SMTP relay」/ Exchange 匿名 receive connector:
@@ -61,7 +61,27 @@ SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
起動時に、サーバーは選択したプロバイダーを出力します。例えば `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com`(または `Resend API` / `DEV mode`)のように表示されます。パスワードがログに記録されることは決してありません。再起動後に SMTP の行が見えない場合は `SMTP_HOST` がプロセスに届いていないので、コンテナ環境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)を確認してください。
**ポート 465 の暗黙的 TLSSMTPS** — SMTPS のみを提供し STARTTLS を通知しないプロバイダー(例: Aliyun / Tencent のエンタープライズメール)向け。ポート `465` は暗黙的 TLS を自動的に有効化します。`SMTP_TLS=implicit`(別名: `smtps`、`ssl`)は非標準の SMTPS ポートでこれを強制します:
```bash
SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465 # implicit TLS auto-enabled on 465
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**厳格な公開 relay例: Google Workspace `smtp-relay.gmail.com`** はさらに有効な EHLO 名を必要とします。これらの relay は公開 IP からのデフォルトの `localhost` 挨拶を拒否し、relay が接続を切断します — これは挨拶の時点ではなく、後続のコマンドで不明瞭な `EOF``smtp auth: EOF`)として表面化します。`SMTP_EHLO_NAME` を relay が期待する FQDN に設定してください。デフォルトはマシンのホスト名で、コンテナ内では通常は有効な FQDN ではありません。
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
起動時に、サーバーは選択したプロバイダーを、ネゴシエートされた TLS モードも含めて出力します。例えば `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` や `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…`(または `Resend API` / `DEV mode`)のように表示されます。パスワードがログに記録されることは決してありません。再起動後に SMTP の行が見えない場合は `SMTP_HOST` がプロセスに届いていないので、コンテナ環境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)を確認してください。
**どちらも設定しない場合**: サーバーはエラーを出しませんが、**送信されるはずだったすべてのメールがサーバーの stdout にのみ書き出されます**。ローカル開発には便利ですが(ログからコードをコピーできます)、プロダクションではブラックホールになります。

View File

@@ -37,7 +37,7 @@ SMTP 경로는 대부분의 온프레미스 메일 서버(특히 Microsoft Excha
|---|---|---|---|
| 익명 내부 relay | `25` | 없음 — IP / 서브넷으로 제출을 신뢰 | 전송 경로상 없음(내부 세그먼트 전용) |
| 인증된 제출(submission) | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, 자동 업그레이드 |
| 암묵적 TLS (SMTPS) | `465` | — | **아직 지원하지 않음** — 포트 25 또는 587을 사용하세요 |
| 암묵적 TLS (SMTPS) | `465` | 선택 사항(`SMTP_USERNAME` + `SMTP_PASSWORD`) | 연결 시 TLS 핸드셰이크 — 포트 `465`에서 자동 활성화, 비표준 포트에서는 `SMTP_TLS=implicit`로 강제 |
**포트 25의 익명 Exchange relay** — 자격 증명 없이 신뢰된 서브넷에서 오는 메일을 받아들이는 일반적인 "internal SMTP relay" / Exchange 익명 receive connector:
@@ -61,7 +61,27 @@ SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
시작 시 서버는 선택한 제공자를 출력합니다. 예를 들어 `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com`(또는 `Resend API` / `DEV mode`)와 같이 표시됩니다. 비밀번호는 절대 로그에 기록되지 않습니다. 재시작 후 SMTP 줄이 보이지 않는다면 `SMTP_HOST`가 프로세스에 도달하지 못한 것이므로, 컨테이너 환경(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)을 확인하세요.
**포트 465의 암묵적 TLS(SMTPS)** — SMTPS만 제공하고 STARTTLS를 알리지 않는 제공자(예: Aliyun / Tencent 엔터프라이즈 메일)용. 포트 `465`는 암묵적 TLS를 자동으로 활성화하며, `SMTP_TLS=implicit`(별칭: `smtps`, `ssl`)는 비표준 SMTPS 포트에서 이를 강제합니다:
```bash
SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465 # implicit TLS auto-enabled on 465
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**엄격한 공개 relay(예: Google Workspace `smtp-relay.gmail.com`)** 는 추가로 유효한 EHLO 이름을 요구합니다. 이들은 공개 IP에서 보내는 기본 `localhost` greeting을 거부하며, relay가 연결을 끊습니다 — 이는 greeting 단계가 아니라 이후 명령에서 불투명한 `EOF`(`smtp auth: EOF`)로 나타납니다. relay가 기대하는 FQDN으로 `SMTP_EHLO_NAME`을 설정하세요. 기본값은 머신 호스트명이며, 컨테이너 안에서는 보통 유효한 FQDN이 아닙니다:
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
시작 시 서버는 협상된 TLS 모드를 포함하여 선택한 제공자를 출력합니다. 예를 들어 `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` 또는 `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…`(또는 `Resend API` / `DEV mode`)와 같이 표시됩니다. 비밀번호는 절대 로그에 기록되지 않습니다. 재시작 후 SMTP 줄이 보이지 않는다면 `SMTP_HOST`가 프로세스에 도달하지 못한 것이므로, 컨테이너 환경(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)을 확인하세요.
**둘 다 설정하지 않으면**: 서버는 오류를 내지 않지만, **전송되어야 했던 모든 이메일이 서버의 stdout에만 기록됩니다**. 로컬 개발에는 편리하지만(로그에서 코드를 복사하면 됩니다), 프로덕션에서는 블랙홀이 됩니다.

View File

@@ -72,6 +72,15 @@ SMTP_TLS=implicit # optional on 465; required on a non-standard SMT
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**Strict public relays (e.g. Google Workspace `smtp-relay.gmail.com`)** additionally require a valid EHLO name. They reject the default `localhost` greeting from a public IP, and the relay drops the connection — which surfaces as an opaque `EOF` on a later command (`smtp auth: EOF`) rather than at the greeting. Set `SMTP_EHLO_NAME` to the FQDN the relay expects; it defaults to the machine hostname, which inside a container is usually not a valid FQDN:
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
At startup the server prints which provider it picked, including the negotiated TLS mode — for example `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` or `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…` (or `Resend API` / `DEV mode`). The password is never logged. If you don't see the SMTP line after restart, `SMTP_HOST` didn't reach the process — check the container env (`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`).
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.

View File

@@ -72,6 +72,15 @@ SMTP_TLS=implicit # 465 上可省略;在非标准 SMTPS 端口上
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**严格公网 relay例如 Google Workspace `smtp-relay.gmail.com`**还要求一个合法的 EHLO 名称。它们会拒绝来自公网 IP 的默认 `localhost` 问候relay 随即断开连接——这不会在问候阶段报错,而是在后续某条命令上表现为一个不知所云的 `EOF``smtp auth: EOF`)。把 `SMTP_EHLO_NAME` 设成 relay 期望的 FQDN它默认取机器主机名而在容器内这通常不是合法的 FQDN
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # relay 接受的 FQDN默认取非 FQDN 的)容器主机名
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
启动时 server 会打印当前选择的 provider 和协商出的 TLS 模式,比如 `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` 或 `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…`(或 `Resend API` / `DEV mode`),密码不会出现在日志里。重启后没看到 SMTP 这行,说明 `SMTP_HOST` 没进到进程,确认下容器环境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)。
**两种都不配**server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。

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

@@ -115,7 +115,7 @@ Daemon behavior is configured via flags or environment variables:
|---------|------|--------------|---------|
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `0`(不限制,由看门狗兜底)|
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |

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

@@ -49,10 +49,12 @@ Multica は 2 つの配信バックエンドをサポートします — クラ
| 変数 | デフォルト | 説明 |
|---|---|---|
| `SMTP_HOST` | 空 | SMTP relay のホスト名。これを設定すると SMTP モードが有効になり、Resend を上書きします |
| `SMTP_PORT` | `25` | SMTP ポート。STARTTLS サブミッションには `587` を使用してください。**ポート 465SMTPS / 暗黙的 TLS)はサポートされていません** |
| `SMTP_PORT` | `25` | SMTP ポート。STARTTLS サブミッションには `587` をSMTPS暗黙的 TLS、自動有効化)には `465` を使用します |
| `SMTP_USERNAME` | 空 | SMTP ユーザー名。認証なしの relay の場合は空のままにしてください |
| `SMTP_PASSWORD` | 空 | SMTP パスワード |
| `SMTP_TLS` | `starttls` | TLS モード。`implicit`(別名 `smtps`、`ssl`)は接続時に即座に TLS ハンドシェイクを行いますSMTPS。`465` ポートでは自動的に有効になります。未設定 / `starttls` の場合は接続後に STARTTLS でアップグレードします |
| `SMTP_TLS_INSECURE` | `false` | TLS 証明書の検証をスキップするには `true` に設定(プライベート CA / 自己署名証明書のみ) |
| `SMTP_EHLO_NAME` | マシンのホスト名 | relay に通知する EHLO/HELO 名。厳格な relay例: Google Workspace `smtp-relay.gmail.com`)が公開 IP からのデフォルトの挨拶を拒否する場合は、実際の FQDN を設定してください — そうしないと relay が接続を切断し、後続のコマンドで不明瞭な `EOF` として表面化します |
サーバーが STARTTLS を通知すると自動的にアップグレードされます。dial タイムアウトは 10 秒で、SMTP セッション全体には 30 秒のデッドラインがあるため、ブラックホール化した relay が auth ハンドラーをハングさせることはできません。
@@ -84,15 +86,19 @@ Multica はユーザーがアップロードした添付ファイル(コメン
| `S3_REGION` | `us-west-2` | AWS リージョン。バケットの実際のリージョンと一致する必要があります — SDK 署名と公開 URL の構築の両方に使われます |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静的な認証情報。両方を設定しない場合は AWS SDK のデフォルト認証情報チェーンIAM role / 環境認証情報)が使われます |
| `AWS_ENDPOINT_URL` | 空 | カスタムの S3 互換エンドポイント(例: [MinIO](https://min.io/))。これを設定すると path-style URL に切り替わります |
| `ATTACHMENT_DOWNLOAD_MODE` | `auto` | 添付ファイルのダウンロード方式: `auto`、`cloudfront`、`presign`、`proxy`。`auto` では CloudFront が完全に設定されている場合は優先し、内部/プライベート endpoint host は server proxy、公開 S3 互換 endpoint は対応時に presigned GET を使います |
| `ATTACHMENT_DOWNLOAD_URL_TTL` | `30m` | CloudFront signed URL と S3 presigned download URL の有効期間。Go duration 形式を受け付けます |
**`S3_BUCKET` を設定しない場合**: サーバーは起動時に `"S3_BUCKET not set, cloud upload disabled"` をログに記録し、すべてのアップロードはローカルディスクにフォールバックします。
**公開 URL** は次の優先順位で構築されます。
**保存されるオブジェクト URL** は次の優先順位で構築されます。
1. `CLOUDFRONT_DOMAIN` が設定されている場合は `https://<CLOUDFRONT_DOMAIN>/<key>`。
2. `AWS_ENDPOINT_URL` が設定されている場合は `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>`path-style
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>`virtual-hosted-style。`S3_BUCKET` にドットが含まれる場合、AWS が発行するワイルドカード TLS 証明書がドットを含むバケットホストを検証できないため、サーバーは `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>`path-styleにフォールバックします。
API の `download_url` は、CloudFront 署名が設定されていない場合 `GET /api/attachments/{id}/download` を使います。この endpoint は安全な場合 CloudFront/S3 presigned URL にリダイレクトし、`http://rustfs:9000` のようなプライベート/内部 endpoint では server がストリーミングします。Docker/VPC 内部のオブジェクトストアでは `ATTACHMENT_DOWNLOAD_MODE=proxy` を明示できます。
### ローカルディスクS3 が設定されていない場合)
| 変数 | デフォルト | 説明 |
@@ -192,18 +198,24 @@ S3 の前段に CloudFront を置く場合、3 つの変数が適用されます
## GitHub 連携
[GitHub PR ↔ イシュー連携](/github-integration)には 2 つの変数が必要です。設定で Connect GitHub を有効にし、受信 webhook を受け付けるには両方を設定してください。
[GitHub PR ↔ イシュー連携](/github-integration)には 2 つの必須変数が必要です。設定で Connect GitHub を有効にし、受信 webhook を受け付けるには両方を設定してください。さらに 2 つの任意変数を設定すると、インストール時点で連携先のアカウント名を取得できます。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | GitHub App の slug`https://github.com/apps/<slug>` の末尾部分)。設定 → GitHub のインストールボタン URL を構成します |
| `GITHUB_WEBHOOK_SECRET` | 空 | GitHub App に設定した Webhook secret。すべての `pull_request` / `installation` delivery の HMAC-SHA256 検証に使われ、setup コールバックの state token の HMAC キーとしても使われます |
| `GITHUB_APP_ID` | 空 | 任意。App 設定ページに表示される数値の App ID。`GITHUB_APP_PRIVATE_KEY` と併せて設定すると、setup コールバックがインストール時点で GitHub から連携先のアカウント名を取得できます |
| `GITHUB_APP_PRIVATE_KEY` | 空 | 任意。App の RSA 秘密鍵の完全な PEM ブロック(`-----BEGIN/END-----` 行を含み、改行を保ったまま。GitHub の App 認証 REST 呼び出しに必要な短命 JWT の発行に使われます |
**どちらかが設定されていない場合の動作:**
**いずれかの必須変数が設定されていない場合の動作:**
- 設定 → GitHub の `Connect GitHub` が**無効**になり、admin に「not configured」というヒントを表示します。
- `/api/webhooks/github` エンドポイントは **`503 github webhooks not configured`** を返します — Multica はすべての署名を有効として扱うのではなく、secret なしではイベント処理を拒否します。
**任意の `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` が設定されていない場合の動作:**
- インストール直後、接続カードには一時的に `Connected to unknown` と表示されます。GitHub から `installation.created` webhook が届くと通常は数秒以内、Multica は行を実際の組織名/ユーザー名に更新し、リアルタイムブロードキャストを発行するため、開いている Settings → GitHub タブは手動更新なしで反映されます。
**注:** `GITHUB_WEBHOOK_SECRET` はインストールフローの state token の署名キーとして再利用されるため、運用者は secret を 1 つだけ管理すればよいです。これは GitHub App の *Client* secret では**ありません** — Client secret は OAuth 関連であり、この連携では使われません。完全な手順は [GitHub 連携 → セルフホストのセットアップ](/github-integration#self-host-setup)を参照してください。
## 使用量分析

View File

@@ -49,10 +49,12 @@ Multica는 두 가지 전송 백엔드를 지원합니다 — 클라우드 배
| 변수 | 기본값 | 설명 |
|---|---|---|
| `SMTP_HOST` | 비어 있음 | SMTP relay 호스트명. 이를 설정하면 SMTP 모드가 활성화되고 Resend를 덮어씁니다 |
| `SMTP_PORT` | `25` | SMTP 포트. STARTTLS 제출에는 `587`을 사용하세요; **포트 465(SMTPS / 암묵적 TLS)는 지원되지 않습니다** |
| `SMTP_PORT` | `25` | SMTP 포트. STARTTLS 제출에는 `587`을, SMTPS(암묵적 TLS, 자동 활성화)에는 `465`를 사용하세요 |
| `SMTP_USERNAME` | 비어 있음 | SMTP 사용자명. 인증 없는 relay의 경우 비워 두세요 |
| `SMTP_PASSWORD` | 비어 있음 | SMTP 비밀번호 |
| `SMTP_TLS` | `starttls` | TLS 모드. `implicit`(별칭 `smtps`, `ssl`)은 연결 시 즉시 TLS 핸드셰이크를 수행합니다(SMTPS). `465` 포트에서는 자동으로 활성화됩니다. 미설정 / `starttls`는 연결 후 STARTTLS로 업그레이드합니다 |
| `SMTP_TLS_INSECURE` | `false` | TLS 인증서 검증을 건너뛰려면 `true`로 설정 (사설 CA / 자체 서명 인증서만 해당) |
| `SMTP_EHLO_NAME` | 머신 호스트명 | relay에 알리는 EHLO/HELO 이름. 엄격한 relay(예: Google Workspace `smtp-relay.gmail.com`)가 공개 IP에서 보내는 기본 greeting을 거부하는 경우 실제 FQDN을 설정하세요 — 그렇지 않으면 relay가 연결을 끊고, 이는 이후 명령에서 불투명한 `EOF`로 나타납니다 |
서버가 STARTTLS를 알리면 자동으로 업그레이드됩니다. dial 타임아웃은 10초이고 전체 SMTP 세션에는 30초 데드라인이 있어, 블랙홀이 된 relay가 auth 핸들러를 멈추게 할 수 없습니다.
@@ -84,15 +86,19 @@ Multica는 사용자가 업로드한 첨부 파일(댓글의 이미지와 파일
| `S3_REGION` | `us-west-2` | AWS 리전. 버킷의 실제 리전과 일치해야 합니다 — SDK 서명과 공개 URL 구성 모두에 사용됩니다 |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 비어 있음 | 정적 자격 증명. 둘 다 설정하지 않으면 AWS SDK 기본 자격 증명 체인(IAM role / 환경 자격 증명)이 사용됩니다 |
| `AWS_ENDPOINT_URL` | 비어 있음 | 사용자 정의 S3 호환 엔드포인트 (예: [MinIO](https://min.io/)). 이를 설정하면 path-style URL로 전환됩니다 |
| `ATTACHMENT_DOWNLOAD_MODE` | `auto` | 첨부 파일 다운로드 방식: `auto`, `cloudfront`, `presign`, `proxy`. `auto`에서는 CloudFront가 완전히 설정되어 있으면 우선 사용하고, 내부/프라이빗 endpoint host는 server proxy를, 공개 S3 호환 endpoint는 지원되는 경우 presigned GET을 사용합니다 |
| `ATTACHMENT_DOWNLOAD_URL_TTL` | `30m` | CloudFront signed URL 및 S3 presigned download URL의 유효 기간. Go duration 형식을 받습니다 |
**`S3_BUCKET`을 설정하지 않으면**: 서버는 시작 시 `"S3_BUCKET not set, cloud upload disabled"`를 로깅하고, 모든 업로드는 로컬 디스크로 폴백합니다.
**공개 URL**은 다음 우선순위 순서로 구성됩니다:
**저장된 객체 URL**은 다음 우선순위 순서로 구성됩니다:
1. `CLOUDFRONT_DOMAIN`이 설정된 경우 `https://<CLOUDFRONT_DOMAIN>/<key>`.
2. `AWS_ENDPOINT_URL`이 설정된 경우 `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style).
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). `S3_BUCKET`에 점이 포함된 경우, AWS가 발급한 와일드카드 TLS 인증서가 점이 포함된 버킷 호스트를 검증하지 못하므로 서버는 `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style)로 폴백합니다.
API `download_url` 값은 CloudFront 서명이 설정되지 않은 경우 `GET /api/attachments/{id}/download`를 사용합니다. 이 endpoint는 안전한 경우 CloudFront/S3 presigned URL로 리디렉션하고, `http://rustfs:9000` 같은 프라이빗/내부 endpoint에서는 server가 스트리밍합니다. Docker/VPC 내부 객체 저장소에서는 `ATTACHMENT_DOWNLOAD_MODE=proxy`를 명시할 수 있습니다.
### 로컬 디스크 (S3가 설정되지 않은 경우)
| 변수 | 기본값 | 설명 |
@@ -192,18 +198,24 @@ S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLO
## GitHub 연동
[GitHub PR ↔ 이슈 연동](/github-integration)에는 두 개의 변수가 필요합니다. 설정에서 Connect GitHub를 활성화하고 들어오는 webhook을 수락하려면 둘 다 설정하세요.
[GitHub PR ↔ 이슈 연동](/github-integration)에는 두 개의 필수 변수가 필요합니다. 설정에서 Connect GitHub를 활성화하고 들어오는 webhook을 수락하려면 둘 다 설정하세요. 추가로 두 개의 선택 변수를 설정하면 설치 시점에 연결된 계정 이름을 즉시 가져올 수 있습니다.
| 변수 | 기본값 | 설명 |
|---|---|---|
| `GITHUB_APP_SLUG` | 비어 있음 | GitHub App의 slug (`https://github.com/apps/<slug>`의 끝부분). 설정 → GitHub 설치 버튼 URL을 구성합니다 |
| `GITHUB_WEBHOOK_SECRET` | 비어 있음 | GitHub App에 설정한 Webhook secret. 모든 `pull_request` / `installation` delivery의 HMAC-SHA256 검증에 사용되며, setup 콜백 state token의 HMAC 키로도 사용됩니다 |
| `GITHUB_APP_ID` | 비어 있음 | 선택. App 설정 페이지에 표시되는 숫자 App ID. `GITHUB_APP_PRIVATE_KEY`와 함께 설정하면 setup 콜백이 설치 시점에 GitHub에서 연결된 계정 이름을 가져올 수 있습니다 |
| `GITHUB_APP_PRIVATE_KEY` | 비어 있음 | 선택. App RSA 비공개 키의 전체 PEM 블록 (`-----BEGIN/END-----` 줄 포함, 줄바꿈 유지). GitHub의 App 인증 REST 호출에 필요한 단명 JWT를 발급하는 데 사용됩니다 |
** 중 하나라도 설정하지 않았을 때의 동작:**
**필수 변수 중 하나라도 설정하지 않았을 때의 동작:**
- 설정 → GitHub의 `Connect GitHub`가 **비활성화**되고 admin에게 "not configured" 힌트를 표시합니다.
- `/api/webhooks/github` 엔드포인트는 **`503 github webhooks not configured`**를 반환합니다 — Multica는 모든 서명을 유효한 것으로 취급하기보다, secret 없이는 이벤트 처리를 거부합니다.
**선택 `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY`가 설정되지 않았을 때의 동작:**
- 설치 직후 연결 카드에 잠시 `Connected to unknown`이 표시됩니다. GitHub의 `installation.created` 웹훅이 도착하면(보통 몇 초 이내) Multica가 행을 실제 조직/사용자 이름으로 갱신하고 실시간 브로드캐스트를 보내, 열려 있는 Settings → GitHub 탭이 수동 새로고침 없이 업데이트됩니다.
**참고:** `GITHUB_WEBHOOK_SECRET`은 설치 흐름 state token의 서명 키로 재사용되므로, 운영자는 secret 하나만 관리하면 됩니다. 이것은 GitHub App의 *Client* secret이 **아닙니다** — Client secret은 OAuth 관련이며 이 연동에서는 사용되지 않습니다. 전체 안내는 [GitHub 연동 → 자체 호스팅 설정](/github-integration#self-host-setup)을 참고하세요.
## 사용량 분석

View File

@@ -54,6 +54,7 @@ Multica supports two delivery backends — [Resend](https://resend.com/) for clo
| `SMTP_PASSWORD` | empty | SMTP password |
| `SMTP_TLS` | `starttls` | TLS mode. `implicit` (aliases `smtps`, `ssl`) forces an immediate TLS handshake on connect (SMTPS); port `465` auto-enables it. Unset / `starttls` upgrades via STARTTLS after connect |
| `SMTP_TLS_INSECURE` | `false` | Set `true` to skip TLS certificate verification (private CA / self-signed only) |
| `SMTP_EHLO_NAME` | machine hostname | EHLO/HELO name announced to the relay. Set a real FQDN when a strict relay (e.g. Google Workspace `smtp-relay.gmail.com`) rejects the default greeting from a public IP — otherwise the relay drops the connection and it surfaces as an opaque `EOF` on a later command |
STARTTLS is upgraded automatically when the server advertises it. The dial timeout is 10s and the whole SMTP session has a 30s deadline, so a black-holed relay can't hang the auth handler.
@@ -85,15 +86,19 @@ Multica stores user-uploaded attachments (images and files in comments). **S3 is
| `S3_REGION` | `us-west-2` | AWS region. Must match the bucket's actual region — it is used both for SDK signing and for building the public URL |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | empty | Static credentials. When both are unset, the AWS SDK default credential chain is used (IAM role / environment credentials) |
| `AWS_ENDPOINT_URL` | empty | Custom S3-compatible endpoint (for example [MinIO](https://min.io/)). Setting this switches to path-style URLs |
| `ATTACHMENT_DOWNLOAD_MODE` | `auto` | Attachment download path: `auto`, `cloudfront`, `presign`, or `proxy`. In `auto`, CloudFront is preferred when fully configured; internal/private endpoint hosts use the server proxy; public S3-compatible endpoints use presigned GET URLs when supported |
| `ATTACHMENT_DOWNLOAD_URL_TTL` | `30m` | TTL for CloudFront signed URLs and S3 presigned download URLs. Accepts Go duration strings |
**When `S3_BUCKET` is unset**: the server logs `"S3_BUCKET not set, cloud upload disabled"` at startup, and all uploads fall back to local disk.
**Public URLs** are constructed in this order of priority:
**Stored object URLs** are constructed in this order of priority:
1. `https://<CLOUDFRONT_DOMAIN>/<key>` if `CLOUDFRONT_DOMAIN` is set.
2. `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style) if `AWS_ENDPOINT_URL` is set.
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). When `S3_BUCKET` contains dots, the server falls back to `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style) because the AWS-issued wildcard TLS certificate does not validate dotted bucket hosts.
API `download_url` values use `GET /api/attachments/{id}/download` unless CloudFront signing is configured. The endpoint redirects to CloudFront/S3 presigned URLs when safe, or streams through the server for private/internal endpoints such as `http://rustfs:9000`. For Docker/VPC-only object stores, set `ATTACHMENT_DOWNLOAD_MODE=proxy` if auto detection is not conservative enough for your network.
### Local disk (when S3 is not configured)
| Variable | Default | Description |
@@ -195,18 +200,24 @@ For a full explanation of how each parameter affects daemon behavior, see [Daemo
## GitHub integration
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks.
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks. Two additional variables are optional but populate the connected account name on install.
| Variable | Default | Description |
|---|---|---|
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → GitHub install button URL |
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
| `GITHUB_APP_ID` | empty | Optional. Numeric App ID from the App's settings page. Combined with `GITHUB_APP_PRIVATE_KEY`, lets the setup callback fetch the connected account name from GitHub immediately on install |
| `GITHUB_APP_PRIVATE_KEY` | empty | Optional. Full PEM block of the App's RSA private key (including `-----BEGIN/END-----` lines, newlines preserved). Used to mint the short-lived JWT GitHub requires for App-authenticated REST calls |
**Behavior when either is unset:**
**Behavior when either of the required variables is unset:**
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
**Behavior when the optional `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` are unset:**
- The connection card briefly shows `Connected to unknown` after install. Multica refreshes the row to the real org/user name as soon as GitHub delivers the `installation.created` webhook (typically within a few seconds), and broadcasts a realtime update so any open Settings → GitHub tab reflects the change without a manual refresh.
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
## Usage analytics

View File

@@ -54,6 +54,7 @@ Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合
| `SMTP_PASSWORD` | 空 | SMTP 密码 |
| `SMTP_TLS` | `starttls` | TLS 模式。`implicit`(别名 `smtps`、`ssl`)在连接时立即进行 TLS 握手SMTPS`465` 端口会自动启用。未设置 / `starttls` 则在连接后通过 STARTTLS 升级 |
| `SMTP_TLS_INSECURE` | `false` | 设为 `true` 跳过 TLS 证书校验(仅限私有 CA / 自签证书)|
| `SMTP_EHLO_NAME` | 机器主机名 | 向 relay 通告的 EHLO/HELO 名称。当严格的 relay例如 Google Workspace `smtp-relay.gmail.com`)拒绝来自公网 IP 的默认问候时,填一个真实的 FQDN——否则 relay 会直接断开连接,并在后续某条命令上表现为一个不知所云的 `EOF` |
服务端 advertise STARTTLS 时会自动升级。dial 超时 10s整个 SMTP 会话有 30s deadline避免 relay 黑洞把 auth handler 挂死。
@@ -85,15 +86,19 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
| `S3_REGION` | `us-west-2` | AWS 区域。必须和 bucket 所在区域一致——SDK 签名和公开 URL 都用它 |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静态凭证。全未设时用 AWS SDK 默认凭证链IAM role / 环境凭证)|
| `AWS_ENDPOINT_URL` | 空 | 自定义 S3 兼容端点(例如 [MinIO](https://min.io/))。设了会切到 path-style URL |
| `ATTACHMENT_DOWNLOAD_MODE` | `auto` | 附件下载路径:`auto`、`cloudfront`、`presign` 或 `proxy`。`auto` 下 CloudFront 配完整时优先 CloudFront内网/私有 endpoint host 走 server proxy公网 S3 兼容 endpoint 在支持时走 presigned GET |
| `ATTACHMENT_DOWNLOAD_URL_TTL` | `30m` | CloudFront signed URL 和 S3 presigned download URL 的有效期。使用 Go duration 格式 |
**`S3_BUCKET` 未设时**server 启动时打 info 日志 `"S3_BUCKET not set, cloud upload disabled"`,所有上传回落到本地磁盘。
**公开 URL** 按优先级拼装:
**对象存储 URL** 按优先级拼装:
1. 设了 `CLOUDFRONT_DOMAIN` → `https://<CLOUDFRONT_DOMAIN>/<key>`
2. 设了 `AWS_ENDPOINT_URL` → `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>`path-style
3. 默认走 AWS S3 → `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>`virtual-hosted-style。bucket 名含点时会回落到 `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>`path-style因为 AWS 通配证书无法覆盖含点 host。
API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /api/attachments/{id}/download`。这个端点会在安全时跳转到 CloudFront/S3 presigned URL遇到 `http://rustfs:9000` 这类私有或内网 endpoint 时则由 server 流式转发。Docker/VPC 内部对象存储建议显式设置 `ATTACHMENT_DOWNLOAD_MODE=proxy`。
### 本地磁盘S3 未配时)
| 环境变量 | 默认值 | 说明 |
@@ -174,6 +179,9 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | 心跳频率 |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | 任务轮询频率 |
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 并发任务上限 |
| `MULTICA_AGENT_TIMEOUT` | `0` | 单次任务的绝对墙钟上限;`0` = 不设上限,任务只受看门狗约束(活跃任务不会因为跑得久被杀)。想要硬性成本/资源天花板时再设一个正值 |
| `MULTICA_AGENT_IDLE_WATCHDOG` | `30m` | 空闲看门狗backend 持续静默(无消息、消息队列为空、且没有工具在途)这么久就 force-stop。`0` = 关闭整套看门狗 |
| `MULTICA_AGENT_TOOL_WATCHDOG` | `2h` | 工具在途时的静默上限:某个工具调用发出后长时间无任何输出(疑似卡死的子进程)这么久就 force-stop。`0` = 关闭该兜底(在途工具永不被停)|
| `MULTICA_<PROVIDER>_PATH` | 对应 CLI 名 | 各 AI 编程工具的可执行文件路径(如 `MULTICA_CLAUDE_PATH`|
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI 编程工具的默认模型 |
@@ -195,18 +203,24 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
## GitHub 集成
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个必填环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。另外两个可选变量用于在安装时直接拿到关联账号名。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug`https://github.com/apps/<slug>` 的尾部。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
| `GITHUB_APP_ID` | 空 | 可选。App 设置页上的数字 App ID。配合 `GITHUB_APP_PRIVATE_KEY` 使用,让 setup 回调在安装那一刻直接从 GitHub 取到关联账号名 |
| `GITHUB_APP_PRIVATE_KEY` | 空 | 可选。App RSA 私钥的完整 PEM 块(包含 `-----BEGIN/END-----` 两行,保留换行)。用于签发 GitHub App 鉴权 REST 调用所需的短效 JWT |
**任一变量未设时:**
**任一必填变量未设时:**
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
**可选 `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` 未设时:**
- 安装完成后,连接卡片会先短暂显示 `已连接到 unknown`。等 GitHub 的 `installation.created` webhook 到达通常几秒内Multica 会把 row 刷成真实的组织/用户名,并通过 realtime 推送让正在打开的 Settings → GitHub 页面无需手动刷新即可更新。
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
## 用量统计

View File

@@ -51,7 +51,7 @@ cd multica
make selfhost
```
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET` and Postgres password, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
@@ -63,7 +63,7 @@ Once ready:
- **Backend API:** http://localhost:8080
<Callout>
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, `POSTGRES_PASSWORD`, and the password segment in `DATABASE_URL`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
</Callout>
### Step 2 — Log In
@@ -133,17 +133,54 @@ Alternatively, configure step by step: `multica config set server_url http://loc
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent
## Usage Dashboard Rollup (Required)
## Usage Dashboard Rollup
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` image does **not** include `pg_cron`, and the backend doesn't run the rollup in-process either — until you schedule it yourself, the dashboard will stay at zero even though `task_usage` is populated.
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. As of MUL-2957 the backend runs this rollup **in-process** on every replica via a DB-backed scheduler (`sys_cron_executions`). A fresh self-host install needs no operator action — the bundled `pgvector/pgvector:pg17` image works as-is, and you do **not** need to swap it for an image that ships `pg_cron`, register an external cron job, run a systemd timer, or schedule a Kubernetes `CronJob`.
Pick one supported path before relying on the Usage / Runtime dashboard:
Multiple backend replicas are safe: every replica ticks every 30 seconds and tries to claim the current 5-minute UTC plan, but the unique key `(job_name, scope_kind, scope_id, plan_time)` means only one wins each plan. Inspect the audit table to confirm steady-state operation:
- **External cron / systemd-timer / Kubernetes `CronJob`**: schedule `SELECT rollup_task_usage_hourly()` every 5 minutes. Idempotent, watermark-driven — overlapping or skipped ticks are safe.
- **Postgres with `pg_cron`**: swap the bundled Postgres image for one that ships `pg_cron`, set `shared_preload_libraries=pg_cron`, then `SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', 'SELECT rollup_task_usage_hourly()')` once.
- **Backfill historical data**: required on the `v0.3.4 → v0.3.5+` upgrade path when the database already has `task_usage` rows — migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run `./backfill_task_usage_hourly --sleep-between-slices=2s` inside the backend container, then re-run the upgrade and configure one of the schedules above.
```sql
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;
```
Full reference (Compose + Kubernetes templates, flag descriptions, upgrade order) lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
<Callout>
**Upgrading from `v0.3.4` to `v0.3.5+`?** As of MUL-2957 the `migrate up` command runs an idempotent monthly-slice backfill automatically right before applying migration `103`, so the upgrade completes in a single invocation — no operator step required. If you are on a pre-MUL-2957 binary or the auto-hook fails for an environmental reason, run `backfill_task_usage_hourly` against the same database and re-run the upgrade. Full recovery flow lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
</Callout>
### Compatibility paths (existing deployments only)
External schedulers — **`pg_cron` registered on the database, an external cron job, a systemd timer, or a Kubernetes `CronJob`** — that call `SELECT rollup_task_usage_hourly()` directly were the only option before MUL-2957 and remain a supported compatibility path. They are no longer the recommended setup; new deployments should rely on the in-process scheduler. The SQL function holds advisory lock 4246 internally, so the in-process scheduler and any pre-existing external schedule can coexist without ever double-writing the rollup.
If you already have a `pg_cron` job in production and want to retire it, the safe sequence is:
1. Confirm the in-process scheduler is healthy on at least one backend replica — recent SUCCESS rows should be landing in `sys_cron_executions` for `rollup_task_usage_hourly`:
```sql
SELECT plan_time, status, runner_id, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
AND status = 'SUCCESS'
ORDER BY plan_time DESC
LIMIT 5;
```
2. Once SUCCESS rows are arriving on schedule, unschedule the redundant `pg_cron` entry:
```sql
SELECT cron.unschedule('rollup_task_usage_hourly')
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
```
3. Leave the `pg_cron` extension itself installed unless you are sure no other workload depends on it. The bundled `pgvector/pgvector:pg17` image does **not** ship `pg_cron`, so nothing in Multica's default install needs it; uninstalling `pg_cron` from a custom image that other workloads still use is a separate decision.
External cron / systemd timer / Kubernetes `CronJob` setups that call `SELECT rollup_task_usage_hourly()` directly can be retired the same way — once `sys_cron_executions` shows steady SUCCESS rows from the in-process scheduler, the external job is redundant and can be removed.
Full reference (audit table semantics, advisory lock 4246, the standalone backfill command, flag descriptions, the migration auto-hook) lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
## Stopping Services
@@ -189,7 +226,8 @@ All configuration is done via environment variables. Copy `.env.example` as a st
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `DATABASE_URL` | PostgreSQL connection string. Keep the password segment in sync with `POSTGRES_PASSWORD`. | `postgres://multica:<postgres-password>@localhost:5432/multica?sslmode=disable` |
| `POSTGRES_PASSWORD` | **Must change from default.** Password used by the bundled Postgres container. Keep it in sync with `DATABASE_URL`. | `openssl rand -hex 24` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |

View File

@@ -114,13 +114,20 @@ API サーバーで:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
# 任意(推奨)— インストール時点で接続済みアカウント名を取得できるため、
# 最初の webhook が届くまで待たなくて済みます:
GITHUB_APP_ID=<App 設定ページに表示される数値の App ID>
GITHUB_APP_PRIVATE_KEY=<BEGIN/END 行を含む完全な PEM ブロック>
```
両方の変数が必須です。どちらかが欠けていると:
`GITHUB_APP_SLUG` と `GITHUB_WEBHOOK_SECRET` は必須です。どちらかが欠けていると:
- Settings の `Connect GitHub` が**無効**になり、「not configured」のヒントが表示されます。
- `/api/webhooks/github` エンドポイントが **`503 github webhooks not configured`** を返します — Multica は secret なしでイベントを処理することを拒否し、すべての署名を黙って有効として扱うことはありません。
`GITHUB_APP_ID` と `GITHUB_APP_PRIVATE_KEY` は**任意**です。これらを設定すると、setup コールバックが GitHub の App 認証された `/app/installations/{id}` エンドポイントを呼び出して、インストール時点で実際の組織名やユーザー名を取得できます。設定しない場合、接続カードには一時的に `Connected to unknown` と表示され、GitHub から `installation.created` webhook が届くと通常は数秒以内にMultica が行を更新し、リアルタイムブロードキャストを発行するため、開いている Settings タブは手動更新なしで反映されます。秘密鍵は App 設定ページの **Private keys → Generate a private key** で生成し、PEM ブロック全体(`-----BEGIN/END RSA PRIVATE KEY-----` の行を含む)を改行を保ったまま env 変数に貼り付けてください。
`FRONTEND_ORIGIN` も設定されている必要がありますどのプロダクションのセルフホストでもすでに設定されています。インストール後、setup コールバックがユーザーを `<FRONTEND_ORIGIN>/settings?tab=github` に戻します。
env 変数を設定した後は API を再起動してください。

View File

@@ -114,13 +114,20 @@ API 서버에서:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
# 선택(권장) — 설치 직후 연결된 계정 이름을 바로 확보합니다.
# 설정하지 않으면 첫 webhook이 도착할 때까지 대기해야 합니다:
GITHUB_APP_ID=<App 설정 페이지에 표시되는 숫자 App ID>
GITHUB_APP_PRIVATE_KEY=<BEGIN/END 줄을 포함한 전체 PEM 블록>
```
두 변수 모두 필수입니다. 둘 중 하나라도 누락되면:
`GITHUB_APP_SLUG`와 `GITHUB_WEBHOOK_SECRET`은 필수입니다. 둘 중 하나라도 누락되면:
- Settings의 `Connect GitHub`이 **비활성화**되고 "not configured" 힌트가 표시됩니다.
- `/api/webhooks/github` 엔드포인트가 **`503 github webhooks not configured`**를 반환합니다 — Multica는 secret 없이 이벤트를 처리하기를 거부하며, 모든 서명을 조용히 유효한 것으로 취급하지 않습니다.
`GITHUB_APP_ID`와 `GITHUB_APP_PRIVATE_KEY`는 **선택 사항**입니다. 설정하면 setup 콜백이 GitHub의 App 인증 `/app/installations/{id}` 엔드포인트를 호출해 설치 직후에 실제 조직명/사용자명을 가져옵니다. 설정하지 않으면 연결 카드에 잠시 `Connected to unknown`이 표시되며, GitHub의 `installation.created` 웹훅이 도착하면(보통 몇 초 이내) Multica가 행을 갱신하고 실시간 브로드캐스트를 보내므로 열려 있는 Settings 탭이 수동 새로고침 없이 업데이트됩니다. 비공개 키는 App 설정 페이지의 **Private keys → Generate a private key**에서 생성한 뒤, PEM 블록 전체(`-----BEGIN/END RSA PRIVATE KEY-----` 줄 포함)를 줄바꿈을 유지한 채 env 값에 붙여넣으세요.
`FRONTEND_ORIGIN`도 설정되어 있어야 합니다(어떤 프로덕션 자체 호스팅이든 이미 설정되어 있습니다). 설치 후 setup 콜백이 사용자를 `<FRONTEND_ORIGIN>/settings?tab=github`으로 다시 돌려보냅니다.
env 변수를 설정한 후 API를 재시작하세요.

View File

@@ -114,13 +114,20 @@ On the API server:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
# Optional but recommended — populates the connected account name on
# install instead of waiting for the first webhook to refresh it:
GITHUB_APP_ID=<numeric App ID from the App's settings page>
GITHUB_APP_PRIVATE_KEY=<full PEM block, including BEGIN/END lines>
```
Both variables are required. If either is missing:
`GITHUB_APP_SLUG` and `GITHUB_WEBHOOK_SECRET` are required. If either is missing:
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
`GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` are **optional**. They let the setup callback call GitHub's App-authenticated `/app/installations/{id}` endpoint to fetch the real organization or user name during install. Without them, the connection card briefly shows `Connected to unknown` until GitHub delivers the `installation.created` webhook (typically within a few seconds), at which point Multica refreshes the row and broadcasts a realtime update so any open Settings tab updates without a manual refresh. Generate the private key under **Private keys → Generate a private key** on the App's settings page; paste the full PEM block (including the `-----BEGIN/END RSA PRIVATE KEY-----` lines) into the env var, preserving newlines.
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings?tab=github` after install.
Restart the API after setting the env vars.

View File

@@ -114,13 +114,19 @@ API server 上:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
# 可选但建议配置——安装完成时直接拿到关联账号名,而不是等第一次 webhook 才刷新:
GITHUB_APP_ID=<App 设置页上的数字 App ID>
GITHUB_APP_PRIVATE_KEY=<完整 PEM 块,包含 BEGIN/END 行>
```
两个都必填。任何一个缺失:
`GITHUB_APP_SLUG` 和 `GITHUB_WEBHOOK_SECRET` 必填。任何一个缺失:
- Settings 里 `Connect GitHub` 按钮会被 **disable**并显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
`GITHUB_APP_ID` 和 `GITHUB_APP_PRIVATE_KEY` **可选**。配上之后setup 回调可以用 App JWT 鉴权调用 GitHub `/app/installations/{id}`,安装完成那一刻就拿到真实的组织名/用户名。不配的话,连接卡片会先显示 `已连接到 unknown`,等 GitHub 的 `installation.created` webhook 到达通常几秒内Multica 会刷新 row 并通过 realtime 推送让 Settings 页面无需手动刷新即可更新。私钥从 App 设置页 **Private keys → Generate a private key** 生成,把整段 PEM含 `-----BEGIN/END RSA PRIVATE KEY-----` 两行)粘到 env 里,保留换行。
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings?tab=github`。
设完 env 重启 API。

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

@@ -0,0 +1,95 @@
---
title: Lark Bot 連携
description: Multica エージェントを Lark飞书Bot に紐づければ、Lark の DM やグループからそのまま対話できます——@ でメンションして自然に話しかけたり、/issue と入力して Lark を離れずに Multica イシューを起票したりできます。
---
import { Callout } from "fumadocs-ui/components/callout";
任意の[エージェント](/agents)を Lark飞书Bot に紐づければ、チームは Lark の中から直接それを使えます——Bot に DM したり、グループで @ メンションしたり、`/issue` と入力してアプリを開かずに [Multica イシュー](/issues)を起票したりできます。エージェントの返信は、作業の進行に合わせて更新されるライブカードとしてチャットに戻ってきます。
各 Bot は 1 つの Multica エージェントと **1 対 1** で紐づきます。2 つ目のエージェントを紐づけると 2 つ目の Bot が作られます。1 つのエージェントが 2 つの Bot を持つことはありません。
## この連携でできること
| 場所 | 動作 |
|---|---|
| **エージェント → 連携** | エージェント詳細ページには **連携Integrations** タブがあります左サイドバーにも対応する区画があります。owner と admin はそこに **Lark に紐づける** が表示され、紐づけると **Lark に接続済み** バッジと **Lark で管理** リンクに切り替わります。 |
| **Bot に DM** | ワークスペースメンバーが Lark の中で Bot に直接メッセージを送ります。各会話はそのエージェントとの Multica [chat](/chat) セッションになり、エージェントはスレッド内で返信します。 |
| **グループで @ メンション** | Bot を Lark グループに追加して @ メンションします。読み取られるのはメンションしたメッセージだけで、Bot はグループ全体を聞いているわけではありません。 |
| **`/issue` コマンド** | `/issue <タイトル>`(本文を続けてもよい)と入力すると、ワークスペースに新しい Multica イシューが作られ、あなたの名義になります。 |
| **ライブ返信カード** | Bot はインタラクティブなカードを投稿し、エージェントの実行に合わせて更新し続けます——進捗、最終的な回答、あるいはエラーが反映されます。 |
## エージェントを紐づけるowner / admin
紐づけはスキャンしてインストールするフローです——アプリのシークレットをコピーする必要も、開発者コンソールでの操作も不要です。
1. **Agents → あなたのエージェント** からそのエージェントを開きます。
2. **連携Integrations** タブ(または左サイドバーの **連携** 区画)を開き、**Lark に紐づける** をクリックします。
3. QR コードが表示されます。スマートフォンで **Lark → スキャン** を開き、新しい PersonalAgent Bot を認可します。
4. スキャンが完了するとダイアログが閉じ、エージェントに **Lark に接続済み** と表示されます。あなた自身の Lark アイデンティティは自動であなたの Multica アカウントに紐づくので、すぐに Bot と対話を始められます。
<Callout type="info">
QR は使い切りで、短い時間が過ぎると失効します。認可する前に失効してしまったら、**もう一度スキャン** をクリックして新しいコードを取得してください。
</Callout>
エージェントが接続されると、**Lark に紐づける** ボタンは **Lark で管理** リンクに置き換わります。スコープの調整、名前の変更、追加の権限の申請が必要なときは、これを使って Lark 内の Bot のアプリページを開いてください——再スキャンは意図的に無効化されており、既存の Bot を取り残してしまわないようにしています。
## Bot を使う(メンバー)
### 最初のメッセージLark アイデンティティを紐づける
初めて Bot にメッセージを送ると、Bot は **Lark アイデンティティを紐づける** よう促すカードで返信します。リンクをタップして Multica にサインインすると、あなたの Lark アカウントがあなたの Multica メンバーシップに紐づきます。これによって、エージェントがあなたとして振る舞えるようになります——たとえば `/issue` はあなたの名義でイシューを起票します。
<Callout type="warning">
Bot を使えるのは **ワークスペースのメンバー** だけです。メンバーでない場合や、アイデンティティの紐づけをスキップした場合、Bot は返信しません——あなたのメッセージは破棄されます(内容は保存せず、監査のために記録されます)。
</Callout>
### 対話と `/issue`
- **エージェントに何でも聞く** —— Bot に DM するか、グループで @ メンションします。会話は通常のエージェント chat セッションで、エージェントはカードの中で返信します。
- **イシューを起票する** —— `/issue Fix the login redirect` と送れば、Multica は新しいイシューを作るのと同じやり方でそのイシューをワークスペースに作ります。タイトルの後ろに行を足せば、それが説明になります。
- **作業を見守る** —— 返信カードはエージェントの実行に合わせて自身を更新するので、進捗と結果がその場で見えます。
エージェントが **オフライン**(ランタイムが接続されていない)または **アーカイブ済み** の場合、Bot はメッセージを黙って破棄するのではなく、短いステータス通知で返信します。
## 管理と切断
ワークスペース全体の管理は **設定 → 連携** にあります。
- **接続済みの Bot** は、ワークスペース内のすべての Bot と、それぞれが紐づくエージェントを一覧表示します。この一覧はすべてのメンバーから見えます。
- **切断** は **owner / admin 専用** です。切断すると Bot は Lark メッセージの受信を停止し、その接続が破棄されます。インストール記録は監査のために保持され、あとで同じエージェントを再び紐づけられます。
## 権限
- **紐づけ / 切断** にはワークスペースの **owner** または **admin** が必要です。member には接続済み Bot 一覧は見えますが、紐づけや切断の操作は見えません。
- **Bot との対話** には、Lark アイデンティティを紐づけたワークスペースメンバーであることが必要です。それ以外の人のメッセージは一律に破棄されます。
- この連携は破棄されたメッセージの本文を保存することはありません——監査のために破棄理由だけを記録します。
## セルフホストのセットアップ
Multica Cloud では連携はすでに利用可能です——このセクションは飛ばしてください。
セルフホストの場合、**保存時の暗号化キーを設定するまで Lark はオフ** です。このキーは、各 Bot の app secret がデータベースに触れる前にそれを暗号化します。
1. 32 バイトのキーを生成し、API サーバーに設定します。
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
```
2. API を再起動します。キーを設定するまで、**設定 → 連携** には「Lark integration not enabled」という通知が表示され、**Lark に紐づける** のエントリポイントは非表示のままになります。
<Callout type="info">
**Feishu と Lark 国際版の両対応。** 各 Bot がどのクラウド(中国大陸の Feishu = `open.feishu.cn`、国際版 Lark = `open.larksuite.com`に属するかは、QR コードをスキャンした時点で自動的に判定され、そのインストールに保存されて、その Bot へのすべての呼び出しに使われます。1 つのデプロイで両方を同時に提供できるため、どちらのテナントのチームも追加設定なしでバインドできます。
`MULTICA_LARK_HTTP_BASE_URL` / `MULTICA_LARK_CALLBACK_BASE_URL` は、デプロイ全体を上書きする任意のオプション(プロキシやモック用)としてのみ残っています。通常運用では未設定のままにして、各インストールがそれぞれのクラウドに到達するようにしてください。
**単一クラウド構成からのアップグレード?** これらを `https://open.larksuite.com` に設定して国際版 Lark を運用していた場合、アップグレード後の初回起動時にサーバーが既存のインストールを Lark リージョンへ付け替えるので、その後はこの上書きを外せます。中国大陸の Feishu デプロイでは操作は不要です。
</Callout>
## 次に
- [エージェント](/agents) — 各 Bot はちょうど 1 つのエージェントに紐づきます
- [Chat](/chat) — Bot の会話が Multica 内で対応するもの
- [イシュー](/issues) — `/issue` が作るもの
- [環境変数](/environment-variables) — セルフホスト構成の完全なリファレンス

View File

@@ -0,0 +1,95 @@
---
title: Lark Bot 연동
description: Multica 에이전트를 Lark(飞书) 봇에 바인딩하면, Lark에서 직접 대화할 수 있습니다 — 개인 메시지나 그룹에서 @로 멘션하거나, 자연스럽게 대화하거나, /issue를 입력해 Lark를 벗어나지 않고 Multica 이슈를 생성하세요.
---
import { Callout } from "fumadocs-ui/components/callout";
아무 [에이전트](/agents)나 Lark(飞书) 봇에 바인딩하면, 팀이 Lark 안에서 바로 그 에이전트를 사용할 수 있습니다 — 봇에게 개인 메시지를 보내거나, 그룹에서 `@`로 멘션하거나, `/issue`를 입력해 앱을 열지 않고도 [Multica 이슈](/issues)를 생성하세요. 에이전트의 답변은 실시간 카드로 채팅에 돌아오며, 작업이 진행되는 동안 계속 업데이트됩니다.
각 봇은 하나의 Multica 에이전트와 **일대일**로 바인딩됩니다. 두 번째 에이전트를 바인딩하면 두 번째 봇이 생성되며, 하나의 에이전트가 두 개의 봇을 갖는 일은 없습니다.
## 연동이 하는 일
| 위치 | 동작 |
|---|---|
| **에이전트 → Integrations** | 에이전트 상세 페이지에 **Integrations** 탭이 있습니다(왼쪽 사이드바에도 대응하는 섹션이 있습니다). owner와 admin에게는 여기에 **Bind to Lark**가 보이며, 바인딩되면 **Connected to Lark** 배지와 **Manage in Lark** 링크로 바뀝니다. |
| **봇에게 개인 메시지** | 워크스페이스 멤버가 Lark에서 봇에게 직접 메시지를 보냅니다. 각 대화는 그 에이전트와의 Multica [chat](/chat) 세션이 되며, 에이전트는 해당 스레드에서 답변합니다. |
| **그룹에서 `@` 멘션** | 봇을 Lark 그룹에 추가하고 `@`로 멘션하세요. 멘션한 메시지만 읽으며, 봇이 그룹 전체를 듣지는 않습니다. |
| **`/issue` 명령** | `/issue <제목>`(본문 추가 가능)을 입력하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. |
| **실시간 답변 카드** | 봇은 인터랙티브 카드를 게시하고 에이전트가 실행되는 동안 계속 갱신합니다 — 진행 상황, 최종 답변, 또는 오류. |
## 에이전트 바인딩하기 (owner / admin)
바인딩은 스캔하여 설치하는 방식입니다 — 복사할 앱 시크릿도, 개발자 콘솔 작업도 없습니다.
1. **Agents → _당신의 에이전트_**에서 에이전트를 엽니다.
2. **Integrations** 탭으로 이동하거나(또는 왼쪽 사이드바의 **Integrations** 섹션 사용) **Bind to Lark**를 클릭합니다.
3. QR 코드가 나타납니다. 휴대폰에서 **Lark → 스캔**을 열고, 새로 생긴 PersonalAgent 봇을 인증하세요.
4. 스캔이 완료되면 대화상자가 닫히고 에이전트에 **Connected to Lark**가 표시됩니다. 당신의 Lark 신원이 자동으로 Multica 계정에 바인딩되므로, 곧바로 봇과 대화를 시작할 수 있습니다.
<Callout type="info">
QR 코드는 일회용이며 짧은 시간 후에 만료됩니다. 인증하기 전에 만료되면 **Scan again**을 클릭해 새 코드를 받으세요.
</Callout>
에이전트가 연결되면 **Bind to Lark** 버튼이 **Manage in Lark** 링크로 바뀝니다. 권한 범위를 조정하거나, 이름을 바꾸거나, 추가 권한을 요청해야 할 때 이 링크로 Lark에서 봇의 앱 페이지를 여세요 — 기존 봇이 고아가 되지 않도록 재스캔은 의도적으로 비활성화되어 있습니다.
## 봇 사용하기 (멤버)
### 첫 메시지: Lark 신원 바인딩하기
봇에게 처음 메시지를 보내면, **Lark 신원을 바인딩**하라는 카드로 답합니다. 링크를 탭하고 Multica에 로그인하면, 당신의 Lark 계정이 Multica 멤버십에 연결됩니다. 바로 이 단계가 에이전트로 하여금 당신을 대신해 행동하게 합니다 — 예를 들어 `/issue`는 이슈를 당신 이름으로 생성합니다.
<Callout type="warning">
**워크스페이스 멤버**만 봇을 사용할 수 있습니다. 멤버가 아니거나 신원 바인딩을 건너뛰면 봇은 응답하지 않으며, 메시지는 폐기됩니다(감사 목적으로 기록되며, 내용은 저장하지 않습니다).
</Callout>
### 대화와 `/issue`
- **무엇이든 에이전트에게 물어보기** — 봇에게 개인 메시지를 보내거나 그룹에서 `@`로 멘션하세요. 이 대화는 일반적인 에이전트 chat 세션이며, 에이전트는 카드에서 답변합니다.
- **이슈 생성** — `/issue 로그인 리디렉션 수정`을 보내면 Multica가 워크스페이스에 그 이슈를 생성하며, 새 이슈가 으레 할당되는 방식 그대로 처리됩니다. 제목 뒤에 줄을 더 추가하면 설명이 됩니다.
- **작업 지켜보기** — 답변 카드는 에이전트가 실행되는 동안 스스로 갱신되므로, 진행 상황과 결과를 그 자리에서 볼 수 있습니다.
에이전트가 **오프라인**(런타임이 연결되지 않음)이거나 **보관됨** 상태라면, 봇은 메시지를 조용히 폐기하는 대신 짧은 상태 안내로 답합니다.
## 관리 및 연결 해제
워크스페이스 전체 관리는 **설정 → Integrations**에 있습니다.
- **Connected bots**는 워크스페이스 내 모든 봇과 각 봇이 바인딩된 에이전트를 나열합니다. 이 목록은 모든 멤버에게 보입니다.
- **Disconnect**는 **owner / admin 전용**입니다. 연결을 해제하면 봇이 Lark 메시지 수신을 멈추고 연결이 해체됩니다. 설치 기록은 감사용으로 유지되며, 이후 같은 에이전트를 다시 바인딩할 수 있습니다.
## 권한
- **바인딩 / 연결 해제**에는 워크스페이스 **owner** 또는 **admin**이 필요합니다. 멤버에게는 connected-bots 목록은 보이지만 바인딩이나 연결 해제 컨트롤은 보이지 않습니다.
- **봇과 대화하기**에는 Lark 신원이 바인딩된 워크스페이스 멤버여야 합니다. 그 외의 사람은 모두 폐기됩니다.
- 연동은 폐기된 메시지의 본문을 절대 저장하지 않으며 — 감사용 폐기 사유만 기록합니다.
## 자체 호스팅 설정
Multica Cloud에서는 연동이 이미 사용 가능합니다 — 이 섹션은 건너뛰세요.
자체 호스팅의 경우, **at-rest 암호화 키를 설정하기 전까지 Lark는 꺼져 있습니다**. 이 키는 각 봇의 앱 시크릿이 데이터베이스에 닿기 전에 암호화합니다.
1. 32바이트 키를 생성해 API 서버에 설정합니다.
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
```
2. API를 재시작하세요. 키가 설정되기 전까지 **설정 → Integrations**에는 "Lark integration not enabled" 안내가 표시되고, **Bind to Lark** 진입점은 숨겨진 채로 유지됩니다.
<Callout type="info">
**Feishu와 Lark 국제판을 동시에 지원.** 각 Bot이 어느 클라우드(중국 본토 Feishu = `open.feishu.cn`, 국제판 Lark = `open.larksuite.com`)에 속하는지는 QR 코드를 스캔할 때 자동으로 감지되어 해당 설치에 저장되고, 그 Bot에 대한 모든 호출에 사용됩니다. 하나의 배포로 둘을 동시에 제공하므로, 어느 테넌트의 팀이든 추가 설정 없이 바인딩할 수 있습니다.
`MULTICA_LARK_HTTP_BASE_URL` / `MULTICA_LARK_CALLBACK_BASE_URL`는 배포 전체를 덮어쓰는 선택적 오버라이드(프록시나 mock용)로만 남아 있습니다. 일반 운영에서는 설정하지 않은 채로 두어 각 설치가 자기 클라우드에 도달하도록 하세요.
**단일 클라우드 구성에서 업그레이드하나요?** 이 변수들을 `https://open.larksuite.com`으로 설정해 국제판 Lark를 운영했다면, 업그레이드 후 첫 부팅 시 서버가 기존 설치를 Lark 리전으로 다시 표시하므로 이후에는 오버라이드를 지울 수 있습니다. 중국 본토 Feishu 배포에서는 별도 작업이 필요 없습니다.
</Callout>
## 다음
- [에이전트](/agents) — 각 봇은 정확히 하나의 에이전트에 바인딩됩니다
- [Chat](/chat) — 봇 대화가 Multica 내부에서 무엇에 대응하는지
- [이슈](/issues) — `/issue`가 생성하는 것
- [환경 변수](/environment-variables) — 전체 자체 호스팅 구성 참조

View File

@@ -0,0 +1,95 @@
---
title: Lark Bot integration
description: Bind a Multica agent to a Lark (飞书) Bot, then talk to it from a Lark DM or group — @-mention it, chat naturally, or type /issue to file a Multica issue without leaving Lark.
---
import { Callout } from "fumadocs-ui/components/callout";
Bind any [agent](/agents) to a Lark (飞书) Bot and your team can work with it from inside Lark — DM the Bot, @-mention it in a group, or type `/issue` to file a [Multica issue](/issues) without opening the app. The agent's replies stream back into the chat as a live card that updates while it works.
Each Bot is bound **one-to-one** to a single Multica agent. Binding a second agent creates a second Bot; one agent never has two Bots.
## What the integration does
| Surface | Behavior |
|---|---|
| **Agent → Integrations** | The agent detail page has an **Integrations** tab (and a matching section in the left sidebar). Owners and admins see **Bind to Lark** there; once bound it flips to a **Connected to Lark** badge with a **Manage in Lark** link. |
| **DM the Bot** | A workspace member messages the Bot directly in Lark. Each conversation becomes a Multica [chat](/chat) session with the agent; the agent answers in-thread. |
| **@-mention in a group** | Add the Bot to a Lark group and @-mention it. Only the mentioning message is read — the Bot does not listen to the whole group. |
| **`/issue` command** | Typing `/issue <title>` (optionally with a body) creates a new Multica issue in the workspace, attributed to you. |
| **Live reply card** | The Bot posts an interactive card and keeps patching it as the agent runs — progress, the final answer, or an error. |
## Bind an agent (owner / admin)
Binding uses a scan-to-install flow — no app secrets to copy, no developer console steps.
1. Open the agent in **Agents → _your agent_**.
2. Go to the **Integrations** tab (or use the **Integrations** section in the left sidebar) and click **Bind to Lark**.
3. A QR code appears. On your phone, open **Lark → Scan**, then authorize the new PersonalAgent Bot.
4. When the scan completes the dialog closes and the agent shows **Connected to Lark**. Your own Lark identity is bound to your Multica account automatically, so you can start chatting with the Bot right away.
<Callout type="info">
The QR is single-use and expires after a short window. If it lapses before you authorize, click **Scan again** for a fresh code.
</Callout>
Once an agent is connected, the **Bind to Lark** button is replaced by a **Manage in Lark** link. Use it to open the Bot's app page in Lark when you need to adjust scopes, rename it, or request additional permissions — re-scanning is intentionally disabled so you don't strand the existing Bot.
## Use the Bot (members)
### First message: bind your Lark identity
The first time you message the Bot, it replies with a card asking you to **bind your Lark identity**. Tap the link, sign in to Multica, and your Lark account is linked to your Multica membership. This is what lets the agent act as you — for example, `/issue` files the issue under your name.
<Callout type="warning">
Only people who are **members of the workspace** can use the Bot. If you aren't a member, or you skip the identity bind, the Bot won't respond — your message is dropped (and recorded for audit, without its contents).
</Callout>
### Chat and `/issue`
- **Ask the agent anything** — DM the Bot or @-mention it in a group. The conversation is a normal agent chat session; the agent replies in the card.
- **File an issue** — send `/issue Fix the login redirect` and Multica creates that issue in the workspace, assigned the way any new issue would be. Add more lines after the title for a description.
- **Watch it work** — the reply card patches itself while the agent runs, so you see progress and the result in place.
If the agent is **offline** (its runtime isn't connected) or **archived**, the Bot replies with a short status notice instead of silently dropping your message.
## Manage and disconnect
Workspace-wide management lives in **Settings → Integrations**:
- **Connected bots** lists every Bot in the workspace and the agent each one is bound to. This list is visible to all members.
- **Disconnect** is **owner / admin only**. Disconnecting stops the Bot from receiving Lark messages and tears down its connection; the installation record is kept for audit, and you can re-bind the same agent later.
## Permissions
- **Bind / disconnect** require workspace **owner** or **admin**. Members see the connected-bots list but no bind or disconnect controls.
- **Talking to the Bot** requires being a workspace member with a bound Lark identity. Everyone else is dropped.
- The integration never stores message bodies for dropped messages — only a drop reason, for audit.
## Self-host setup
On Multica Cloud the integration is already available — skip this section.
For self-host, Lark is **off until you set an at-rest encryption key**. The key encrypts each Bot's app secret before it touches the database.
1. Generate a 32-byte key and set it on the API server:
```dotenv
MULTICA_LARK_SECRET_KEY=<base64-encoded 32-byte key>
```
2. Restart the API. Until the key is set, **Settings → Integrations** shows a "Lark integration not enabled" notice and the **Bind to Lark** entry points stay hidden.
<Callout type="info">
**Feishu and Lark international, side by side.** The cloud each Bot belongs to — mainland Feishu (`open.feishu.cn`) or Lark international (`open.larksuite.com`) — is detected automatically when you scan the QR, stored on the installation, and used for every call to that Bot. A single deployment serves both at once, so teams on either tenant can bind without any extra configuration.
The `MULTICA_LARK_HTTP_BASE_URL` / `MULTICA_LARK_CALLBACK_BASE_URL` env vars remain only as an optional deployment-wide override (a proxy or a mock); leave them unset for normal operation so each installation keeps reaching its own cloud.
**Upgrading from a single-cloud setup?** If you ran an international-Lark deployment by setting those vars to `https://open.larksuite.com`, the server relabels your existing installations to the Lark region on first boot after upgrade — you can then clear the override. Mainland deployments need no action.
</Callout>
## Next
- [Agents](/agents) — each Bot is bound to exactly one agent
- [Chat](/chat) — what a Bot conversation maps to inside Multica
- [Issues](/issues) — what `/issue` creates
- [Environment variables](/environment-variables) — full self-host configuration reference

View File

@@ -0,0 +1,95 @@
---
title: 飞书 Bot 接入
description: 把 Multica 智能体绑定到飞书LarkBot就能直接在飞书里和它对话——私聊、群里 @ 它,或者输入 /issue 直接创建 Multica issue全程不用离开飞书。
---
import { Callout } from "fumadocs-ui/components/callout";
把任意[智能体](/agents)绑定到飞书 Bot团队就能在飞书里直接使用它——私聊 Bot、在群里 @ 它,或者输入 `/issue` 直接创建一个 [Multica issue](/issues),不用打开应用。智能体的回复会以一张实时卡片的形式回到聊天里,随着它干活不断更新。
每个 Bot 与一个 Multica 智能体**一对一**绑定。再绑定一个智能体会创建另一个 Bot一个智能体永远不会有两个 Bot。
## 这个集成能做什么
| 入口 | 行为 |
|---|---|
| **智能体 → 集成** | 智能体详情页有一个 **集成Integrations** tab左侧栏也有对应的区块。所有者和管理员能在这里看到 **绑定到飞书**;绑定后会变成 **已连接到飞书** 徽标,并带一个 **在飞书中管理** 链接。 |
| **私聊 Bot** | 工作区成员在飞书里直接给 Bot 发消息。每段对话都会成为该智能体的一个 Multica [chat](/chat) 会话,智能体在会话里回复。 |
| **群里 @ 它** | 把 Bot 加进飞书群再 @ 它。Bot 只读取 @ 它的那条消息,不会监听整个群。 |
| **`/issue` 命令** | 输入 `/issue <标题>`(可附正文)会在工作区创建一个新的 Multica issue记在你名下。 |
| **实时回复卡片** | Bot 会发出一张可交互卡片,并随着智能体运行不断更新——进度、最终答复或报错。 |
## 绑定智能体(所有者 / 管理员)
绑定走的是扫码安装流程——不用复制任何应用密钥,也不用进开发者后台。
1. 在 **Agents → 你的智能体** 打开该智能体。
2. 进入 **集成Integrations** tab或使用左侧栏的 **集成** 区块),点击 **绑定到飞书**。
3. 弹出一个二维码。用手机打开 **飞书 → 扫一扫**,然后授权这个新的 PersonalAgent Bot。
4. 扫码完成后弹窗关闭,智能体显示 **已连接到飞书**。你自己的飞书身份会自动绑定到你的 Multica 账号,绑完即可开始和 Bot 对话。
<Callout type="info">
二维码是一次性的,并且会在较短时间后过期。如果在授权前就过期了,点 **重新扫码** 获取新码即可。
</Callout>
智能体连接后,**绑定到飞书** 按钮会替换成 **在飞书中管理** 链接。需要调整权限范围、改名或申请更多权限时,用它打开 Bot 在飞书里的应用页面——重新扫码被刻意禁用,以免把已有的 Bot 弄成孤儿。
## 使用 Bot成员
### 第一条消息:绑定你的飞书身份
第一次给 Bot 发消息时,它会回一张卡片,让你 **绑定飞书身份**。点开链接、登录 Multica你的飞书账号就会关联到你的 Multica 成员身份。正是这一步让智能体能以你的身份行事——比如 `/issue` 会把 issue 记在你名下。
<Callout type="warning">
只有**工作区成员**才能使用 Bot。如果你不是成员或者跳过了身份绑定Bot 不会回复——你的消息会被丢弃(仅出于审计目的记录,不保存消息内容)。
</Callout>
### 对话与 `/issue`
- **随便问智能体** —— 私聊 Bot或在群里 @ 它。对话就是一段普通的智能体 chat 会话,智能体在卡片里回复。
- **创建 issue** —— 发送 `/issue 修复登录跳转`Multica 会在工作区创建这个 issue和新建任何 issue 一样。标题后面再加几行就是描述。
- **看它干活** —— 回复卡片会随着智能体运行不断更新,进度和结果都在原处呈现。
如果智能体**离线**(运行时未连接)或**已归档**Bot 会回一条简短的状态提示,而不是悄悄丢掉你的消息。
## 管理与断开
工作区级别的管理在 **设置 → 集成**
- **已连接的 Bot** 列出工作区里每个 Bot 以及它绑定的智能体。这个列表所有成员都能看到。
- **断开连接** 仅限 **所有者 / 管理员**。断开后 Bot 停止接收飞书消息、连接被销毁;安装记录会保留以便审计,之后你可以重新绑定同一个智能体。
## 权限
- **绑定 / 断开** 需要工作区**所有者**或**管理员**。成员能看到已连接 Bot 列表,但看不到绑定或断开的操作。
- **和 Bot 对话** 需要你是工作区成员且已绑定飞书身份。其余的人一律被丢弃。
- 对于被丢弃的消息,集成不会保存消息内容——只记录一个丢弃原因,用于审计。
## 自部署配置
在 Multica Cloud 上这个集成已经可用——可跳过本节。
自部署时,**在你设置好静态加密密钥之前,飞书集成是关闭的**。这个密钥会在每个 Bot 的 app secret 落库之前对其加密。
1. 生成一个 32 字节的密钥并设置到 API 服务器:
```dotenv
MULTICA_LARK_SECRET_KEY=<base64 编码的 32 字节密钥>
```
2. 重启 API。在密钥设置好之前**设置 → 集成** 会显示「未启用飞书集成」提示,**绑定到飞书** 入口也会保持隐藏。
<Callout type="info">
**同时支持飞书与海外版 Lark。** 每个 Bot 属于哪个云——中国大陆飞书(`open.feishu.cn`)还是海外版 Lark`open.larksuite.com`)——会在你扫码时自动识别、记录在该安装上,并用于对这个 Bot 的所有调用。同一个部署可以同时服务两者,因此两个租户的团队都能直接绑定,无需任何额外配置。
`MULTICA_LARK_HTTP_BASE_URL` / `MULTICA_LARK_CALLBACK_BASE_URL` 仅作为可选的部署级覆盖项保留(用于代理或 mock正常运行时请保持不设置让每个安装各自连到自己的云。
**从单云部署升级?** 如果你之前是把这两个变量设为 `https://open.larksuite.com` 来跑海外版 Lark升级后服务会在首次启动时自动把存量安装重标记为 Lark region之后你就可以清掉这个覆盖项。国内飞书部署无需任何操作。
</Callout>
## 下一步
- [智能体](/agents) —— 每个 Bot 都绑定到恰好一个智能体
- [Chat](/chat) —— 一段 Bot 对话在 Multica 里对应什么
- [Issues](/issues) —— `/issue` 创建的是什么
- [环境变量](/environment-variables) —— 完整的自部署配置参考

View File

@@ -31,6 +31,7 @@
"inbox",
"---連携---",
"github-integration",
"lark-bot-integration",
"---セルフホスト & 運用---",
"environment-variables",
"auth-setup",

View File

@@ -31,6 +31,7 @@
"inbox",
"---Integrations---",
"github-integration",
"lark-bot-integration",
"---Self-hosting & ops---",
"environment-variables",
"auth-setup",

View File

@@ -31,6 +31,7 @@
"inbox",
"---연동---",
"github-integration",
"lark-bot-integration",
"---자체 호스팅 & 운영---",
"environment-variables",
"auth-setup",

View File

@@ -30,6 +30,7 @@
"inbox",
"---集成---",
"github-integration",
"lark-bot-integration",
"---自部署运维---",
"environment-variables",
"auth-setup",

View File

@@ -13,32 +13,32 @@ Multica は **12 個の AI コーディングツール**を標準でサポート
| ツール | ベンダー | セッション再開 | MCP | スキル注入パス | モデル選択 |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Antigravity CLI 自体の内部で管理 |
| **Claude Code** | Anthropic | ✅ | **✅(実際に使用する唯一のツール)** | `.claude/skills/` | 静的 + flag |
| **Codex** | OpenAI | ⚠️ コードは存在するが到達不可 | | `$CODEX_HOME/skills/` | 静的 |
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 動的探索(`agy models` |
| **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/` | 動的探索 |
## 各ツールの用途
### Antigravity
Google が提供します。CLI バイナリ名は `agy` です。Google の Antigravity サービスと連携し、Gemini ベースのデフォルトモデルが付属しています。**セッション再開が動作します** — `--conversation <id>` を通じて行われ、stdout が構造化されたイベントストリームではなくプレーンテキストであるため、デーモンが CLI のログファイルから conversation UUID をキャプチャします。`--model` flag はありません — モデル選択は Antigravity CLI の設定内にあるため、Multica はこのプロバイダーに対してエージェントごとのモデルピッカーを無効にします。スキルは `.agents/skills/` に配置されますCLI が Gemini CLI のワークスペーススキルレイアウトをそのまま継承します — [Antigravity 移行ドキュメント](https://antigravity.google/docs/gcli-migration)を参照)。
Google が提供します。CLI バイナリ名は `agy` です。Google の Antigravity サービスと連携し、Gemini ベースのデフォルトモデルが付属しています。**セッション再開が動作します** — `--conversation <id>` を通じて行われ、stdout が構造化されたイベントストリームではなくプレーンテキストであるため、デーモンが CLI のログファイルから conversation UUID をキャプチャします。**モデル選択が動作します** — `--model` flagagy 1.0.6 で追加)を通じて行われ、デーモンが `agy models` でカタログを列挙し、選択された値をそのまま渡します。これらは `provider/model` slug ではなく `Claude Opus 4.6 (Thinking)` のような人間が読める表示名である点に注意してください。また agy は認識できない値を渡すと黙って空実行するため、手入力ではなく検出されたリストから選ぶことをおすすめします。スキルは `.agents/skills/` に配置されますCLI が Gemini CLI のワークスペーススキルレイアウトをそのまま継承します — [Antigravity 移行ドキュメント](https://antigravity.google/docs/gcli-migration)を参照)。
### 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

@@ -13,11 +13,11 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
| 도구 | 공급사 | 세션 재개 | MCP | 스킬 주입 경로 | 모델 선택 |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Antigravity CLI 자체 내부에서 관리 |
| **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/` | 동적 탐색 |
@@ -30,7 +30,7 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
### Antigravity
Google에서 제공합니다. CLI 바이너리 이름은 `agy`입니다. Google의 Antigravity 서비스와 연동되며 Gemini 기반의 기본 모델을 함께 제공합니다. **세션 재개가 동작합니다** — `--conversation <id>`를 통해서이며, stdout이 구조화된 이벤트 스트림이 아니라 일반 텍스트이기 때문에 데몬이 CLI의 로그 파일에서 conversation UUID를 캡처합니다. `--model` flag는 없습니다 — 모델 선택은 Antigravity CLI 설정 안에 있으므로, Multica는 이 제공자에 대해 에이전트별 모델 선택기를 비활성화합니다. 스킬은 `.agents/skills/`에 들어갑니다(CLI가 Gemini CLI의 워크스페이스 스킬 레이아웃을 그대로 따릅니다 — [Antigravity 마이그레이션 문서](https://antigravity.google/docs/gcli-migration) 참고).
Google에서 제공합니다. CLI 바이너리 이름은 `agy`입니다. Google의 Antigravity 서비스와 연동되며 Gemini 기반의 기본 모델을 함께 제공합니다. **세션 재개가 동작합니다** — `--conversation <id>`를 통해서이며, stdout이 구조화된 이벤트 스트림이 아니라 일반 텍스트이기 때문에 데몬이 CLI의 로그 파일에서 conversation UUID를 캡처합니다. **모델 선택이 동작합니다** — `--model` flag(agy 1.0.6에서 추가)를 통해서이며, 데몬이 `agy models`로 카탈로그를 열거하고 선택된 값을 그대로 전달합니다. 이 값들은 `provider/model` slug가 아니라 `Claude Opus 4.6 (Thinking)` 같은 사람이 읽는 표시 이름이라는 점에 유의하세요. 또한 agy는 인식할 수 없는 값을 받으면 조용히 빈 실행을 하므로, 직접 입력하기보다 발견된 목록에서 선택하는 것을 권장합니다. 스킬은 `.agents/skills/`에 들어갑니다(CLI가 Gemini CLI의 워크스페이스 스킬 레이아웃을 그대로 따릅니다 — [Antigravity 마이그레이션 문서](https://antigravity.google/docs/gcli-migration) 참고).
### Claude Code
@@ -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

@@ -13,11 +13,11 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
| Tool | Vendor | Session resumption | MCP | Skill injection path | Model selection |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Managed inside the Antigravity CLI itself |
| **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 |
@@ -30,7 +30,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
### Antigravity
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. There is no `--model` flag — model selection lives inside the Antigravity CLI settings, so Multica disables the per-agent model picker for this provider. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
### Claude Code
@@ -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

@@ -13,11 +13,11 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
| 工具 | 厂商 | 会话恢复 | MCP | Skill 注入路径 | 模型选择 |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅(`--conversation <id>`| ❌ | `.agents/skills/` | 由 Antigravity CLI 自己管理 |
| **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/` | 动态发现 |
@@ -30,7 +30,7 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
### Antigravity
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。CLI 没有 `--model` flag——模型选择保存在 Antigravity 自己的设置里,因此 Multica 禁用了这款工具的模型选择控件。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flagagy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug而且 agy 遇到无法识别的值会静默空跑,所以优先从发现列表里挑选,不要手填。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
### Claude Code
@@ -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

@@ -30,7 +30,7 @@ make selfhost
`make selfhost` は次のことを行います。
1. `.env` がなければ `.env.example` から生成し、**ランダムな JWT_SECRET** を併せて作成します
1. `.env` がなければ `.env.example` から生成し、**ランダムな JWT_SECRET と Postgres パスワード** を併せて作成します
2. 公式の Docker イメージPostgreSQL、Multica backend、Multica frontendを取得します
3. `docker-compose.selfhost.yml` を使ってすべてのサービスを起動します
4. バックエンドの `/health` エンドポイントが準備できるまで待機します
@@ -49,7 +49,7 @@ make selfhost
- **バックエンド**: [http://localhost:8080](http://localhost:8080)
<Callout type="info">
**ポートは `127.0.0.1` でのみ待ち受けます。** `docker-compose.selfhost.yml` は公開されるすべてのポートを loopback にバインドします — `ss -tlnp` には `0.0.0.0:8080` は表示されず、設計上、他のマシンからはサービスにアクセスできません。デフォルトの `JWT_SECRET` と Postgres の認証情報は、公開インターネット上に置いては絶対にいけません。マシン間アクセスが必要な場合は、TLS を終端するリバースプロキシをスタックの前に置いてください — [ステップ5b — マシン間: リバースプロキシを前に置く](#5b-cross-machine-front-with-a-reverse-proxy)を参照してください。
**ポートは `127.0.0.1` でのみ待ち受けます。** `docker-compose.selfhost.yml` は公開されるすべてのポートを loopback にバインドします — `ss -tlnp` には `0.0.0.0:8080` は表示されず、設計上、他のマシンからはサービスにアクセスできません。サーバーのシークレットと Postgres の認証情報は、公開インターネット上に置いては絶対にいけません。マシン間アクセスが必要な場合は、TLS を終端するリバースプロキシをスタックの前に置いてください — [ステップ5b — マシン間: リバースプロキシを前に置く](#5b-cross-machine-front-with-a-reverse-proxy)を参照してください。
</Callout>
## 2. 重要: プロダクションの安全設定を維持する
@@ -81,7 +81,7 @@ make selfhost
**オプション B — SMTP relay内部ネットワーク / オンプレミス):**
デプロイ環境が `api.resend.com` に到達できない場合や、すでに内部メールリレーMicrosoft Exchange、Postfix、オンプレミスの SendGrid など)がある場合に使ってください。両方が設定されている場合は `SMTP_HOST` が Resend より優先されるため、認証メールと招待メールは内部リレーにとどまります。ポート 465SMTPS / 暗黙的 TLS現在サポートされていません — 25 または 587 を使ってください
デプロイ環境が `api.resend.com` に到達できない場合や、すでに内部メールリレーMicrosoft Exchange、Postfix、オンプレミスの SendGrid など)がある場合に使ってください。両方が設定されている場合は `SMTP_HOST` が Resend より優先されるため、認証メールと招待メールは内部リレーにとどまります。STARTTLS は広告されると自動的にアップグレードされます。ポート `465`SMTPS / 暗黙的 TLS接続直後の TLS ハンドシェイクを自動的に有効化し、`SMTP_TLS=implicit`(別名: `smtps`、`ssl`)は非標準の SMTPS ポートで強制的に有効化します
**匿名 Exchange 内部リレー(ポート 25** — ホストが IP で信頼され、認証情報なしで送信する場合:
@@ -105,6 +105,26 @@ SMTP_TLS_INSECURE=false # set true only for private CA / self-signed
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**暗黙的 TLS / SMTPSポート 465** — STARTTLS を広告しないアリババクラウド / テンセントの法人メールなどのプロバイダー向け。ポート `465` は暗黙的 TLS を自動的に有効化するため、ここでは `SMTP_TLS` は省略可能です:
```bash
SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**厳格な公開 relay例: Google Workspace `smtp-relay.gmail.com`** が公開 IP からのデフォルトの `localhost` 挨拶を拒否する場合は、`SMTP_EHLO_NAME` を relay が期待する FQDN に設定してください — そうしないと接続が切断され、後続のコマンドで不明瞭な `EOF` として表面化します。デフォルトはコンテナのホスト名で、これは通常は有効な FQDN ではありません。
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
その後、再起動します: `docker compose -f docker-compose.selfhost.yml restart backend`。再起動時、バックエンドはどのプロバイダーを選んだかを出力します(`EmailService: SMTP relay …` / `Resend API` / `DEV mode`)— 認証情報は決してログに残らないため、この行はヘルプを求めるときに共有しても安全です。
追加の認証構成OAuth、サインアップの許可リストと SMTP 変数の完全なリファレンスは、[認証設定](/auth-setup)と[環境変数 → メール](/environment-variables#email-configuration)を参照してください。
@@ -134,7 +154,7 @@ multica setup self-host
### 5b. マシン間: リバースプロキシを前に置く
compose スタックは `127.0.0.1` でのみ待ち受けるため、別のマシンにあるデーモンは `http://<server-ip>:8080` に直接接続できません — そして、そうなることを望むべきでもありません。さもなければデフォルトの `JWT_SECRET` が公開インターネットから到達可能になってしまうからです。TLS を終端し、`127.0.0.1:8080`(バックエンド)と `127.0.0.1:3000`フロントエンドへ転送するリバースプロキシをサーバーに置き、CLI を公開 HTTPS URL に向けてください。
compose スタックは `127.0.0.1` でのみ待ち受けるため、別のマシンにあるデーモンは `http://<server-ip>:8080` に直接接続できません — そして、そうなることを望むべきでもありません。さもなければサーバーのシークレットが公開インターネットから到達可能になってしまうからです。TLS を終端し、`127.0.0.1:8080`(バックエンド)と `127.0.0.1:3000`フロントエンドへ転送するリバースプロキシをサーバーに置き、CLI を公開 HTTPS URL に向けてください。
```bash
multica setup self-host \
@@ -142,6 +162,10 @@ multica setup self-host \
--app-url https://<your-domain>
```
<Callout type="info">
フラグより環境変数を使いたい場合は、対応するフラグを省略すると `setup self-host` が `MULTICA_SERVER_URL` と `MULTICA_APP_URL` を読み取ります(両方設定した場合はフラグが優先されます)。`MULTICA_SERVER_URL` は[環境変数](/environment-variables)で示される `ws://…/ws` というデーモン形式も受け付け、HTTP ベース URL に正規化します。
</Callout>
単一のホスト名でフロントエンドとバックエンドの両方を前段に置く(デーモンと Web アプリの両方に必要な WebSocket サポートを含む)最小限の Caddyfile は次のとおりです。
```nginx
@@ -172,44 +196,26 @@ multica.example.com {
Cloud と同じ流れです — [Cloud クイックスタート → ステップ5-6](/cloud-quickstart#5-create-an-agent)を参照してください。
## 7. 使用量ロールアップのスケジューリング(使用量ダッシュボードに必須)
<span id="7-usage-rollup-no-operator-action-required" />
<Callout type="warning">
使用量 / ランタイムのダッシュボードは、`rollup_task_usage_hourly()` が埋める派生テーブル `task_usage_hourly` からデータを読み取ります。バンドルされた `pgvector/pgvector:pg17` の Postgres イメージには **`pg_cron` が含まれておらず**、バックエンドもロールアップをインプロセスで実行しません。`rollup_task_usage_hourly()` をスケジューリングするものが何もないと、生の `task_usage` 行は届き続けるのに、ダッシュボードは永遠にゼロのままになります。
## 7. 使用量ロールアップ(オペレーターの操作は不要)
<Callout type="info">
使用量 / ランタイムのダッシュボードは、`rollup_task_usage_hourly()` が埋める派生テーブル `task_usage_hourly` からデータを読み取ります。MUL-2957 以降、バックエンドは DB バックドのスケジューラー経由でこのロールアップをインプロセスで実行するようになり、`pg_cron` は不要になりました。外部 cron / systemd タイマーも推奨セットアップではなくなっています。バンドルされた `pgvector/pgvector:pg17` イメージは変更なしで動作します。
</Callout>
サポートされているオプションのいずれか1つを選んでください — 1つあれば十分です。
インプロセススケジューラーは 30 秒おきにティックし、`sys_cron_executions` テーブルを介して 5 分ごとの UTC プランをクレームします。複数のバックエンドレプリカでも安全です — 一意キー `(job_name, scope_kind, scope_id, plan_time)` により、各プランで勝者は 1 つだけです。新規デプロイでは何の設定も不要です。
**オプション A — 外部 cron / systemd-timer最もシンプル。** 任意の帯域外スケジューラから5分ごとにロールアップを実行します。冪等でウォーターマーク駆動なので、取りこぼしたティックは追いつきます。
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**オプション B — Postgres を `pg_cron` を同梱したイメージに置き換える。** `docker-compose.selfhost.yml` の `pgvector/pgvector:pg17` を、`pgvector` と `pg_cron` の両方を備えたイメージ(`supabase/postgres`、またはカスタムビルド)に置き換え、`shared_preload_libraries=pg_cron` を設定して再起動してから、ジョブを一度登録します。
**互換性 — 既存の `pg_cron` 登録。** 以前 rollup を `pg_cron` ジョブとして登録していた(`SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', …)`)場合でも、削除する必要はありません — SQL 関数が内部で advisory lock 4246 を保持するため、アプリのスケジューラーと `pg_cron` が二重書き込みすることはありません。冗長なエントリを削除するには:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
SELECT cron.unschedule('rollup_task_usage_hourly')
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
```
**オプション C — まず履歴をバックフィルする(アップグレード経路)。** `v0.3.4 → v0.3.5+` アップグレード中で、既存の `task_usage` 行がある場合、migration `103` は hourly テーブルがシードされるまで `refusing to drop legacy daily rollups: ...` とともに `migrate up` を中断します。バンドルされたバックフィルを一度実行してから、オプション A または B を設定してください
**`v0.3.4 → v0.3.5+` からのアップグレード。** 以前のリリースでは、migration 103 を適用する前にオペレーターが手動で `cmd/backfill_task_usage_hourly` を実行する必要があり、そうしないと migration の fail-closed ガードが `migrate up` を中断していました。MUL-2957 以降、これは自動化されました — migrate コマンドが migration 103 を適用する直前に冪等な月単位スライスのバックフィルadvisory lock 4246 の下)を実行してから処理を続行します。忙しい DB では `--sleep-between-slices=2s` で読み取り負荷を絞るためにスタンドアロンの backfill を実行することもできますが、もはや必須ではありません
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s` は、忙しい DB での読み取り負荷を調整します。完了後、バックエンドのコンテナを再起動すると(起動時に migration が実行されます)アップグレードが完了します。
完全なリファレンス — Kubernetes の `CronJob` テンプレートとアップグレード順序を含む — は、リポジトリの [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup) にあります。
完全なリファレンス(運用ノートと Kubernetes デプロイ形態を含む)は、リポジトリの [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup) にあります。
## Kubernetes デプロイ(代替手段)
@@ -263,8 +269,8 @@ multica setup self-host \
- **バックエンドが起動しない**: `docker compose -f docker-compose.selfhost.yml logs backend` でコンテナのログを確認してください。たいていは `.env` の不正な `DATABASE_URL` または `JWT_SECRET` が原因です
- **認証コードが届かない**: メールバックエンドが構成されていない場合Resend も SMTP もない)→ `docker compose logs backend` で `[DEV] Verification code` を探してください
- **WebSocket が接続できない**: 公開デプロイでは、`FRONTEND_ORIGIN` を実際のフロントエンドのドメインに必ず設定する必要があります。[トラブルシューティング → WebSocket が接続できない](/troubleshooting#websocket-wont-connect)を参照してください
- **使用量 / ランタイムのダッシュボードがゼロのまま**: `rollup_task_usage_hourly()` がスケジューリングされていません — 上記の [ステップ7](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)と[トラブルシューティング → 使用量ダッシュボードがゼロと表示される](/troubleshooting#usage-dashboard-stays-at-zero)を参照してください
- **`migrate up` が `refusing to drop legacy daily rollups` で失敗する**: `v0.3.4 → v0.3.5+` のアップグレード経路ガードです。まず `backfill_task_usage_hourly` を実行してください — [ステップ7 → オプション C](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)を参照してください
- **使用量 / ランタイムのダッシュボードがゼロのまま**: `rollup_task_usage_hourly()` がスケジューリングされていません — 上記の [ステップ7](#7-usage-rollup-no-operator-action-required)と[トラブルシューティング → 使用量ダッシュボードがゼロと表示される](/troubleshooting#usage-dashboard-stays-at-zero)を参照してください
- **`migrate up` が `refusing to drop legacy daily rollups` で失敗する**: `v0.3.4 → v0.3.5+` のアップグレード経路ガードです。MUL-2957 以降、migrate コマンドは migration 103 を適用する前に自動でバックフィルを実行します — [ステップ7](#7-usage-rollup-no-operator-action-required)を参照してください
## 次のステップ

View File

@@ -30,7 +30,7 @@ make selfhost
`make selfhost`는 다음을 수행합니다.
1. `.env`가 없으면 `.env.example`로부터 생성하며 **무작위 JWT_SECRET** 함께 만듭니다
1. `.env`가 없으면 `.env.example`로부터 생성하며 **무작위 JWT_SECRET과 Postgres 비밀번호** 함께 만듭니다
2. 공식 Docker 이미지(PostgreSQL, Multica backend, Multica frontend)를 받아옵니다
3. `docker-compose.selfhost.yml`을 사용해 모든 서비스를 시작합니다
4. 백엔드의 `/health` 엔드포인트가 준비될 때까지 기다립니다
@@ -49,7 +49,7 @@ make selfhost
- **백엔드**: [http://localhost:8080](http://localhost:8080)
<Callout type="info">
**포트는 `127.0.0.1`에서만 수신합니다.** `docker-compose.selfhost.yml`은 공개된 모든 포트를 loopback에 바인딩합니다 — `ss -tlnp`에서는 `0.0.0.0:8080`이 보이지 않으며, 설계상 다른 기기에서는 서비스에 접근할 수 없습니다. 기본 `JWT_SECRET`과 Postgres 자격 증명이 공개 인터넷에 노출되어서는 절대 안 됩니다. 기기 간 접근이 필요하면 TLS를 종료하는 리버스 프록시를 스택 앞에 두세요 — [5b단계 — 기기 간: 리버스 프록시를 앞에 두기](#5b-cross-machine-front-with-a-reverse-proxy)를 참고하세요.
**포트는 `127.0.0.1`에서만 수신합니다.** `docker-compose.selfhost.yml`은 공개된 모든 포트를 loopback에 바인딩합니다 — `ss -tlnp`에서는 `0.0.0.0:8080`이 보이지 않으며, 설계상 다른 기기에서는 서비스에 접근할 수 없습니다. 서버 시크릿과 Postgres 자격 증명이 공개 인터넷에 노출되어서는 절대 안 됩니다. 기기 간 접근이 필요하면 TLS를 종료하는 리버스 프록시를 스택 앞에 두세요 — [5b단계 — 기기 간: 리버스 프록시를 앞에 두기](#5b-cross-machine-front-with-a-reverse-proxy)를 참고하세요.
</Callout>
## 2. 중요: 프로덕션 안전 설정 유지하기
@@ -81,7 +81,7 @@ make selfhost
**옵션 B — SMTP relay(내부 네트워크 / 온프레미스):**
배포 환경이 `api.resend.com`에 접근할 수 없거나, 이미 내부 메일 릴레이(Microsoft Exchange, Postfix, 온프레미스 SendGrid 등)가 있는 경우에 사용하세요. 둘 다 설정된 경우 `SMTP_HOST`가 Resend보다 우선하므로, 인증 및 초대 메일이 내부 릴레이에 머무릅니다. 465 포트(SMTPS / 암묵적 TLS)는 현재 지원하지 않습니다 — 25 또는 587을 사용하세요.
배포 환경이 `api.resend.com`에 접근할 수 없거나, 이미 내부 메일 릴레이(Microsoft Exchange, Postfix, 온프레미스 SendGrid 등)가 있는 경우에 사용하세요. 둘 다 설정된 경우 `SMTP_HOST`가 Resend보다 우선하므로, 인증 및 초대 메일이 내부 릴레이에 머무릅니다. STARTTLS는 광고될 때 자동으로 업그레이드됩니다. `465` 포트(SMTPS / 암묵적 TLS)는 연결 직후의 TLS 핸드셰이크를 자동으로 활성화하며, `SMTP_TLS=implicit`(별칭: `smtps`, `ssl`)는 비표준 SMTPS 포트에서 강제로 활성화합니다.
**익명 Exchange 내부 릴레이(포트 25)** — 호스트가 IP로 신뢰되며 자격 증명 없이 제출하는 경우:
@@ -105,6 +105,26 @@ SMTP_TLS_INSECURE=false # 비공개 CA / 자체 서명 인증서일 때
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**암묵적 TLS / SMTPS(포트 465)** — STARTTLS를 광고하지 않는 알리바바 클라우드 / 텐센트 기업 메일 같은 제공자용. 포트 `465`는 암묵적 TLS를 자동으로 활성화하므로, 여기서 `SMTP_TLS`는 생략할 수 있습니다:
```bash
SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
공개 IP에서 보내는 기본 `localhost` greeting을 거부하는 **엄격한 공개 relay(예: Google Workspace `smtp-relay.gmail.com`)** 의 경우, relay가 기대하는 FQDN으로 `SMTP_EHLO_NAME`을 설정하세요 — 그렇지 않으면 연결이 끊기고, 이는 이후 명령에서 불투명한 `EOF`로 나타납니다. 기본값은 컨테이너 호스트명이며, 보통 유효한 FQDN이 아닙니다:
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # relay가 받아들이는 FQDN; 기본값은 (FQDN이 아닌) 컨테이너 호스트명
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
그런 다음 재시작합니다: `docker compose -f docker-compose.selfhost.yml restart backend`. 재시작 시 백엔드는 어떤 제공자를 선택했는지 출력합니다(`EmailService: SMTP relay …` / `Resend API` / `DEV mode`) — 자격 증명은 절대 로그에 남지 않으므로, 이 줄은 도움을 요청할 때 공유해도 안전합니다.
추가 인증 구성(OAuth, 가입 허용 목록)과 전체 SMTP 변수 레퍼런스는 [인증 설정](/auth-setup)과 [환경 변수 → 이메일](/environment-variables#email-configuration)을 참고하세요.
@@ -134,7 +154,7 @@ multica setup self-host
### 5b. 기기 간: 리버스 프록시를 앞에 두기
compose 스택은 `127.0.0.1`에서만 수신하므로, 다른 기기에 있는 데몬은 `http://<server-ip>:8080`에 직접 연결할 수 없습니다 — 그리고 그렇게 되기를 원해서도 안 됩니다. 그렇지 않으면 기본 `JWT_SECRET`이 공개 인터넷에서 접근 가능해지기 때문입니다. 서버에 TLS를 종료하고 `127.0.0.1:8080`(백엔드)과 `127.0.0.1:3000`(프런트엔드)으로 전달하는 리버스 프록시를 두고, CLI를 공개 HTTPS URL로 연결하세요.
compose 스택은 `127.0.0.1`에서만 수신하므로, 다른 기기에 있는 데몬은 `http://<server-ip>:8080`에 직접 연결할 수 없습니다 — 그리고 그렇게 되기를 원해서도 안 됩니다. 그렇지 않으면 서버 시크릿이 공개 인터넷에서 접근 가능해지기 때문입니다. 서버에 TLS를 종료하고 `127.0.0.1:8080`(백엔드)과 `127.0.0.1:3000`(프런트엔드)으로 전달하는 리버스 프록시를 두고, CLI를 공개 HTTPS URL로 연결하세요.
```bash
multica setup self-host \
@@ -142,6 +162,10 @@ multica setup self-host \
--app-url https://<your-domain>
```
<Callout type="info">
플래그 대신 환경 변수를 선호한다면, 해당 플래그를 생략할 때 `setup self-host`가 `MULTICA_SERVER_URL`과 `MULTICA_APP_URL`을 읽습니다(둘 다 설정하면 플래그가 우선합니다). `MULTICA_SERVER_URL`은 [환경 변수](/environment-variables)에 나오는 `ws://…/ws` 데몬 형식도 허용하며 HTTP 기본 URL로 정규화합니다.
</Callout>
단일 호스트네임에서 프런트엔드와 백엔드를 모두 앞단에 두는(데몬과 웹 앱 모두에 필요한 WebSocket 지원 포함) 최소 Caddyfile은 다음과 같습니다.
```nginx
@@ -172,44 +196,26 @@ multica.example.com {
Cloud와 동일한 흐름입니다 — [Cloud 빠른 시작 → 5-6단계](/cloud-quickstart#5-create-an-agent)를 참고하세요.
## 7. 사용량 롤업 스케줄링(사용량 대시보드에 필수)
<span id="7-usage-rollup-no-operator-action-required" />
<Callout type="warning">
사용량 / 런타임 대시보드는 `rollup_task_usage_hourly()`가 채우는 파생 테이블 `task_usage_hourly`에서 데이터를 읽습니다. 번들된 `pgvector/pgvector:pg17` Postgres 이미지에는 **`pg_cron`이 포함되어 있지 않으며**, 백엔드도 롤업을 인프로세스로 실행하지 않습니다. `rollup_task_usage_hourly()`를 스케줄링하는 것이 없으면, 원시 `task_usage` 행은 계속 들어오는데 대시보드는 영원히 0에 머무릅니다.
## 7. 사용량 롤업(운영자 작업 불필요)
<Callout type="info">
사용량 / 런타임 대시보드는 `rollup_task_usage_hourly()`가 채우는 파생 테이블 `task_usage_hourly`에서 데이터를 읽습니다. MUL-2957부터 백엔드는 DB 기반 스케줄러를 통해 인프로세스로 이 롤업을 실행하므로 더 이상 `pg_cron`이 필요하지 않으며, 외부 cron / systemd 타이머도 권장 설정이 아닙니다. 번들된 `pgvector/pgvector:pg17` 이미지가 변경 없이 동작합니다.
</Callout>
지원되는 옵션 중 하나를 고르세요 — 하나만 있으면 됩니다.
인프로세스 스케줄러는 30초마다 틱하면서 `sys_cron_executions` 테이블을 통해 5분 단위 UTC 플랜을 클레임합니다. 백엔드 레플리카가 여러 개여도 안전합니다 — 고유 키 `(job_name, scope_kind, scope_id, plan_time)` 덕분에 각 플랜에서 단 하나만이 승자가 됩니다. 신규 배포에는 어떤 설정도 필요 없습니다.
**옵션 A — 외부 cron / systemd-timer(가장 간단함).** 임의의 외부 스케줄러에서 5분마다 롤업을 실행합니다. 멱등하고 워터마크 기반이므로, 놓친 틱은 따라잡습니다.
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**옵션 B — Postgres를 `pg_cron`이 포함된 이미지로 교체.** `docker-compose.selfhost.yml`의 `pgvector/pgvector:pg17`을 `pgvector`와 `pg_cron`을 모두 갖춘 이미지(`supabase/postgres` 또는 커스텀 빌드)로 교체하고, `shared_preload_libraries=pg_cron`을 설정한 뒤 재시작하고, 작업을 한 번 등록합니다.
**호환성 — 기존 `pg_cron` 등록.** 이전에 rollup을 `pg_cron` 잡으로 등록했었다면(`SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', …)`) 굳이 제거할 필요는 없습니다 — SQL 함수가 내부적으로 advisory lock 4246을 잡기 때문에 앱 스케줄러와 `pg_cron`이 이중 쓰기를 할 수 없습니다. 중복 항목을 제거하려면:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
SELECT cron.unschedule('rollup_task_usage_hourly')
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
```
**옵션 C — 먼저 히스토리 백필(업그레이드 경로).** `v0.3.4 → v0.3.5+` 업그레이드하는 중이고 기존 `task_usage` 행이 있다면, migration `103`이 hourly 테이블이 시드될 때까지 `refusing to drop legacy daily rollups: ...`와 함께 `migrate up`을 중단니다. 번들된 백필을 한 번 실행한 다음, 옵션 A 또는 B를 설정하세요.
**`v0.3.4 → v0.3.5+` 업그레이드.** 이전 릴리스에서는 migration 103을 적용하기 전에 운영자가 직접 `cmd/backfill_task_usage_hourly`를 실행해야 했고, 그러지 않으면 fail-closed 가드가 `migrate up`을 중단했습니다. MUL-2957부터 이 작업은 자동입니다 — migrate 명령이 migration 103을 적용하기 직전에(advisory lock 4246 보호 아래에서) 멱등한 월별 슬라이스 백필을 실행한 뒤 계속 진행합니다. 바쁜 DB에서는 여전히 `--sleep-between-slices=2s`로 읽기 부하를 조절하기 위해 스탠드얼론 backfill을 실행할 수 있지만 더 이상 필수는 아닙니다.
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s`는 바쁜 DB에서 읽기 부하를 조절합니다. 완료된 후 백엔드 컨테이너를 재시작하면(시작 시 migration이 실행됨) 업그레이드가 완료됩니다.
전체 레퍼런스 — Kubernetes `CronJob` 템플릿과 업그레이드 순서 포함 — 는 저장소의 [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup)에 있습니다.
전체 레퍼런스(운영 노트와 Kubernetes 배포 형태 포함)는 저장소의 [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup)에 있습니다.
## Kubernetes 배포(대체 방안)
@@ -263,8 +269,8 @@ multica setup self-host \
- **백엔드가 시작되지 않음**: `docker compose -f docker-compose.selfhost.yml logs backend`로 컨테이너 로그를 확인하세요. 보통 `.env`의 잘못된 `DATABASE_URL` 또는 `JWT_SECRET`이 원인입니다
- **인증 코드를 받지 못함**: 이메일 백엔드가 구성되지 않은 경우(Resend도 SMTP도 없음) → `docker compose logs backend`에서 `[DEV] Verification code`를 찾으세요
- **WebSocket이 연결되지 않음**: 공개 배포에서는 반드시 `FRONTEND_ORIGIN`을 실제 프런트엔드 도메인으로 설정해야 합니다. [문제 해결 → WebSocket이 연결되지 않음](/troubleshooting#websocket-wont-connect)을 참고하세요
- **사용량 / 런타임 대시보드가 0에 머무름**: `rollup_task_usage_hourly()`가 스케줄링되지 않고 있습니다 — 위의 [7단계](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)와 [문제 해결 → 사용량 대시보드가 0으로 표시됨](/troubleshooting#usage-dashboard-stays-at-zero)을 참고하세요
- **`migrate up`이 `refusing to drop legacy daily rollups`로 실패함**: `v0.3.4 → v0.3.5+` 업그레이드 경로 가드입니다. 먼저 `backfill_task_usage_hourly`를 실행하세요 — [7단계 → 옵션 C](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)를 참고하세요
- **사용량 / 런타임 대시보드가 0에 머무름**: `rollup_task_usage_hourly()`가 스케줄링되지 않고 있습니다 — 위의 [7단계](#7-usage-rollup-no-operator-action-required)와 [문제 해결 → 사용량 대시보드가 0으로 표시됨](/troubleshooting#usage-dashboard-stays-at-zero)을 참고하세요
- **`migrate up`이 `refusing to drop legacy daily rollups`로 실패함**: `v0.3.4 → v0.3.5+` 업그레이드 경로 가드입니다. MUL-2957부터 migrate 명령이 migration 103을 적용하기 전에 백필을 자동으로 실행합니다 — [7단계](#7-usage-rollup-no-operator-action-required)를 참고하세요
## 다음 단계

View File

@@ -30,7 +30,7 @@ make selfhost
`make selfhost` will:
1. Generate a `.env` from `.env.example` if missing, with a **random JWT_SECRET**
1. Generate a `.env` from `.env.example` if missing, with a **random JWT_SECRET and Postgres password**
2. Pull the official Docker images (PostgreSQL, Multica backend, Multica frontend)
3. Bring up every service using `docker-compose.selfhost.yml`
4. Wait until the backend's `/health` endpoint is ready
@@ -50,7 +50,7 @@ Once it's up:
- **Backend**: [http://localhost:8080](http://localhost:8080)
<Callout type="info">
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. Secrets and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
</Callout>
## 2. Important: keep production safety on
@@ -117,6 +117,15 @@ SMTP_TLS=implicit # optional on 465; required on a non-standard SMT
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
For **strict public relays (e.g. Google Workspace `smtp-relay.gmail.com`)** that reject the default `localhost` greeting from a public IP, set `SMTP_EHLO_NAME` to the FQDN the relay expects — otherwise the connection is dropped and surfaces as an opaque `EOF` on a later command. It defaults to the container hostname, which is usually not a valid FQDN:
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`. On restart, the backend prints which provider it picked and the negotiated TLS mode (`EmailService: SMTP relay <host>:<port> (starttls|implicit-tls) from=…` / `Resend API` / `DEV mode`) — credentials are never logged, so this line is safe to share when asking for help.
For more auth configuration (OAuth, signup allowlist) and the full SMTP variable reference, see [Auth setup](/auth-setup) and [Environment variables → Email](/environment-variables#email-configuration).
@@ -146,7 +155,7 @@ That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3
### 5b. Cross-machine: front with a reverse proxy
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since server secrets would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
```bash
multica setup self-host \
@@ -154,6 +163,10 @@ multica setup self-host \
--app-url https://<your-domain>
```
<Callout type="info">
Prefer environment variables over flags? `setup self-host` reads `MULTICA_SERVER_URL` and `MULTICA_APP_URL` when the matching flag is omitted — a flag still takes precedence over the env var. `MULTICA_SERVER_URL` also accepts the `ws://…/ws` daemon form from [Environment variables](/environment-variables) and normalizes it to the HTTP base.
</Callout>
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
```nginx
@@ -184,44 +197,24 @@ After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` i
Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-create-an-agent).
## 7. Schedule the usage rollup (required for the Usage dashboard)
## 7. Usage rollup (no operator action required)
<Callout type="warning">
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` Postgres image **does not include `pg_cron`**, and the backend does not run the rollup in-process either. If nothing schedules `rollup_task_usage_hourly()`, raw `task_usage` rows keep arriving while the dashboard stays at zero forever.
<Callout type="info">
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. As of MUL-2957 the backend runs this rollup in-process via the DB-backed scheduler — `pg_cron` is no longer required, and external cron / systemd timers are no longer the recommended setup. The bundled `pgvector/pgvector:pg17` image works without changes.
</Callout>
Pick one of the supported options — only one is needed.
The in-process scheduler ticks every 30 seconds and claims a 5-minute UTC plan via the `sys_cron_executions` table. Multiple backend replicas are safe — the unique key `(job_name, scope_kind, scope_id, plan_time)` means only one wins each plan. No setup is needed for new deployments.
**Option A — External cron / systemd-timer (simplest).** Run the rollup every 5 minutes from any out-of-band scheduler. It's idempotent and watermark-driven, so missed ticks catch up:
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**Option B — Swap Postgres for an image that ships `pg_cron`.** Replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that has both `pgvector` and `pg_cron` (`supabase/postgres`, or a custom build), set `shared_preload_libraries=pg_cron`, restart, then register the job once:
**Compatibility — existing `pg_cron` registrations.** If you previously registered the rollup as a `pg_cron` job (`SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', …)`), you do not need to remove it — the SQL function holds advisory lock 4246 internally, so the app scheduler and `pg_cron` cannot double-write. To drop the redundant entry:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
SELECT cron.unschedule('rollup_task_usage_hourly')
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
```
**Option C — Backfill history first (upgrade path).** If you're upgrading from `v0.3.4 → v0.3.5+` and have existing `task_usage` rows, migration `103` will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run the bundled backfill once, then set up Option A or B:
**Upgrade from `v0.3.4 → v0.3.5+`.** The previous release asked operators to run `cmd/backfill_task_usage_hourly` manually before applying migration 103, otherwise the migration's fail-closed guard would abort `migrate up`. As of MUL-2957 this is automatic: the migrate command runs an idempotent monthly-slice backfill (under advisory lock 4246) immediately before applying migration 103, then continues. You may still run the standalone backfill on a busy DB to throttle read pressure with `--sleep-between-slices=2s`, but it is no longer required.
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s` throttles read pressure on a busy DB. After it finishes, restart the backend container (migrations run on startup) and the upgrade completes.
Full reference — including the Kubernetes `CronJob` template and the upgrade order — lives in the repo's [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
Full reference — including operations notes and the Kubernetes deployment shape — lives in the repo's [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
## Kubernetes deployment (alternative)
@@ -275,8 +268,8 @@ The full reference — three login modes, the `backend` ExternalName workaround
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
- **Usage / Runtime dashboard stays at zero**: `rollup_task_usage_hourly()` isn't being scheduled — see [Step 7](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard) above and [Troubleshooting → Usage dashboard shows zero](/troubleshooting#usage-dashboard-stays-at-zero)
- **`migrate up` fails with `refusing to drop legacy daily rollups`**: upgrade-path guard from `v0.3.4 → v0.3.5+`. Run `backfill_task_usage_hourly` first — see [Step 7 → Option C](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)
- **Usage / Runtime dashboard stays at zero**: `rollup_task_usage_hourly()` isn't being scheduled — see [Step 7](#7-usage-rollup-no-operator-action-required) above and [Troubleshooting → Usage dashboard shows zero](/troubleshooting#usage-dashboard-stays-at-zero)
- **`migrate up` fails with `refusing to drop legacy daily rollups`**: upgrade-path guard from `v0.3.4 → v0.3.5+`. As of MUL-2957 the migrate command runs the backfill automatically before applying migration 103 — see [Step 7](#7-usage-rollup-no-operator-action-required)
## Next steps

View File

@@ -30,7 +30,7 @@ make selfhost
`make selfhost` 会:
1. 如果没有 `.env` 文件,从 `.env.example` 自动生成一份并**生成随机 JWT_SECRET**
1. 如果没有 `.env` 文件,从 `.env.example` 自动生成一份并**生成随机 JWT_SECRET 和 Postgres 密码**
2. 拉取官方 Docker 镜像PostgreSQL、Multica backend、Multica frontend
3. 用 `docker-compose.selfhost.yml` 启动全部服务
4. 等后端 `/health` 端点准备就绪
@@ -49,7 +49,7 @@ make selfhost
- **后端**[http://localhost:8080](http://localhost:8080)
<Callout type="info">
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免服务密钥和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
</Callout>
## 2. 重要:保持生产安全配置
@@ -116,6 +116,15 @@ SMTP_TLS=implicit # 465 上可省略;非标准 SMTPS 端口上必
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
对于**拒绝来自公网 IP 的默认 `localhost` 问候的严格公网 relay例如 Google Workspace `smtp-relay.gmail.com`**,把 `SMTP_EHLO_NAME` 设成 relay 期望的 FQDN——否则连接会被直接断开并在后续某条命令上表现为一个不知所云的 `EOF`。它默认取容器主机名,而后者通常不是合法的 FQDN
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # relay 接受的 FQDN默认取非 FQDN 的)容器主机名
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。重启时 backend 会打印当前选择的 provider 和协商出的 TLS 模式(`EmailService: SMTP relay <host>:<port> (starttls|implicit-tls) from=…` / `Resend API` / `DEV mode`),密码不会被记录,所以这行截图给同事是安全的。
更多 auth 配置OAuth、注册白名单以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。
@@ -145,7 +154,7 @@ multica setup self-host
### 5b. 跨机访问:用反向代理把服务挡在前面
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理Caddy / nginx / Cloudflare Tunnel由它终结 TLS再反代到 `127.0.0.1:8080`backend和 `127.0.0.1:3000`frontend。然后把 CLI 指到公开的 HTTPS 域名:
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则服务密钥会直接暴露在公网。正确做法是在 server 上跑一个反向代理Caddy / nginx / Cloudflare Tunnel由它终结 TLS再反代到 `127.0.0.1:8080`backend和 `127.0.0.1:3000`frontend。然后把 CLI 指到公开的 HTTPS 域名:
```bash
multica setup self-host \
@@ -153,6 +162,10 @@ multica setup self-host \
--app-url https://<你的域名>
```
<Callout type="info">
更习惯用环境变量?省略对应 flag 时,`setup self-host` 会读取 `MULTICA_SERVER_URL` 和 `MULTICA_APP_URL`(同时设置时 flag 优先)。`MULTICA_SERVER_URL` 也接受[环境变量](/environment-variables)里那种 `ws://…/ws` 的 daemon 写法,并自动归一化为 HTTP 地址。
</Callout>
最小可用的 Caddyfile单域名同时挂前后端带 WebSocket 转发daemon 和网页端都依赖):
```nginx
@@ -183,44 +196,26 @@ multica.example.com {
流程和 Cloud 一样——见 [Cloud 快速上手 → 5-6 步](/cloud-quickstart#5-创建智能体)。
## 7. 调度用量汇总任务Usage Dashboard 必需)
<span id="7-usage-rollup-no-operator-action-required" />
<Callout type="warning">
Usage / Runtime 看板读的是派生表 `task_usage_hourly`,需要 `rollup_task_usage_hourly()` 周期性运行才能填充。**默认的 `pgvector/pgvector:pg17` 镜像不带 `pg_cron`**,后端进程内部也不会跑这个 rollup——什么都没调度的话原始 `task_usage` 行会继续写入,但 dashboard 会一直停在 0不会报错。
## 7. 用量汇总(无需运维操作)
<Callout type="info">
Usage / Runtime 看板读的是派生表 `task_usage_hourly`,由 `rollup_task_usage_hourly()` 周期性填充。从 MUL-2957 起,后端通过 DB 后端的调度器在进程内运行该 rollup —— 不再需要 `pg_cron`,外部 cron / systemd timer 也不再是推荐方案。默认的 `pgvector/pgvector:pg17` 镜像无需改动即可工作。
</Callout>
三种支持路径,三选一即可
进程内调度器每 30 秒 tick 一次,通过 `sys_cron_executions` 表认领 5 分钟一档的 UTC plan。多 backend 副本同时跑也安全 —— 唯一键 `(job_name, scope_kind, scope_id, plan_time)` 保证每个 plan 只有一个赢家。新部署不需要任何额外配置
**Option A —— 外部 cron / systemd-timer最简单。** 在任意外部调度器上每 5 分钟跑一次 rollup。函数是幂等的、按 watermark 推进,丢一两个 tick 下次能补上
```bash
# /etc/cron.d/multica-rollup —— 每 5 分钟跑一次
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**Option B —— 换成自带 `pg_cron` 的 Postgres 镜像。** 把 `docker-compose.selfhost.yml` 里的 `pgvector/pgvector:pg17` 换成同时带 `pgvector` 和 `pg_cron` 的镜像(比如 `supabase/postgres`,或自己 build 一份),把 `shared_preload_libraries=pg_cron` 配上、重启 Postgres然后注册一次任务
**兼容性 —— 已注册的 `pg_cron` 任务。** 如果你之前用 `pg_cron` 注册过 rollup`SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', …)`),不删也行 —— SQL 函数内部持有 advisory lock 4246应用调度器和 `pg_cron` 不会并发双写。要清掉冗余项可以执行
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
SELECT cron.unschedule('rollup_task_usage_hourly')
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
```
**Option C —— 先回填历史(升级路径)。** 如果你是从 `v0.3.4` 升级到 `v0.3.5+` 且数据库里已经有 `task_usage` 行,migration `103` 会以 `refusing to drop legacy daily rollups: ...` 报错并中止 `migrate up`,直到 hourly 表被 seed 过。先跑一次内置的 backfill 命令,然后再配 Option A 或 Option B 让新数据持续流进来:
**从 `v0.3.4v0.3.5+` 升级。** 上一版要求运维在应用 migration 103 之前手动跑 `cmd/backfill_task_usage_hourly`,否则 fail-closed 守卫会中止 `migrate up`。从 MUL-2957 起这一步是自动的migrate 命令会在应用 migration 103 之前advisory lock 4246 保护下)运行幂等的按月切片 backfill然后继续。在繁忙的数据库上你仍可以用 `--sleep-between-slices=2s` 跑独立 backfill 来限制读压力,但已不是必需。
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s` 用来在繁忙的数据库上限制读压力。回填跑完后重启后端容器migration 在启动时自动跑),升级就能继续。
完整参考(含 Kubernetes `CronJob` 模板和升级顺序)见仓库的 [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup)。
完整参考(含运维注意事项和 Kubernetes 部署形态)见仓库的 [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup)。
## Kubernetes 部署(替代方案)
@@ -274,8 +269,8 @@ multica setup self-host \
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
- **验证码收不到**没配任何邮件后端Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
- **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上)
- **Usage / Runtime 看板一直是 0**:没人调度 `rollup_task_usage_hourly()` —— 见上面的 [第 7 步](#7-调度用量汇总任务usage-dashboard-必需) 和 [故障排查 → Usage 看板一直是 0](/troubleshooting#usage-看板一直是-0)
- **`migrate up` 报 `refusing to drop legacy daily rollups`**`v0.3.4 → v0.3.5+` 升级路径的 fail-closed guard。`backfill_task_usage_hourly` —— 见 [第 7 步 → Option C](#7-调度用量汇总任务usage-dashboard-必需)
- **Usage / Runtime 看板一直是 0**:没人调度 `rollup_task_usage_hourly()` —— 见上面的 [第 7 步](#7-usage-rollup-no-operator-action-required) 和 [故障排查 → Usage 看板一直是 0](/troubleshooting#usage-看板一直是-0)
- **`migrate up` 报 `refusing to drop legacy daily rollups`**`v0.3.4 → v0.3.5+` 升级路径的 fail-closed guard。从 MUL-2957 起 migrate 命令在应用 migration 103 之前会自动跑 backfill —— 见 [第 7 步](#7-usage-rollup-no-operator-action-required)
## 下一步

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

@@ -42,6 +42,8 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
两种超时的失败原因都是 `timeout`**会自动重试**(下一节)。关联的运行时失联判定见 [守护进程与运行时 → 运行时什么时候被判定为离线](/daemon-runtimes#运行时什么时候被判定为离线)。
上面这层是**服务端的粗粒度兜底**——按任务启动时间算,不看任务是否还在活动。真正区分「卡死」和「正常的长任务」的是**本地守护进程**:它不再用固定墙钟时长砍任务(`MULTICA_AGENT_TIMEOUT` 默认 `0` = 不设上限),而是看活动——只要 agent 还在持续产出事件(消息、工具调用),守护进程就不会因为跑得久判它超时(服务端那条 2.5h 仍是外层上限)。只有真正静默卡死时才会被**空闲看门狗**`MULTICA_AGENT_IDLE_WATCHDOG`,默认 30 分钟)终止;如果是某个工具调用发出后长时间无任何输出(疑似卡死的子进程),则由更大的**工具看门狗**预算(`MULTICA_AGENT_TOOL_WATCHDOG`,默认 2 小时)兜底。这类被看门狗终止的任务失败原因是 `idle_watchdog`,和墙钟 `timeout` 区分开。各参数见 [环境变量 → 守护进程的调节参数](/environment-variables#守护进程的调节参数)。
## 哪些失败会自动重试,哪些不会
失败分两类:**可重试**和**不可重试**。
@@ -105,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

@@ -180,9 +180,9 @@ docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
**考えられる原因**:
1. **`rollup_task_usage_hourly()` が一切スケジュールされていない** — 使用量 / ランタイムのダッシュボードは派生テーブル `task_usage_hourly` から読み取り、このテーブルはその関数によって埋められます。同梱の `pgvector/pgvector:pg17` イメージには `pg_cron` が含まれておらず、バックエンドもプロセス内で rollup を実行しません。外部スケジューラのない新規セルフホストインストールでは、これがデフォルトの状態です。
2. **`pg_cron` はインストールされているが誤ったデータベースを指している** — `pg_cron.database_name` のデフォルトは `postgres` です。Multica データベース名が異なる場合、スケジュールされたジョブは `rollup_task_usage_hourly()` を一切見つけられません。
3. **スケジューラは動作しているが rollup が静かにエラーを出している** — 例えば cron エントリ内部の DB ロール / search_path が誤っている
1. **`rollup_task_usage_hourly()` がクレームされていない** — 使用量 / ランタイムのダッシュボードは派生テーブル `task_usage_hourly` から読み取り、このテーブルはその関数によって埋められます。MUL-2957 以降、バックエンドは DB バックドのスケジューラー(`sys_cron_executions`)を介してこの rollup をインプロセスで実行します。古いビルド、未適用の migration `113`、またはレプリカが残っていない長時間のバックエンド停止があると、最近の SUCCESS 行のないテーブルが残ることがあります。
2. **`pg_cron` は互換性のために構成されているが誤ったデータベースを指している** — `pg_cron.database_name` のデフォルトは `postgres` です。Multica データベース名が異なる場合、スケジュールされたジョブは `rollup_task_usage_hourly()` を一切見つけられません。インプロセススケジューラーはこれに依存しませんが、もしインプロセススケジューラーを除去して `pg_cron` に依存している場合、DB 名は一致しなければなりません。
3. **ハンドラーがクレームされているが静かにエラーを出している** — マイグレーションが部分的にしか適用されていないために SQL 関数が欠落している、あるいは DB ロール / search_path が誤って構成されている、など。`sys_cron_executions` の FAILED 監査行を確認してください
**診断方法**:
@@ -191,24 +191,30 @@ docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- Confirm pg_cron is (or isn't) available.
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- If pg_cron is installed, check the schedule + last run.
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- Inspect the in-process scheduler's audit log.
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;
-- Watermark — if this is 1970-01-01, the rollup has never run.
SELECT watermark_at FROM task_usage_hourly_rollup_state;
-- Compatibility path: if you previously registered pg_cron, confirm
-- it is (or isn't) available and pointing at the right database.
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
SELECT jobname, schedule, database, active FROM cron.job;
```
**解決方法**:
- rollup を手動で一度呼び出して動作するか確認してください: `SELECT rollup_task_usage_hourly();` — ダッシュボードを再読み込みしてください。数値が表示されれば、欠けているのはスケジューラだけです。
- [セルフホストクイックスタート → 使用量 rollup のスケジューリング](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)からサポートされる方式のいずれかを選んでください: 外部 cron / systemd-timer / Kubernetes CronJob、または Postgres を `pg_cron` を含むイメージに置き換える
- スケジュール設定より前の履歴がすでにある場合は、バックエンドコンテナ内部で `backfill_task_usage_hourly` を実行し、ウォーターマーク以前のバケットを埋めてください。
- 少なくとも 1 つのバックエンドレプリカでスケジューラーが実際に動作していることを確認してください — 30 秒ごとに `sys_cron_executions` の `rollup_task_usage_hourly` に SUCCESS 行が追加されているはずです。
- SQL パスを検証するため、rollup を手動で一度呼び出してください: `SELECT rollup_task_usage_hourly();` — ダッシュボードを再読み込みしてください。数値が表示されれば SQL 関数は問題なく、スケジューラーのクレーム経路に問題があります
- migration `113_sys_cron_executions` がまだ適用されていない場合は、バックエンドを再起動してマイグレーションを実行するか、手動で `migrate up` を呼び出してください。
- インプロセススケジューラー以前のレガシー `pg_cron` 履歴がある場合でも、SQL 関数は内部で advisory lock 4246 を保持するため二重書き込みは発生しません — オプションの `cron.unschedule` クリーンアップについては [セルフホストクイックスタート → 使用量ロールアップ](/self-host-quickstart#7-usage-rollup-no-operator-action-required) を参照してください。
## マイグレーション `103` が `refusing to drop legacy daily rollups` で失敗する
@@ -224,9 +230,11 @@ ERROR: refusing to drop legacy daily rollups:
**考えられる原因**: これはマイグレーション `103` の fail-closed ガードです。`task_usage_hourly` が生の `task_usage` に追いつくまで、レガシーの daily rollup の削除を拒否します。既存の行が存在し、rollup のウォーターマークがまだ epoch に留まっているとき — つまり、まだどの履歴も hourly テーブルに rollup されていないとき — にこのガードが発動します。
MUL-2957 以降、migrate コマンドは migration `103` を適用する直前に冪等な月別スライス backfilladvisory lock 4246 の下を自動で実行するため、v0.3.4 → v0.3.5+ への直接アップグレードは単一の `migrate up` 呼び出しで完了します。それでもこのエラーが表示される場合は、MUL-2957 以前のバイナリを使用しているか、フック自体が失敗しています — 直前の `task_usage hourly rollup hook` 行で migrate ログを確認してください。
**解決方法**:
1. 同じデータベースに対して backfill を実行してください(冪等であり、中断しても安全で、再実行しても安全です):
1. MUL-2957 以前のバイナリを使用しており、まずバイナリをアップグレードできない場合は、同じデータベースに対してスタンドアロンの backfill を実行してください(冪等であり、中断しても安全で、再実行しても安全です):
```bash
# Docker Compose
@@ -239,7 +247,7 @@ ERROR: refusing to drop legacy daily rollups:
```
2. アップグレードを再実行してください — バックエンドコンテナを再起動するだけで十分で、マイグレーションは起動時に実行されます。これでガードが最新のウォーターマークを確認し、`103` の適用を許可します。
3. ウォーターマークが進み続けるように、継続的な rollup スケジュールcron / `pg_cron`)を設定してください — [セルフホストクイックスタート → 使用量 rollup のスケジューリング](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)を参照してください。
3. インプロセススケジューラーがウォーターマークを進め続けます — [セルフホストクイックスタート → 使用量ロールアップ](/self-host-quickstart#7-usage-rollup-no-operator-action-required) を参照してください。
`--sleep-between-slices=2s` は、数年分の履歴を持つプロダクションデータベースにとって控えめなデフォルト値です。直近 N か月のみを保持し、それより古いバケットを永久に放棄してもかまわない場合は `--months-back N --force-partial` を使用してください。

View File

@@ -180,9 +180,9 @@ docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
**가능한 원인**:
1. **`rollup_task_usage_hourly()`가 전혀 스케줄링되지 않음** — 사용량 / 런타임 대시보드는 파생 테이블 `task_usage_hourly`에서 읽으며, 이 테이블은 해당 함수로 채워집니다. 번들된 `pgvector/pgvector:pg17` 이미지에는 `pg_cron`이 포함되어 있지 않으며, 백엔드도 프로세스 내에서 rollup을 실행하지 않습니다. 외부 스케줄러 없이 새로 설치한 자체 호스팅에서는 이것이 기본 상태입니다.
2. **`pg_cron`이 설치되었지만 잘못된 데이터베이스를 가리킴** — `pg_cron.database_name`의 기본값은 `postgres`입니다. Multica 데이터베이스 이름이 다르면 스케줄된 작업이 `rollup_task_usage_hourly()`를 전혀 보지 못합니다.
3. **스케줄러는 실행되지만 rollup이 조용히 오류를 냄** — 예를 들어 cron 항목 내부의 DB 역할 / search_path가 잘못됨.
1. **`rollup_task_usage_hourly()`가 클레임되지 않음** — 사용량 / 런타임 대시보드는 파생 테이블 `task_usage_hourly`에서 읽으며, 이 테이블은 해당 함수로 채워집니다. MUL-2957부터 백엔드는 DB 기반 스케줄러(`sys_cron_executions`)를 통해 인프로세스로 rollup을 실행합니다. 오래된 빌드, 적용되지 않은 migration `113`, 또는 레플리카가 남아있지 않은 장기간의 백엔드 중단이 있으면 최근 SUCCESS 행이 없는 테이블이 남을 수 있습니다.
2. **`pg_cron`이 호환성 용도로 구성되었지만 잘못된 데이터베이스를 가리킴** — `pg_cron.database_name`의 기본값은 `postgres`입니다. Multica 데이터베이스 이름이 다르면 스케줄된 작업이 `rollup_task_usage_hourly()`를 전혀 보지 못합니다. 인프로세스 스케줄러는 이에 의존하지 않지만, 인프로세스 스케줄러를 제거하고 `pg_cron`에 의존한다면 DB 이름이 일치해야 합니다.
3. **핸들러가 클레임되지만 조용히 오류를 냄** — 예: 마이그레이션이 일부만 적용되어 SQL 함수가 누락되었거나, DB 역할 / search_path가 잘못 구성됨. `sys_cron_executions`의 FAILED 감사 행을 확인하세요.
**진단 방법**:
@@ -191,24 +191,30 @@ docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- Confirm pg_cron is (or isn't) available.
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- If pg_cron is installed, check the schedule + last run.
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- Inspect the in-process scheduler's audit log.
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;
-- Watermark — if this is 1970-01-01, the rollup has never run.
SELECT watermark_at FROM task_usage_hourly_rollup_state;
-- Compatibility path: if you previously registered pg_cron, confirm
-- it is (or isn't) available and pointing at the right database.
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
SELECT jobname, schedule, database, active FROM cron.job;
```
**해결 방법**:
- rollup을 수동으로 한 번 호출하여 동작하는지 확인하세요: `SELECT rollup_task_usage_hourly();` — 대시보드를 새로고침하세요. 숫자가 나타나면 빠진 것은 스케줄러뿐입니다.
- [자체 호스팅 빠른 시작 → 사용량 rollup 스케줄링](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)에서 지원되는 방식 중 하나를 선택하세요: 외부 cron / systemd-timer / Kubernetes CronJob, 또는 Postgres를 `pg_cron`이 포함된 이미지로 교체.
- 스케줄 설정 이전의 이력이 이미 있다면, 백엔드 컨테이너 내부에서 `backfill_task_usage_hourly`를 실행하여 워터마크 이전의 버킷을 채우세요.
- 적어도 하나의 백엔드 레플리카에서 스케줄러가 실제로 실행 중인지 확인하세요 — 30초마다 `sys_cron_executions`의 `rollup_task_usage_hourly`에 SUCCESS 행이 추가되어야 합니다.
- SQL 경로를 검증하기 위해 rollup을 수동으로 한 번 호출하세요: `SELECT rollup_task_usage_hourly();` — 대시보드를 새로고침하세요. 숫자가 나타나면 SQL 함수는 정상이며, 문제는 스케줄러 클레임 경로에 있습니다.
- migration `113_sys_cron_executions`가 아직 적용되지 않았다면 백엔드를 재시작해 마이그레이션을 실행하거나 수동으로 `migrate up`을 호출하세요.
- 인프로세스 스케줄러 이전의 레거시 `pg_cron` 이력이 있어도 SQL 함수가 내부적으로 advisory lock 4246을 잡고 있어 두 경로가 이중 쓰기할 수 없습니다 — 선택적 `cron.unschedule` 정리는 [자체 호스팅 빠른 시작 → 사용량 롤업](/self-host-quickstart#7-usage-rollup-no-operator-action-required)을 참고하세요.
## 마이그레이션 `103`이 `refusing to drop legacy daily rollups`로 실패함
@@ -224,9 +230,11 @@ ERROR: refusing to drop legacy daily rollups:
**가능한 원인**: 이것은 마이그레이션 `103`의 fail-closed 가드입니다. `task_usage_hourly`가 원시 `task_usage`를 따라잡을 때까지 레거시 daily rollup 삭제를 거부합니다. 기존 행이 존재하고 rollup 워터마크가 여전히 epoch에 머물러 있을 때 — 즉 아직 어떤 이력도 hourly 테이블로 rollup되지 않았을 때 — 이 가드가 발동합니다.
MUL-2957부터 migrate 명령은 migration `103`을 적용하기 직전에 멱등한 월별 슬라이스 backfill(advisory lock 4246 보호)을 자동으로 실행하므로, v0.3.4 → v0.3.5+ 직접 업그레이드는 단일 `migrate up` 호출로 완료됩니다. 그래도 이 오류가 보인다면, MUL-2957 이전 바이너리를 사용 중이거나 훅 자체가 실패한 것입니다 — migrate 로그에서 `task_usage hourly rollup hook` 로그를 확인하세요.
**해결 방법**:
1. 같은 데이터베이스에 대해 backfill을 실행하세요(멱등하며, 중단해도 안전하고, 다시 실행해도 안전합니다):
1. MUL-2957 이전 바이너리를 사용 중이고 바이너리를 먼저 업그레이드할 수 없다면, 같은 데이터베이스에 대해 스탠드얼론 backfill을 실행하세요(멱등하며, 중단해도 안전하고, 다시 실행해도 안전합니다):
```bash
# Docker Compose
@@ -239,7 +247,7 @@ ERROR: refusing to drop legacy daily rollups:
```
2. 업그레이드를 다시 실행하세요 — 백엔드 컨테이너를 재시작하는 것으로 충분하며, 마이그레이션은 시작 시 실행됩니다. 이제 가드가 최신 워터마크를 확인하고 `103`을 적용하도록 허용합니다.
3. 워터마크 계속 진행되도록 지속적인 rollup 스케줄(cron / `pg_cron`)을 설정하세요 — [자체 호스팅 빠른 시작 → 사용량 rollup 스케줄링](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)을 참고하세요.
3. 인프로세스 스케줄러가 이후 워터마크 계속 진행시킵니다 — [자체 호스팅 빠른 시작 → 사용량 롤업](/self-host-quickstart#7-usage-rollup-no-operator-action-required)을 참고하세요.
`--sleep-between-slices=2s`는 수년 치 이력이 있는 프로덕션 데이터베이스에서 적절한 기본값입니다. 최근 N개월만 보관하고 더 오래된 버킷을 영구히 포기해도 괜찮다면 `--months-back N --force-partial`을 사용하세요.

View File

@@ -180,9 +180,9 @@ Check your inbox (including spam) for the real verification code.
**Likely causes**:
1. **`rollup_task_usage_hourly()` is never scheduled** — the Usage / Runtime dashboards read from the derived `task_usage_hourly` table, which is populated by that function. The bundled `pgvector/pgvector:pg17` image does not include `pg_cron`, and the backend does not run the rollup in-process either. On a fresh self-host install with no external scheduler, this is the default state.
2. **`pg_cron` is installed but pointing at the wrong database** — `pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, the scheduled job never sees `rollup_task_usage_hourly()`.
3. **The scheduler is running but the rollup is silently erroring** — e.g. wrong DB role / search_path inside the cron entry.
1. **`rollup_task_usage_hourly()` is never being claimed** — the Usage / Runtime dashboards read from the derived `task_usage_hourly` table, populated by that function. Since MUL-2957 the backend runs the rollup in-process via the DB-backed scheduler (`sys_cron_executions`); a stale build, a missing migration `113`, or a sustained backend outage with no replicas left running can leave the table without a recent SUCCESS row.
2. **`pg_cron` is configured for compatibility but pointing at the wrong database** — `pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, the scheduled job never sees `rollup_task_usage_hourly()`. The in-process scheduler does not depend on this, but if you removed the in-process scheduler and rely on `pg_cron`, the DB name must match.
3. **The handler is being claimed but silently erroring** — e.g. the SQL function is missing because migrations were partially applied, or DB role / search_path is misconfigured. Check the FAILED audit rows in `sys_cron_executions`.
**How to diagnose**:
@@ -191,24 +191,30 @@ Check your inbox (including spam) for the real verification code.
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- Confirm pg_cron is (or isn't) available.
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- If pg_cron is installed, check the schedule + last run.
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- Inspect the in-process scheduler's audit log.
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;
-- Watermark — if this is 1970-01-01, the rollup has never run.
SELECT watermark_at FROM task_usage_hourly_rollup_state;
-- Compatibility path: if you previously registered pg_cron, confirm
-- it is (or isn't) available and pointing at the right database.
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
SELECT jobname, schedule, database, active FROM cron.job;
```
**How to fix**:
- Call the rollup once by hand to confirm it works: `SELECT rollup_task_usage_hourly();` — refresh the dashboard; if numbers appear, the only missing piece is a scheduler.
- Pick one of the supported paths from [Self-host quickstart → Schedule the usage rollup](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard): external cron / systemd-timer / Kubernetes CronJob, or swap Postgres for an image with `pg_cron`.
- If you already have history that pre-dates the schedule, run `backfill_task_usage_hourly` inside the backend container to seed buckets before the watermark.
- Confirm the scheduler is actually running on at least one backend replica — every 30 seconds it should add a SUCCESS row to `sys_cron_executions` for `rollup_task_usage_hourly`.
- Call the rollup once by hand to verify the SQL path: `SELECT rollup_task_usage_hourly();` — refresh the dashboard; if numbers appear, the SQL function is fine and the issue is on the scheduler claim path.
- If migration `113_sys_cron_executions` has not applied yet, restart the backend so migrations run, or invoke `migrate up` manually.
- If you have legacy `pg_cron` history that pre-dates the in-process scheduler, the SQL function still holds advisory lock 4246 internally and the two paths cannot double-write — see [Self-host quickstart → Usage rollup](/self-host-quickstart#7-usage-rollup-no-operator-action-required) for the optional `cron.unschedule` cleanup.
## Migration `103` fails with `refusing to drop legacy daily rollups`
@@ -224,9 +230,11 @@ ERROR: refusing to drop legacy daily rollups:
**Likely cause**: this is migration `103`'s fail-closed guard. It refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up with raw `task_usage`. The guard fires whenever existing rows are present and the rollup watermark still sits at the epoch — i.e. nothing has rolled history into the hourly table yet.
Since MUL-2957 the migrate command runs an idempotent monthly-slice backfill (under advisory lock 4246) automatically immediately before applying migration `103`, so v0.3.4 → v0.3.5+ direct upgrades complete in a single `migrate up` invocation. If you are still seeing this error you are either on a pre-MUL-2957 binary or the hook itself failed — check the migrate logs for an earlier `task_usage hourly rollup hook` line.
**How to fix**:
1. Run the backfill against the same database (idempotent, safe to interrupt, safe to re-run):
1. If you are on a pre-MUL-2957 binary and cannot upgrade the binary first, run the standalone backfill against the same database (idempotent, safe to interrupt, safe to re-run):
```bash
# Docker Compose
@@ -239,7 +247,7 @@ ERROR: refusing to drop legacy daily rollups:
```
2. Re-run the upgrade — restarting the backend container is enough, migrations run on startup. The guard now sees a current watermark and lets `103` apply.
3. Set up an ongoing rollup schedule (cron / `pg_cron`) so the watermark keeps advancing — see [Self-host quickstart → Schedule the usage rollup](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard).
3. The in-process scheduler then keeps the watermark advancing — see [Self-host quickstart → Usage rollup](/self-host-quickstart#7-usage-rollup-no-operator-action-required).
`--sleep-between-slices=2s` is a polite default on production databases with years of history. Use `--months-back N --force-partial` if you only want to keep the last N months and are willing to permanently abandon older buckets.

View File

@@ -180,9 +180,9 @@ docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
**可能原因**
1. **`rollup_task_usage_hourly()` 没人调度** —— Usage / Runtime 看板读的是派生表 `task_usage_hourly`,这张表必须靠 `rollup_task_usage_hourly()` 周期性填充。默认的 `pgvector/pgvector:pg17` 镜像不带 `pg_cron`,后端进程内部也不会跑 rollup。如果你是新装的自部署、没配过外部调度器默认就是这种状态
2. **`pg_cron` 装了但指向了错的库** —— `pg_cron.database_name` 默认是 `postgres`;如果你的 Multica 数据库名不是 `postgres`,调度任务根本看不到 `rollup_task_usage_hourly()`。
3. **调度跑了,但 rollup 静默报错** —— 比如 cron entry 里 DB role / search_path 不对
1. **`rollup_task_usage_hourly()` 没被认领** —— Usage / Runtime 看板读的是派生表 `task_usage_hourly`,这张表必须靠 `rollup_task_usage_hourly()` 周期性填充。从 MUL-2957 起后端通过 DB 后端调度器(`sys_cron_executions`)在进程内跑 rollup旧版本 binary、未应用 migration `113`、或者所有副本长时间下线,都可能让这张表里没有最近的 SUCCESS 行
2. **`pg_cron` 作为兼容路径配着、但指向了错的库** —— `pg_cron.database_name` 默认是 `postgres`;如果你的 Multica 数据库名不是 `postgres`,调度任务根本看不到 `rollup_task_usage_hourly()`。进程内调度器不依赖这一项,但如果你刻意拿掉了进程内调度而靠 `pg_cron`DB 名就必须对得上。
3. **handler 被认领了、但静默报错** —— 比如 migration 没全部应用导致 SQL 函数缺失、或 DB role / search_path 配错了。看 `sys_cron_executions` 里的 FAILED 审计行
**怎么查**
@@ -191,24 +191,29 @@ docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- 看 pg_cron 装没装、有没有加载
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- 如果 pg_cron 装了,看调度和最近一次运行
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- 看进程内调度器的审计日志
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;
-- watermark —— 如果还是 1970-01-01说明 rollup 从来没跑过
SELECT watermark_at FROM task_usage_hourly_rollup_state;
-- 兼容路径:以前注册过 pg_cron确认装没装、指对了库没
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
SELECT jobname, schedule, database, active FROM cron.job;
```
**怎么修**
- 手动跑一次确认函数本身没问题:`SELECT rollup_task_usage_hourly();` —— 刷新看板;如果数字出来了,缺的就只是调度器
- 从 [Self-host 快速上手 → 调度用量汇总任务](/self-host-quickstart#7-调度用量汇总任务usage-dashboard-必需) 里挑一种调度方式:外部 cron / systemd-timer / Kubernetes CronJob或者换成带 `pg_cron` 的 Postgres 镜像
- 如果调度配好之前数据库已经有一段历史,先在后端容器里跑 `backfill_task_usage_hourly` 把 watermark 之前的桶补出来
- 确认至少一个后端副本里调度器真的在跑 —— 每 30 秒应该往 `sys_cron_executions` 的 `rollup_task_usage_hourly` 加一条 SUCCESS 行
- 手动跑一次 SQL 验证函数本身没问题:`SELECT rollup_task_usage_hourly();` —— 刷新看板如果数字出来了SQL 这层 OK问题在调度器认领路径上
- 如果 migration `113_sys_cron_executions` 还没应用,重启后端让 migration 跑一遍,或手动 `migrate up`
- 历史里有遗留的 `pg_cron` 入口也没事 —— SQL 函数里还持有 advisory lock 4246应用调度器和 `pg_cron` 不会双写;要清掉冗余项见 [Self-host 快速上手 → 用量汇总](/self-host-quickstart#7-usage-rollup-no-operator-action-required) 里的 `cron.unschedule`。
## migration `103` 报 `refusing to drop legacy daily rollups`
@@ -224,9 +229,11 @@ ERROR: refusing to drop legacy daily rollups:
**可能原因**:这是 migration `103` 的 fail-closed guard。它要求 `task_usage_hourly` 已经追平了原始的 `task_usage` 之后,才允许丢掉旧的 daily rollup。只要数据库里有历史数据、且 rollup watermark 还停在 epoch说明还没把历史回填进 hourly 表),这条 guard 就会拦住。
从 MUL-2957 起migrate 命令在应用 migration `103` 之前会自动跑一次幂等的按月切片 backfilladvisory lock 4246 保护),所以 v0.3.4 → v0.3.5+ 直升一次 `migrate up` 就能搞定。如果你还看到这个错,要么用的是 MUL-2957 之前的二进制,要么 hook 自己也失败了 —— 看 migrate 日志里更早一行的 `task_usage hourly rollup hook` 看具体原因。
**怎么修**
1. 对同一个数据库跑一次 backfill幂等可以打断可以重试
1. 如果你跑的是 MUL-2957 之前的 binary又没办法先升级 binary对同一个数据库手动跑一次独立 backfill幂等可以打断可以重试
```bash
# Docker Compose
@@ -239,7 +246,7 @@ ERROR: refusing to drop legacy daily rollups:
```
2. 重新跑升级 —— 重启 backend 容器即可,启动时会自动跑 migration。Guard 看到新的 watermark`103` 就会通过。
3. 同时配上持续的 rollup 调度,保证 watermark 持续推进 —— 见 [Self-host 快速上手 → 调度用量汇总任务](/self-host-quickstart#7-调度用量汇总任务usage-dashboard-必需)。
3. 之后由进程内调度器持续推 watermark —— 见 [Self-host 快速上手 → 用量汇总](/self-host-quickstart#7-usage-rollup-no-operator-action-required)。
`--sleep-between-slices=2s` 在有多年历史的生产库上是个比较克制的默认值。如果你只想保留最近 N 个月、可以接受永久丢掉更老的桶,用 `--months-back N --force-partial`。

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

@@ -30,6 +30,7 @@ import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type { Workspace } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { WorkspaceAvatar } from "@/components/workspace/workspace-avatar";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
@@ -45,12 +46,12 @@ export default function SwitchWorkspaceRoute() {
const onSelect = (ws: Workspace) => {
if (ws.slug === activeSlug) return;
Alert.alert(
"切换工作区",
`确定切换到 "${ws.name}"?`,
"Switch workspace",
`Switch to "${ws.name}"?`,
[
{ text: "取消", style: "cancel" },
{ text: "Cancel", style: "cancel" },
{
text: "切换",
text: "Switch",
onPress: () => {
router.dismiss();
router.replace(`/${ws.slug}/inbox`);
@@ -64,7 +65,7 @@ export default function SwitchWorkspaceRoute() {
<View className="flex-1">
<View className="px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">
Switch workspace
</Text>
</View>
{isLoading ? (
@@ -80,7 +81,6 @@ export default function SwitchWorkspaceRoute() {
active={ws.slug === activeSlug}
onPress={() => onSelect(ws)}
iconTint={t.foreground}
mutedIconTint={t.mutedForeground}
/>
))}
</ScrollView>
@@ -94,13 +94,11 @@ function WorkspaceRow({
active,
onPress,
iconTint,
mutedIconTint,
}: {
workspace: Workspace;
active: boolean;
onPress: () => void;
iconTint: string;
mutedIconTint: string;
}) {
return (
<Pressable
@@ -108,18 +106,18 @@ function WorkspaceRow({
disabled={active}
accessibilityLabel={
active
? `${workspace.name}, 当前工作区`
: `切换到 ${workspace.name}`
? `${workspace.name}, current workspace`
: `Switch to ${workspace.name}`
}
className={cn(
"flex-row items-center gap-3 px-4 py-3 active:bg-secondary",
active && "opacity-100",
)}
>
<ExpoImage
source="sf:building.2"
tintColor={active ? iconTint : mutedIconTint}
style={{ width: 18, height: 18 }}
<WorkspaceAvatar
name={workspace.name}
avatarUrl={workspace.avatar_url}
size={24}
/>
<Text
className={cn(

View File

@@ -17,6 +17,7 @@ import type {
IssueStatus,
IssuePriority,
} from "@multica/core/types";
import { formatDateOnly } from "@multica/core/issues/date";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
@@ -46,6 +47,7 @@ const PRIORITY_LABEL: Record<IssuePriority, string> = {
// Mirrors useTypeLabels in packages/views/inbox/components/inbox-detail-label.tsx
const TYPE_LABEL: Record<InboxItemType, string> = {
issue_assigned: "Assigned",
issue_subscribed: "Subscribed",
unassigned: "Unassigned",
assignee_changed: "Reassigned",
status_changed: "Status changed",
@@ -64,12 +66,9 @@ const TYPE_LABEL: Record<InboxItemType, string> = {
quick_create_failed: "Quick-create failed",
};
// due_date is a calendar day — format timezone-safely (no offset day shift).
function shortDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return formatDateOnly(dateStr, { month: "short", day: "numeric" }, "en-US");
}
function singleLine(value: string | null | undefined): string {

View File

@@ -23,6 +23,7 @@ import type {
Issue,
IssuePriority,
} from "@multica/core/types";
import { formatDateOnly } from "@multica/core/issues/date";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
@@ -67,11 +68,11 @@ const ISSUE_PICKER_PATHNAMES = {
"due-date": "/[workspace]/issue/[id]/picker/due-date",
} as const satisfies Record<IssuePickerField, string>;
// due_date is a calendar day — format timezone-safely so the day never shifts
// with the viewer's offset. Mirrors web's formatDate in list-row/board-card.
function formatDueDate(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return formatDateOnly(iso, { month: "short", day: "numeric" }, "en-US") || null;
}
export function AttributeRow({ issue }: { issue: Issue }) {

View File

@@ -26,6 +26,7 @@ import { Linking, Pressable, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type { Attachment } from "@multica/core/types";
import { MarkdownImage } from "@/lib/markdown/markdown-image";
import { resolveAttachmentUrl } from "@/lib/attachment-url";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { Text } from "@/components/ui/text";
@@ -108,12 +109,19 @@ function FileCard({
return (
<Pressable
onPress={() => {
// download_url is the signed HTTPS link; opening it hands off to
// download_url is the canonical link opening it hands off to
// Safari which handles auth-token-free download + previewing for
// common types (PDF, txt). Mirrors what the markdown link renderer
// does for `[name](url)`.
if (attachment.download_url) {
void Linking.openURL(attachment.download_url);
//
// The backend may return a server-relative URL like
// `/api/attachments/{id}/download` when no CloudFront signer is
// configured (MUL-2976). RN's `Linking.openURL` requires an
// absolute http(s) URL — it returns "Cannot open URL" otherwise —
// so resolve against `EXPO_PUBLIC_API_URL` first.
const target = resolveAttachmentUrl(attachment.download_url);
if (target) {
void Linking.openURL(target);
}
}}
accessibilityRole="button"

View File

@@ -28,6 +28,7 @@
import { useMemo } from "react";
import { ActivityIndicator, Linking, Pressable, ScrollView, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { resolveAttachmentUrl } from "@/lib/attachment-url";
import { useLightbox } from "@/lib/markdown/lightbox-provider";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
@@ -193,8 +194,17 @@ function AttachmentChipView({ item, onRemove, onRetry }: AttachmentChipProps) {
// Prefer the local on-device file over the network URL — instant,
// no signed-URL round-trip, works the same pre/post upload.
open(item.localUri);
} else if (item.downloadUrl) {
void Linking.openURL(item.downloadUrl);
} else {
// Non-image file chip: open the canonical download URL in Safari.
// `downloadUrl` comes from `api.uploadFile(...).download_url`, which
// on non-CloudFront deployments is a server-relative path like
// `/api/attachments/{id}/download` (MUL-2976). RN's `Linking.openURL`
// requires an absolute http(s) URL — `Cannot open URL` otherwise — so
// resolve against `EXPO_PUBLIC_API_URL` first. Already-absolute
// CloudFront/presigned URLs pass through unchanged. `null` (no
// downloadUrl yet) falls through to a no-op.
const target = resolveAttachmentUrl(item.downloadUrl);
if (target) void Linking.openURL(target);
}
};

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