* docs(changelog): add v0.3.33 entry for the 2026-06-30 release (MUL-3889)
Adds the v0.3.33 release entry to the /changelog page in all four
landing locales (en, zh-Hans, ja, ko), covering the 16 user-visible
changes that landed on main since v0.3.32 (2026-06-29 release).
The entry groups changes into Features / Improvements / Bug Fixes,
using product-language phrasing per the team convention (no
"modified function X" style notes). The Chinese version follows the
team's localization convention: `agent` → `智能体`, `Squad` → `小队`,
while `Issue` stays as-is as the canonical product term.
Release highlights:
- feat(autopilot): View/Write permission layer + Manage Access (MUL-3807)
- feat(slack): unified chat history backfill (MUL-3871) and typing
reaction on inbound messages (MUL-3874)
- feat(skills): import skills from a .skill/.zip archive (MUL-3865)
- feat(cli)!: drop short UUID prefix resolution for `multica issue`
(MUL-3838)
- feat(views): Agents page mobile friendly (MUL-3873)
- improvement: rewrite of the comment routing cascade
(MUL-3794 + MUL-3879 follow-up)
- improvement: docs swap removed Gemini for CodeBuddy (MUL-3861) and
remove 117 dead _one i18n keys (MUL-3877)
- improvement: self-host preflight allows newer Docker Compose
- fix(daemon): reconcile in-flight task and workspace state on WS
reconnect (community contribution, closes#4665)
- fix(agent): recover Antigravity reply from transcript when stdout
is empty (MUL-3726)
- fix(server): skip CLIENT SETNAME for managed Redis compatibility
(MUL-3848, community contribution)
- fix(views): count tasks, not agents, in activity hover header
(MUL-3872)
Verified via the existing `apps/web typecheck`, vitest landing
suite (changelog-page-client.test.ts among them), and eslint on the
i18n directory; all green.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): tighten v0.3.33 entry wording (MUL-3889)
Per feedback that the first draft read too verbose for the public
changelog, trim every bullet of the v0.3.33 entry to one short
sentence and drop the supporting clauses that were rehashing
implementation detail (contributor handles, issue numbers, "30s
ticker" specifics, byline of what the rewrite incidentally fixed).
The net effect is a tighter list that matches the cadence of the
v0.3.32 / v0.3.31 entries already on the page.
Applied identically across en.ts / zh.ts / ja.ts / ko.ts.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Before this, the chat prompt only carried a generic, always-on hint ('if this
came from a chat channel...'), and the task carried no channel signal — so the
agent never definitively knew it was inside Slack. For an ambiguous ask like
'what did you just talk about', it could read Multica instead of the Slack
conversation.
- Thread a chat_channel_type ('slack') signal: the server sets it on the chat
task response when the session has a Slack binding
(GetChannelChatSessionBindingBySession); the daemon Task carries it.
- buildChatPrompt now emits an EXPLICIT block only when channel-backed: 'You are
operating inside a Slack conversation … this conversation and its history live
in Slack, NOT in Multica … read it with multica chat history, do NOT look in
Multica.' Web-only chat sessions get no such block (their history is the
Multica chat_session the agent already resumes).
Tests: slack-backed prompt asserts the explicit Slack/“NOT in Multica”/command
copy; web-only prompt asserts the block is absent.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): add unified `multica chat history` pull for channel backfill (MUL-3871)
Agents @mentioned in a Slack thread/channel only saw the triggering message,
never the prior conversation (GitHub #4717). Instead of force-assembling a
recent-context block on every inbound (the Feishu approach), expose a single
channel-agnostic pull command the agent runs on demand.
- channel: normalized HistoryMessage/HistoryPage/HistoryOptions vocab so the
agent sees one shape regardless of platform.
- slack.History: resolves session -> binding -> installation -> bot token and
reads conversations.replies (real thread) or conversations.history (DM /
top-level channel, capturing sibling messages). thread_ts is recorded on the
binding config at session creation to pick the right call.
- handler GET /api/chat/history: authorized purely by the task-scoped token
(stamped X-Task-ID -> the task's own chat session), so an agent can only read
the conversation it is currently running for.
- multica chat history CLI command (no args; same for every channel).
- buildChatPrompt nudge so the agent discovers the command.
Feishu is intentionally untouched. Adding a platform = implement the reader.
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): require task-token actor source on chat history endpoint
Niko's review caught a privilege-boundary hole: the endpoint trusted
X-Task-ID, but it is mounted under the general Auth group where a normal
JWT / mul_ PAT request does NOT strip a client-forged X-Task-ID — only the
mat_ task-token branch stamps it. A workspace member who knew a chat task id
could forge the header and read that task's Slack channel/DM/thread history.
Gate on the server-set X-Actor-Source == "task_token" (the Auth middleware
deletes any client-supplied value and re-stamps it only on the mat_ branch),
then trust X-Task-ID. Adds a regression test: a forged X-Task-ID without the
task-token actor source is rejected with 403 and never reaches the reader.
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): thread-first history for follow-ups, channel for first turn (MUL-3871)
A Slack conversation has two nested histories: the surrounding channel and the
agent's own thread (the bot's first reply opens a thread on the @mention). The
first version picked replies-vs-history from a thread_ts fixed at session
creation, so a session started by a top-level @mention always read CHANNEL
history — even on follow-ups inside the bot's thread, which should read THREAD
history first.
- Add a HistoryScope (auto|thread|channel). The handler resolves auto:
first turn (no prior bot reply) -> channel; follow-up -> thread. The agent can
override with --scope channel|thread, and the response reports the scope read.
- The thread root is derived from the binding (last_thread_id / composite-key
suffix), available for every engaged group session, instead of the
creation-time thread_ts (now removed from the binding config).
- A DM degrades a thread request to channel history (DMs have no threads).
- Prompt guidance + CLI help updated to explain the policy.
Tests: scope selection (thread/channel/DM-fallback/no-root), root derivation,
and handler auto-resolution (first->channel, follow-up->thread, explicit
override).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
After MUL-3794 rewrote the comment routing cascade, computeCommentAgentTriggers
returned early for every non-member author, so worker-agent result comments on a
squad-assigned issue no longer woke the assigned squad leader, breaking the
leader->worker->leader coordination loop.
Restore a narrow agent-authored fallback: when the issue is squad-assigned and
the author is not a member, route to routeAssignedSquadLeaderFallback. Member/
thread routing and explicit @agent/@squad mention routing are untouched, and the
lastTaskWasLeader self-trigger suppression is preserved (it lives inside
routeAssignedSquadLeaderFallback). Explicit mentions are handled before this
branch, so a mentioned target is never double-enqueued alongside the leader.
Co-authored-by: multica-agent <github@multica.ai>
agy 1.0.14 print mode can complete a turn (tools executed, final reply produced) while writing zero bytes to stdout, so the daemon recorded a blank but "completed" run and the user saw no answer (MUL-3726, #4595).
When an otherwise-completed turn returns empty stdout, recover the assistant text agy durably wrote to its per-conversation transcript, bounded to the current turn (reset on each USER_INPUT, status=DONE only) so a resumed conversation never re-emits prior turns' answers. App data dir is read from the daemon-owned --log-file rather than guessing $HOME. All paths fail soft to "" so genuine no-text completions and other statuses are unchanged.
Verified against real agy 1.0.14 output plus unit + end-to-end + resume-boundary tests.
Import a skill from a local .skill/.zip archive: POST /api/skills/import now accepts a multipart upload (file + on_conflict) alongside the JSON URL body, and the CLI gains `multica skill import --file <path>`. Reuses the existing create + on_conflict contract, per-file/bundle/count caps, reserved-SKILL.md rule, and a zip-slip guard.
Closes#4730
MUL-3865
ja/ko/zh-Hans resolve only the CLDR `other` plural category, so every
`_one` key in those locales is dead weight that i18next never renders.
Remove 117 such orphan keys across 25 namespaces. Each already has its
`_other` sibling, so this is behavior-preserving.
Also add a parity-test guard that fails if a locale whose CLDR plural
rules lack a `one` category ships any `_one` key, so these can't silently
accumulate again (gated on Intl.PluralRules, the same source i18next uses).
Follow-up to #4740 (MUL-3877).
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Slack is not officially launched yet, so the 'already created your app
with an older manifest — add reactions:write and reinstall' guidance is
unnecessary; nobody is running a pre-launch manifest in production. Remove
the warning callout from all four locales (en/zh/ja/ko).
The reactions:write scope in the manifest and the scope table stay, since
the typing indicator still depends on it.
Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): add typing reaction on inbound message (MUL-3874)
Mirror the Feishu typing indicator on Slack: react with 👀 on the user's
message when it is ingested, then remove the reaction when the agent's run
finishes (EventChatDone) or fails (EventTaskFailed).
- New slack.TypingIndicatorManager: Add on ingest, Clear on terminal run
events; state keyed by chat_session_id, bot token re-resolved from the DB on
clear (never held in memory), all failures logged and swallowed (best-effort).
- Wire via the channel-agnostic engine.TypingNotifier seam (slackTypingNotifier
in the ResolverSet) — the Router already calls OnIngested off the ACK path.
- Clear subscribes to the event bus directly so a failed run also drops the
reaction (the outbound replier only handles EventChatDone).
- Skip messages older than 2m so Socket Mode reconnect replays don't restamp.
Requires the installed Slack app to hold the reactions:write scope.
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): clear typing reaction when no task runs; document reactions:write (MUL-3874)
Addresses review feedback on the typing-indicator PR.
1. Stuck reaction on offline/archived agent. The debounced flush
(flushChatRun) enqueues no task when the agent has no runtime or is
archived (or on any enqueue/reload error), so no task lifecycle event is
ever published and the bus-driven clear never fires — leaving the 👀 (and
Feishu's Typing) reaction stuck on the user's message. Fix at the shared
engine seam: add TypingNotifier.OnSettled(ctx, sessionID), which the Router
calls from the flush on every no-task exit (before any offline/archived
notice). Both the Slack and Feishu notifiers route it to manager.Clear, so
the latent Feishu case is fixed too. Adds engine coverage (offline/archived
clear, success does not) and a Slack OnSettled test.
2. Missing reactions:write scope in docs. reactions.add/remove silently fail
without the scope, but the BYO app manifest/docs never listed it. Add
reactions:write to the manifest + scope table and a reinstall note across
all four locales (en/zh/ja/ko).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
After a WebSocket disconnect, the daemon's view of running tasks and
workspace state can lag the server for up to 5s (per-task cancellation
poll) or 30s (workspace sync) because both loops park on coarse tickers
that do not observe the WS wakeup channel.
This change adds a small fan-out broadcaster (`reconcileBroadcaster`)
that the WS connect path fires once per (re)connect. `watchTaskCancellation`
and `workspaceSyncLoop` subscribe and re-check immediately on broadcast,
without disturbing the ticker cadence. The broadcaster is edge-triggered
with a one-slot replay so a broadcast that lands before a subscriber is
ready is not lost (closes the daemon-startup race), and back-to-back
broadcasts inside 1s are debounced so a flapping connection cannot fan
out into a request stampede.
Existing behaviour is preserved: shouldInterruptAgent still decides
whether to interrupt, the 5s/30s ticker still bounds the worst case,
and the WS heartbeat / HTTP heartbeat coordination is untouched.
Closes#4665
The agent-activity hover card renders one row per task and counts tasks.length, but it reused the agent-worded hover_header copy, so a single agent running multiple tasks made the card read '3 agents working' while the workspace chip read '2 working' (unique agents).
Add a dedicated hover_header_tasks key (en/zh-Hans/ja/ko) and point the hover card at it so the header now reads '3 tasks working'. The per-issue chip keeps hover_header since it genuinely passes the unique-agent count.
Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Gemini CLI runtime was removed in MUL-3617 (#4503), but the canonical
"12 built-in providers" list still advertised [Gemini](/providers#gemini)
(a now-dangling anchor) and omitted CodeBuddy, which the daemon actually
auto-detects (config.go probes `codebuddy`). Swap Gemini -> CodeBuddy
across all 16 occurrences (index / how-multica-works / cloud-quickstart /
daemon-runtimes x en/zh/ja/ko); the count stays at twelve.
MUL-3861
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
BREAKING CHANGE: `multica issue <command> <ref>` no longer accepts short
UUID prefixes (e.g. `1881abcd`). Pass the issue key shown by
`multica issue list` (`MUL-123`) or the full UUID instead. Other
resources without a human-readable key (autopilots, projects, labels,
task runs, workspaces) continue to accept short UUID prefixes.
The previous resolver paged the entire workspace issue list client-side
to disambiguate a short prefix, which timed out on workspaces with
~1000 issues (14–35s; reported in GH #4701). Since the issue key
(`MUL-123`) already covers every human use case for an issue reference
and the full UUID covers every machine case, supporting a third
identifier form has no real product value and forces every issue
command to carry ambiguous / min-length / hex-validation semantics
through the CLI.
Rather than pushing the prefix resolver down into the server (with a
new DB query and an expression / generated index), this change removes
the path entirely. The user-facing migration is trivial: the
`identifier` column shown by `multica issue list` is already routable.
Changes:
- `resolveIssueRef` now accepts only the issue key (`MUL-123`) or the
full UUID. A short hex prefix returns a tailored error pointing to
the supported forms; non-hex gibberish returns a generic guidance
error. Neither path makes an HTTP call.
- The unused `fetchIssueCandidates` paginator is removed.
- Tests cover: full UUID succeeds via a single GET, identifier-first
resolution does not list, and short prefix / dashed short prefix /
bare numeric / non-hex inputs all fail fast with no HTTP traffic.
Product rationale and the first-principles discussion are recorded on
Multica issue MUL-3838.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): add View/Write permission layer
Autopilot write and execute operations were gated only by workspace
membership, so any member could edit, delete, trigger, or rotate the
webhook of any autopilot, and GetAutopilot returned webhook tokens to
every member (a token alone can trigger the autopilot).
- Add canWriteAutopilot / requireAutopilotWrite: update, delete, trigger,
replay-delivery, and all trigger/secret management now require the
autopilot creator or a workspace owner/admin.
- Redact webhook_token/path/url in GetAutopilot for callers without write
access; trigger metadata otherwise stays visible (View default = all
members). Creating an autopilot stays open to any member.
- ANDs with the existing private-assignee-agent dispatch gate.
MUL-3807
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): delegate write access via collaborators + manage-access UI
Adds an explicit grant primitive so an autopilot's creator/admin can
authorize specific workspace members to manage it, with a frontend entry
point — beyond the implicit creator/owner-admin set from the prior commit.
Backend:
- New autopilot_collaborator table (migration 128, members-only, app-layer
cleanup, no FK) + sqlc queries.
- memberCanWriteAutopilot now also honors explicit collaborators; the write
gate, webhook-secret redaction, and a new per-caller can_write flag (on
list + detail) all flow through it.
- POST/DELETE /api/autopilots/{id}/collaborators (writer-gated); GetAutopilot
embeds the collaborators list. Delete cleans up grants in its transaction.
- Tests: grant->write->revoke flow, non-writer can't grant, non-member rejected.
Frontend (web + desktop via packages/views):
- ManageAccessDialog: member picker to grant/revoke, current list with remove.
- 'Manage access' entry in the autopilot detail header; edit/run/add-trigger/
delete and the list-row kebab + per-trigger rotate/delete now gate on
can_write (absent => allowed, server stays the gate).
- can_write wired through types/schema/api client/mutations; en + zh-Hans copy.
MUL-3807
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): add manage-access i18n keys to ja/ko locales
The locale parity test requires every non-EN bundle to cover every EN
key. The prior commit added detail.manage_access + the access.* block to
en and zh-Hans only, failing parity for ja and ko. Add the translated
keys to both.
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): restrict access-list management to creator/admin only
Final-review fix: AddAutopilotCollaborator/RemoveAutopilotCollaborator
used requireAutopilotWrite, which counts granted collaborators as
writers — so a collaborator could in turn grant/revoke others, a
privilege escalation contradicting the 'collaborators cannot re-grant'
design.
- New requireAutopilotAccessManagement guard uses the narrower
autopilotWriteByOwnership predicate (creator or workspace owner/admin
only); swapped into both collaborator endpoints. Collaborators keep
their edit/trigger/secret write-execute rights.
- GetAutopilot now also stamps can_manage_access (narrower than
can_write); the detail page gates the 'Manage access' button on it so
collaborators no longer see an entry that would 403.
- Tests: collaborator grant-others -> 403, revoke-peer -> 403, while
retaining edit; can_manage_access true for owner, false for collaborator.
MUL-3807
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): add v0.3.32 entry for the 2026-06-29 release (MUL-3840)
Lands the daily release notes for v0.3.32 in all four landing locales (en / zh-Hans / ko / ja). Groups today's PRs by Feature / Improvement / Bug Fix with product-oriented wording, keeping technical commit jargon out of the user-facing changelog.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): drop two fixes from v0.3.32 per release-confirmation feedback
Removes the 'deleted agents hidden from usage leaderboard' (MUL-3771, #4637) and 'Antigravity daemon-mode guidance' fix lines from all four locales, leaving five customer-facing fixes for the 2026-06-29 release. The Issue body on MUL-3840 is kept in sync separately.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): drop reverted self-host onboarding beacon from v0.3.32
The anonymous self-host onboarding source beacon (MUL-3708, #4691) was reverted in #4712 because of issues with the collection path. Remove the corresponding feature bullet from all four locales so the v0.3.32 changelog only advertises what actually ships.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): hold Slack BYO app feature back from v0.3.32 user changelog
Per release confirmation, the Slack bring-your-own-app feature is shipping behind a disabled frontend entry for v0.3.32 — code lands, but it is not publicly available yet. Drop the Slack feature bullet from all four locales and rewrite the entry title around what is actually exposed to users (Remove parent Issue + daemon reconnect + attachment preview improvements).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
PR #4637 (MUL-3771) dropped hard-deleted agents from the per-agent
leaderboard so they'd stop rendering as a bare UUID, but the top-line
Cost/Tokens KPIs still count their spend (those totals aggregate
task_usage_hourly without joining `agent`). The breakdown therefore no
longer reconciled with the totals (#4640).
Instead of dropping unknown-agent rows, fold them into a single
aggregated "Deleted agents" row: sum(visible rows) == KPI total again,
with no UUID exposed. Archived agents still appear as themselves (the
agent list is fetched with include_archived). The bucket carries
tokens + cost only; Time/Tasks render as "—" since the run-time rollups
inner-join `agent` and never attribute time to deleted agents.
- bucketUnknownAgentRows replaces filterKnownAgentRows in dashboard/utils
- Leaderboard renders the sentinel bucket row with a neutral placeholder
and a "{{count}} agents · {{deleted}} deleted" caption
- i18n: deleted_agents + caption_with_deleted (en/zh-Hans/ja/ko)
- tests cover bucket reconciliation, archived-stays, null-loading passthrough
Co-authored-by: multica-agent <github@multica.ai>
Match the code fix (#4703): the "link your account" link is built from the web
app URL (MULTICA_APP_URL ?? FRONTEND_ORIGIN), which a normal deployment already
sets — not MULTICA_PUBLIC_URL (the backend/API URL). Updates en + zh + ja + ko.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The Slack "link your account" prompt built its redeem link from
MULTICA_PUBLIC_URL, but /slack/bind is a web-app page — the link must use the
web app URL, not the backend/API URL. MULTICA_PUBLIC_URL is intentionally the
backend/API public URL (webhooks, daemon server_url, attachments); the Lark
replier already uses appURLFromEnv() (MULTICA_APP_URL ?? FRONTEND_ORIGIN).
Slack was never migrated, so on deployments that set FRONTEND_ORIGIN but not
MULTICA_PUBLIC_URL (e.g. dev) the binding prompt silently failed
("public url not configured") and @-mentions got no response.
Rename slack.OutboundReplierConfig.PublicURL -> AppURL and feed it
appURLFromEnv() in router.go, mirroring Lark. Backend/API-URL uses of
MULTICA_PUBLIC_URL (webhooks, attachments, daemon server_url) are unchanged.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(slack): declutter the Slack connect UI
Trim the Slack bring-your-own-app UI to match the leaner Lark card and
stop burying the setup behind prose nobody reads:
- Drop the "Required bot scopes: …" block from the connect dialog.
- Shorten the Slack integration card description to mirror the Lark
card; the token/admin details stay in the setup docs.
- Remove the dialog intro paragraph and the per-field token hints;
replace the small "Read the setup guide" link with a larger,
more prominent step-by-step guide link.
Removes the now-unused i18n keys (byo_dialog_intro, byo_bot_token_hint,
byo_app_token_hint, byo_scopes_hint) across en/zh-Hans/ja/ko.
* docs(slack): drop the users:read warning callout
The bot manifest already lists users:read as a required scope (with the
bots.info rationale in the scopes table), so the standalone warning
callout was redundant. Removed across en/zh/ja/ko.
* feat(analytics): anonymous self-host onboarding source beacon (MUL-3708)
Production self-host servers now report the anonymous onboarding "how did
you hear about us" channel to Multica's public write-only ingest, so the
self-host source distribution becomes visible alongside official cloud.
Official cloud keeps its existing PostHog capture unchanged; this is a
submit-time beacon, not a background telemetry pipeline.
- server/internal/sourcebeacon: ShouldSend gate (production + non-local +
non-*.multica.ai app host, fail-closed — judged by the app/frontend host,
not the backend URL, which official often leaves unset), per-instance
salted hashing, deterministic event uuid, fire-and-forget sender.
- POST /api/telemetry/self-host-source: public, write-only, per-IP
rate-limited, 4 KiB body cap, channel allowlist, strict unknown-field
rejection. Lands in PostHog as self_host_source_channel with a
deterministic uuid (best-effort dedup), $process_person_profile=false,
and deployment=self_host — a distinct event name so it never pollutes the
official onboarding funnel.
- Hook in PatchOnboarding fires once when the source is first set; never
blocks onboarding. Only channel enum(s) + two per-instance hashes leave
the box — never user_id/email/name/workspace/org/domain/role/use_case/the
source_other free-text/IP.
- migration 128: system_settings singleton holding instance_salt.
- frontend: self-host-only anonymous-collection notice on the source step,
gated by a new /api/config self_host_source_notice flag (en/zh-Hans/ko/ja).
- analytics.Event gains an optional top-level uuid; docs/analytics.md,
SELF_HOSTING.md and .env.example document exactly what is/isn't sent and
how to disable it (ANALYTICS_DISABLED). Also fixes the long-standing
team_size→source drift in docs/analytics.md.
Verified locally: go build/vet, go test (sourcebeacon, analytics, handler),
pnpm typecheck (all packages), locale parity (157), step-source (6) + core
config/schema (69) vitest, lint (0 errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(analytics): wire self-host source beacon through metrics, guard nil pool (MUL-3708)
Addresses Howard CI blockers on #4691 (no product-direction change):
- loadInstanceSalt returns "" on nil pool; salt is only loaded when
ShouldSendFromEnv() is true, via a bounded (5s) context — restores the
"router constructible without a DB" invariant (nil-pool routing tests).
- Add multica_self_host_source_channel_total counter (by source) + an
IncForEvent case, so every analytics event is paired with a Prometheus
counter. NormalizeSourceChannel reuses sourcebeacon allowlist (no 3rd copy).
- Beacon handler now builds the event via the analytics.SelfHostSourceChannel
helper and ships it through obsmetrics.RecordEvent (no naked Capture); not
IsMetricsOnly, so it still reaches PostHog.
- Prime the new family in the registry-families test.
Verified: go build/vet, go test ./internal/metrics ./internal/sourcebeacon
./internal/handler ./cmd/server (incl. the 3 named blockers + registry +
record-event-helper lints) all green.
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>
Invalidate per-session chat message caches (messages, messages-page,
pending-task, task-messages) on websocket reconnect / WS instance change so
a chat that missed chat/task events while disconnected recovers without a
full reload, matching the existing per-issue recovery pattern.
Co-authored-by: Ryan <1141524679@qq.com>
The bring-your-own-app Connect Slack dialog only had a (hidden) video CTA, so
users had no in-product pointer to the setup instructions. Add an always-visible
"Read the setup guide" link that opens the Slack integration docs page,
localized to the viewer's language (https://multica.ai/docs[/<lang>]/slack-bot-integration),
following the existing doc-link convention in the app. Adds the byo_docs_link
string to en / zh-Hans / ja / ko.
The doc page it points to ships in the docs PR (#4693).
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs: channel integrations overview + Slack bot page + Slack app setup guide (MUL-3666)
- channels.mdx: channel-engine overview with an architecture diagram (Mermaid),
the inbound pipeline, the session/context model, and the authorization gates
(account binding + workspace membership) — all shared by Lark and Slack.
- slack-bot-integration.mdx: the Slack channel page (mirrors lark-bot-integration)
— BYO connect flow, usage (@ in channel / DM / /issue), one-bot-per-agent,
permissions, and self-host (MULTICA_SLACK_SECRET_KEY).
- create-slack-app.mdx: standalone step-by-step — create a Slack app from a
copy-paste manifest, install it, and grab the bot + app-level tokens.
- meta.json: list the three pages under Integrations.
English (canonical) only this pass; zh/ja/ko localization to follow.
Co-authored-by: multica-agent <github@multica.ai>
* docs(slack): inline full manifest + step-by-step setup into the Slack page (MUL-3666)
The Slack page only linked out for setup, which read as too thin. Fold the
complete, code-verified app manifest and the full walkthrough (create from
manifest → install + bot token → app-level token with connections:write →
connect in Multica) directly into slack-bot-integration.mdx, plus a table
explaining what each scope/event is for.
Remove the now-redundant standalone create-slack-app.mdx (its content lives on
the Slack page) and update meta.json + the channels.mdx links accordingly, so
there's one comprehensive Slack page and no duplicated manifest to drift.
Co-authored-by: multica-agent <github@multica.ai>
* docs(i18n): translate channel + Slack pages to zh/ja/ko and add to nav (MUL-3666)
Adds Simplified Chinese, Japanese, and Korean versions of channels.mdx and
slack-bot-integration.mdx, and lists both pages under Integrations in
meta.{zh,ja,ko}.json. The copy-paste manifest YAML and dotenv blocks are kept
byte-identical to the English source across all languages; in-page anchors in
the channel page point at the slug of each translated heading.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): single app-level Socket Mode connection routed by team_id (MUL-3666)
Reshape the Slack adapter from the stage-3 per-installation Socket Mode model
into the multi-tenant B2 connection model: ONE deployment-level Socket Mode
connection (app-level xapp- token, env MULTICA_SLACK_APP_TOKEN) receives the
Events API stream for every installed workspace and routes each inbound event
to its channel_installation by team_id — the existing
GetChannelInstallationByAppID routing, unchanged.
- AppConnector: the single shared connection (slack/app_connector.go). No leader
election — per the design "one (or a few)" connections are fine: each replica
opens one, Slack delivers each event to one of them, and the existing
(installation, message_id) two-phase dedup guarantees exactly-once processing.
Resolves the per-team bot user id (via the same app_id query) to detect/strip
@-mentions, since one connection serves many workspaces.
- Inbound translation (Events API -> channel.InboundMessage) extracted to
slack/inbound.go as free functions parameterized by the per-team bot identity.
- channel.go trimmed to the outbound Send-only sender; per-installation config
(config.go) no longer carries an app-level token — installs hold only the
per-workspace bot token (xoxb-) for outbound, since xapp- can't be OAuth'd.
- engine.Supervisor now skips channel types with no registered Factory, so Slack
installs (driven by the app-level connector, not per-installation channels) no
longer churn the lease/Build loop.
- Wiring: router.go builds the connector when MULTICA_SLACK_APP_TOKEN is set;
main.go runs it alongside the Supervisor. Feishu untouched; channel_* schema
unchanged.
Verified: go build ./..., go vet ./..., gofmt, and
go test ./internal/integrations/... all pass.
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): OAuth self-serve install backend (MUL-3666)
Add the in-product OAuth install flow that creates Slack installations, the
keystone the B2 connector consumes.
- slack.InstallService: Begin (build authorize URL, seal workspace/agent/
initiator into the OAuth state), Complete (verify state, exchange code via
oauth.v2.access, upsert channel_type='slack' install with the bot token
encrypted at rest, auto-bind the installer's Slack id so their first message
is not dropped), plus List/Get/Revoke. State is stateless: sealed with the
deployment secretbox + an embedded expiry, no session store.
- HTTP handlers (handler/slack.go): member-visible list, admin-only begin +
revoke, and the public OAuth callback (recovers context from the sealed state,
redirects the browser back to Settings → Integrations with a result flag).
- Routes + wiring: workspace-scoped list/begin/revoke mirror the Lark
admin/member split; the callback is a public route like GitHub's. Built from
MULTICA_SLACK_CLIENT_ID/SECRET (+ redirect derived from MULTICA_PUBLIC_URL,
override MULTICA_SLACK_REDIRECT_URL; scopes via MULTICA_SLACK_SCOPES).
- Realtime: slack_installation:created / :revoked events.
Verified: go build ./..., go vet, gofmt, and go test ./internal/integrations/slack/...
all pass (new install_test.go covers state sign/verify/expiry/tamper, authorize
URL, code exchange + encrypted upsert + installer bind, and oauth error paths).
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): in-product OAuth install UI for web + desktop (MUL-3666)
Add the "Connect Slack" self-serve install UI mirroring the Feishu/Lark
integration, completing the in-product install half of B2. Slack's OAuth flow
is a redirect (not a device-code QR poll), so the UI is simpler than Lark's.
- core: SlackInstallation / List / Begin types; api.listSlackInstallations /
beginSlackInstall / deleteSlackInstallation; slackKeys + slackInstallationsOptions
query; realtime invalidation on slack_installation:* events.
- views: slack-tab.tsx (SlackTab settings panel + per-agent SlackAgentBindButton
+ connected badge + disconnect confirm). Connect calls beginSlackInstall and
hands the authorize URL to openExternal (system browser on desktop, new tab on
web); Slack bounces to the backend callback which lands the install, and the
realtime event refreshes the list. Wired into the Settings → Integrations tab
and the agent-detail Integrations tab alongside Lark.
- i18n: en + zh-Hans settings.slack.* strings.
Verified: pnpm typecheck (full monorepo, 6/6) and pnpm lint (@multica/core,
@multica/views — 0 errors) pass.
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): outbound Replier + user-binding redeem flow (MUL-3666)
Fill the stage-3 Replier=nil tail so non-installer Slack users can onboard and
get status feedback — completing B2 end to end.
- slack.OutboundReplier (engine.OutboundReplier): on NeedsBinding it mints a
single-use binding token and DMs/replies a "link your account" prompt with the
redeem URL (wrapped as <url|label> so formatMrkdwn doesn't mangle the
base64url token); on AgentOffline/AgentArchived it posts a status notice; on an
/issue-created Ingest it confirms the new issue. Plain chat stays silent (the
agent's own reply lands via EventChatDone). Reuses the bot-token Send path and
reads the installation row from ResolvedInstallation.Platform — no new transport.
- slack.BindingTokenService: Mint + transactional RedeemAndBind over the generic
channel_binding_token / channel_user_binding queries (channel_type='slack'),
mirroring lark.BindingTokenService. 15-min TTL, SHA256-hashed tokens, the
three typed failure modes (invalid/expired, already-assigned, not-member).
- HTTP: POST /api/slack/binding/redeem (public, session-authed) maps the failures
to 410/409/403. NewSlackResolverSet now takes the replier (nil disables it).
- Frontend: /slack/bind redeem page (packages/views/slack + apps/web route) +
api.redeemSlackBindingToken + en/zh slack_bind copy.
Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/...
(new replier_test.go covers all outcome branches + the prompt URL), plus full
pnpm typecheck (6/6) and pnpm lint (0 errors).
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): address review must-fixes — connector leak, team-keyed install, /issue copy (MUL-3666)
Three fixes from Niko's review:
1. AppConnector.connectOnce leaked the Socket Mode goroutine/connection on a
handler error: it ran sm.RunContext on the long-lived ctx and returned the
error without cancelling it, so a transient DB/router error left the old
connection alive (consuming events into an unread channel) while Run opened a
second one. Each connection now runs under its own cancellable context and a
deferred cancel + join tears it down on every exit path before reconnect.
2. Slack re-install collided with the (channel_type, app_id) unique index:
connecting the same Slack team to a different agent failed because the upsert
conflict key was (workspace_id, agent_id, channel_type). Add a team-keyed
UpsertChannelInstallationByAppID (ON CONFLICT on the (channel_type, app_id)
index, updating agent_id) and use it for the Slack OAuth install, so
re-connecting a workspace moves the bot to the chosen agent instead of
erroring. Feishu's per-agent upsert is unchanged.
3. /issue clarified: it is not a registered Slack slash command (no `commands`
scope), so Slack never routes one to us. Issue creation runs through the
message path — `@bot /issue <title>` in a channel or `/issue <title>` in a
DM — which the engine parser handles. Documented in the connector and the
user-facing copy (en + zh).
Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/...,
make sqlc, plus pnpm typecheck (6/6) and pnpm lint (0 errors).
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): make OAuth install transactional — agent-move binding consistency + cross-workspace guard (MUL-3666)
Address Elon's review: the team-keyed upsert kept the same installation row and
only flipped agent_id, but engine session reuse matches purely on
(installation_id, channel_chat_id) and each chat_session is permanently tied to
the agent it was created under — so after moving a Slack team from Agent A to
Agent B, existing DMs/threads kept routing to Agent A; only brand-new
channels/threads reached B. Cross-workspace re-install was worse: the SQL also
moved workspace_id while the application-layer user/chat-session bindings stayed
behind, inheriting the previous workspace's relations.
InstallService.Complete now runs one transaction (lookup → upsert → retire →
installer-bind), all application-layer per the no-FK rule:
- Look up the existing installation by team_id (config->>'app_id').
- Reject a silent cross-workspace ownership change (ErrTeamOwnedByAnotherWorkspace
→ callback redirects with slack_error=team_in_other_workspace). The owning
workspace must disconnect first.
- On an agent change within the same workspace, retire the installation's
chat-session bindings (new DeleteChannelChatSessionBindingsByInstallation) so
the next message creates a fresh session under the new agent. The chat_session
rows are preserved for history; user bindings stay valid (same users/workspace).
- Installer auto-bind moves into the tx; an already-bound-elsewhere id is a
benign skip, a real DB error aborts the whole install.
InstallService now takes a TxStarter; the queries seam gains WithTx (dbInstallQueries
adapter) so Complete stays unit-testable with a fake tx.
Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...
(new tests: agent-move retire, same-agent no-retire, cross-workspace reject,
fresh-install no-retire).
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): atomic cross-workspace install guard + green up frontend CI (MUL-3666)
Two things: address Elon's review and fix the failing frontend CI job.
Review (atomic cross-workspace guard): the previous guard was a SELECT before
the upsert, which loses the concurrent-OAuth race — two workspaces can both read
no rows, one inserts, the other's ON CONFLICT update then silently re-points the
team. Move the guard into the upsert itself: ON CONFLICT ... DO UPDATE ... WHERE
channel_installation.workspace_id = EXCLUDED.workspace_id, and map the empty
RETURNING (pgx.ErrNoRows) to ErrTeamOwnedByAnotherWorkspace. The pre-SELECT now
only feeds the agent-change cleanup. Also corrected the error copy: a team stays
bound to its first Multica workspace (revoke is soft, keeping the row + unique
index), so migration is an operator action, not "disconnect first".
CI (frontend vitest, @multica/views#test):
- The agent IntegrationsTab now renders the real SlackAgentBindButton, whose
connected badge calls useQueryClient — absent from integrations-tab.test.tsx's
react-query mock. Hoisted the owner/admin gate above the per-platform sections
(one role notice instead of one per platform), made the agents members_note
generic (en/zh/ja/ko), and updated the test (mock @multica/core/slack, stub
SlackAgentBindButton, assert both platforms).
- Added slack-tab.test.tsx covering the real SlackAgentBindButton / SlackTab.
- locale parity: added the slack (settings) + slack_bind (common) blocks to ja
and ko so every EN key has a translated counterpart.
Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...;
pnpm --filter @multica/views test (1478 pass), pnpm typecheck (6/6), pnpm lint (0 errors).
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): surface agent-page Slack entry points when Lark is off (MUL-3666)
The agent-detail Integrations tab and the inspector's Integrations section
only considered Lark, so a Slack-only deployment (Lark disabled) showed neither
the Integrations tab nor a Connect-Slack button — the per-agent entry points
were unreachable.
- agent-overview-pane: gate the Integrations tab on Lark OR Slack configured
(new slackInstallationsOptions query), not Lark alone.
- agent-detail-inspector: render SlackAgentBindButton alongside LarkAgentBindButton
in the Integrations section.
- regression test: the Integrations tab appears when only Slack is configured.
Verified: pnpm typecheck (6/6), pnpm --filter @multica/views test (1478+ pass),
pnpm lint (0 errors).
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): BYO-app install backend — paste xoxb+xapp, per-app install keyed by real app id (MUL-3666)
Adds the bring-your-own-app install path so multiple agents can each have
their own bot identity in the SAME Slack workspace (hosted B2 caps at one
agent/workspace). User pastes their app's bot token (xoxb-) + app-level
token (xapp-); we validate the bot token via auth.test, parse the real
Slack app id from the xapp- token, encrypt both tokens, and persist a
per-app installation keyed by that app id (real 'A…' ids never collide
with hosted 'T…' team ids in the existing unique index — no schema change).
- config.go: add app_token_encrypted (BYO discriminator + per-app socket token)
- install.go: extract shared persistInstall (atomic cross-ws guard + agent-move retire)
- byo_install.go: RegisterBYO + auth.test + app-id parse
- handler + route: POST /api/workspaces/{id}/slack/install/byo (admin-only)
- tests: keying, encryption, invalid tokens, auth.test failure, cross-ws, agent move
Follow-ups (separate commits): per-app Socket Mode connector that consumes
the stored app token; in-product BYO install dialog (video + paste form).
Co-authored-by: multica-agent <github@multica.ai>
* refactor(slack): drop OAuth, unify on BYO per-installation model (MUL-3666)
Per product decision, Slack drops the hosted-app OAuth path entirely and
unifies on bring-your-own-app (BYO): every installation carries its OWN
app-level token and gets its OWN Socket Mode connection, so multiple agents
can each have a distinct bot identity in one Slack workspace.
- Remove OAuth install (Begin/Complete/code-exchange/sealed state/OAuthConfig/
default scopes), the OAuth callback + begin handlers + routes, and the
MULTICA_SLACK_CLIENT_ID/SECRET/REDIRECT/APP_TOKEN env wiring.
- Replace the single deployment-level AppConnector with a per-installation
slackChannel (authenticated with its own xapp- token) registered as a channel
Factory, so the engine Supervisor drives one Socket Mode connection per
installation (exactly like Feishu). inbound/outbound/resolvers reused as-is.
- Route inbound by the event's api_app_id (== the installation's real app id),
not team_id.
- InstallService slims to at-rest encryption + the shared persistInstall +
list/get/revoke; install is the BYO paste path only (byo_install.go).
- Tests: drop the OAuth tests; slack + handler + engine all green.
Follow-up (frontend): replace the OAuth "Connect Slack" button with the BYO
paste dialog (the begin endpoint it calls is now gone).
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): verify BYO bot + app tokens are from the same app, and the app token is live (MUL-3666)
Niko review: RegisterBYO only parsed the app id from the xapp string and
auth.test'd the bot token, so pasting app A's bot token with app B's app
token would 'connect' but be broken (inbound on B's socket, outbound with
A's identity). Now: resolve the bot's owning app id via bots.info (on the
bot_id from auth.test) and require it to equal the xapp's app id; and live-
validate the app token via apps.connections.open. Reject (no persist) on
mismatch or a dead app token.
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): in-product BYO install dialog (paste bot + app tokens) (MUL-3666)
The OAuth begin endpoint was removed server-side, so the "Connect Slack"
button now opens a dialog where the admin pastes the bot token (xoxb-) and
app-level token (xapp-) of the Slack app they created, and submits to the
BYO install endpoint. Includes an optional setup-video link (URL constant,
left empty until the walkthrough is recorded).
- core: drop beginSlackInstall / BeginSlackInstallResponse; add
registerSlackBYO + RegisterSlackBYORequest.
- views: SlackAgentBindButton opens the BYO dialog; refreshed comments and
install_supported docs (now means "configured", no OAuth).
- i18n: new slack.byo_* keys + refreshed page_description in en/zh-Hans/ja/ko.
- tests: dialog submit path; views vitest (1479), typecheck, lint, locale
parity all green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): Elon review — team_id routing guard, per-agent reconnect, users:read hint (MUL-3666)
1. Inbound routing keys on api_app_id (the APP, not the Slack workspace), so
additionally require the event's team_id to match the installation's stored
team. A distributed BYO app installed into another Slack workspace emits the
same app id and would otherwise mis-route to this Multica installation.
Extracted installationServesTeam() + unit test.
2. BYO install is now agent-keyed (UpsertChannelInstallation, conflict on
workspace_id+agent_id+channel_type): one bot per agent. Disconnect →
reconnect a NEW app for the SAME agent now UPDATES that agent's row in place
instead of violating the (workspace, agent, channel) unique. A unique
violation on the (channel_type, app_id) routing index → ErrTeamOwnedByAnother-
Workspace (the app is already connected to another agent/workspace). No
chat-session retire is needed: a row's agent_id never changes.
3. UX: bots.info (the same-app check) needs the users:read scope — the connect
dialog now lists the required bot scopes including it, and the error text
says so.
Backend build/vet/gofmt/test + views vitest + typecheck + locale parity green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): publish slack_installation:created on BYO connect; refresh stale comments (MUL-3666)
Niko final review: RegisterSlackBYO wrote the response but never published
EventSlackInstallationCreated, so only the installer's own tab refreshed —
other open clients (Settings, Agent Integrations, other tabs) did not see the
new bot in realtime, inconsistent with the revoke event and Lark. Now publishes
it on success via a small publishSlackInstallationCreated helper, with a unit
test (Bus.Publish is synchronous).
Also refreshed comments that still described the removed hosted-OAuth /
single deployment-level AppConnector model (handler SlackInstall field,
channel.go / inbound.go / outbound.go / byo_install.go). PR title updated
separately to the BYO per-installation Socket Mode model.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The deep-link highlight tint faded out over 700ms on the comment body
layers but the sticky header's background switched instantly, and its
4px bottom `after` gradient band recolored by class-switching that
`transition-colors` cannot animate. Both desynced from the body during
the fade, showing a white header and a pale seam under it.
Add `transition-colors duration-700` to the sticky shell so the header
background fades with the body, and make the `after` band derive its
color from the header via `bg-[inherit]` + a `mask-image` fade instead
of a per-state gradient color, so all three layers are driven by the
single header background transition.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(daemon): bound runtime --version probe so one wedged CLI can't block all runtimes
A CLI whose `--version` never returns (e.g. a brew-installed claude wedged
by a bun regression) stalled the daemon's sequential runtime registration
loop forever. Registration runs inside the blocking preflight that gates
/health, so the daemon never flipped from "starting" to "running" and every
runtime on the host appeared disconnected — not just the broken one.
detectCLIVersion now derives a 10s timeout context and sets cmd.WaitDelay so
a node/bun shim that leaves a child holding the stdout pipe open can't defeat
the timeout. A wedged probe now fails fast; the existing per-agent error skip
isolates the broken runtime and the rest register normally.
MUL-3812
Co-authored-by: multica-agent <github@multica.ai>
* test(agent): reap the hang script's orphaned child instead of leaking it
The MUL-3812 regression test spawned a background `sleep 60` that outlived
the killed parent and lingered for up to 60s on CI. The hang script now
records the child's PID and t.Cleanup reaps it, so no temporary process is
left behind.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
HandleFailedTasks resets a stuck in_progress issue back to todo via a direct UpdateIssueStatus, bypassing the HTTP handler that emits issue:updated. Without that event the frontend realtime reconcile never runs, so status-filtered board columns/lists stay stale until the next write. Publish issue:updated (status_changed + prev_status) after the reset. Fixes#4648 (MUL-3782).
* fix(dashboard): hide deleted agents from usage leaderboard (MUL-3771)
The usage leaderboard fell back to rendering the raw agent UUID when an
agent was no longer in the workspace agent list (`agent?.name ?? row.agentId`).
Hard-deleted agents only survive as legacy usage rollup rows, so they showed
up as a bare UUID.
Filter the leaderboard rows down to agents still present in the workspace.
The agent list is fetched with `include_archived: true`, so archived agents
keep their names and stay; only hard-deleted agents drop out. Filtering is
skipped until the agent list has loaded so a slow fetch doesn't transiently
blank the board. Top-line KPI totals are unchanged — only the per-agent list
is affected.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard): stabilize empty agent list
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <lambda@multica.ai>
* feat(issues): add Remove parent issue action to promote a sub-issue to standalone (MUL-3764)
Surfaces a discoverable UI affordance for clearing an issue's parent — the
backend and CLI (multica issue update --parent "") already support it, but
the Official App only exposed Set parent. Adds:
- A 'Remove parent issue' item in the issue actions menu (dropdown +
right-click), shown only when the issue has a parent.
- A hover unlink button on the parent card in the issue detail sidebar.
- A removeParent handler that clears parent_issue_id and stage in one
write (stage only orders sub-issues under a parent) with a success toast.
Closes#4629
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): toast remove-parent on success only, prune old parent's children cache (MUL-3764)
Addresses review feedback on #4630:
- use-issue-actions.ts: the remove-parent success toast fired eagerly after
mutate(), so a request that failed on permission/network/validation would
flash "removed" before the error toast and optimistic rollback. Move it to
onSuccess so only a server-confirmed detach is announced.
- mutations.ts: when a write re-parents an issue away from its current parent,
prune it from the old parent's children cache instead of patching it to
parent_issue_id: null in place. The parent's sub-issues list renders that
array directly, so the orphaned row used to linger until the settle refetch.
onError still restores prevChildren, so the prune rolls back on failure.
Adds cache-prune coverage (optimistic remove / rollback / non-reparenting
no-op) and onSuccess-vs-onError toast coverage.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(scheduler): advance autopilot next_run_at after each scheduled dispatch
The display-only autopilot_trigger.next_run_at column was written only on
trigger create/update and never advanced afterward, so for a recurring
schedule it froze at a past slot and the list rendered it as a 'next run'
in the past (e.g. '53m ago'). The intended AdvanceTriggerNextRun query was
dead code with zero callers.
Wire it up at the scheduler's existing post-dispatch seam (replacing the
last_fired_at-only TouchAutopilotTriggerFiredAt bump, which AdvanceTrigger-
NextRun already supersets). The advanced value is computed on the app local
clock via ComputeNextRun — the same path create/update use — so the whole
next_run_at display column is owned by one clock and stays consistent;
scheduling itself is untouched and still runs off DB time via
NextOccurrencesUTC. On a cron/timezone parse failure we fall back to the
last_fired_at-only bump.
Adds a deterministic regression test for the reported scenario (hourly
cron in America/New_York) and documents the local-clock ownership on
ComputeNextRun.
MUL-3749
Co-authored-by: multica-agent <github@multica.ai>
* fix(scheduler): floor next_run_at advance at plan_time to survive clock skew
Addresses review feedback on the next_run_at write-back (MUL-3749):
- The post-dispatch advance computed the value from time.Now() alone. The
handler is entered only after DB time judged the plan due, so if this app
instance's clock lags the DB clock at a period boundary, time.Now() could
recompute the slot that just fired and next_run_at would not advance —
the original staleness bug, at the boundary. Extract advancedNextRun,
which anchors at max(now, plan_time) via NextOccurrenceAfterUTC so the
written value is always strictly after the fired plan_time while still
tracking the local clock in the normal case.
- Add scheduler-layer tests asserting the written value is strictly after
plan_time across skew / on-slot / normal cases. The previous service-layer
test only exercised the helper with an explicit after, not this path.
- Sync the stale ListSchedulableAutopilotTriggers comment: the scheduler
now writes last_fired_at via AdvanceTriggerNextRun (sqlc regenerated).
MUL-3749
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): add v0.3.31 entry for the 2026-06-26 release (MUL-3748)
Covers the cross-workspace inbox unread dot in the switcher (MUL-3695), the
Composio Go SDK foundation (MVP), per-worktree desktop dev isolation
(MUL-3724), and the new reusable VideoEmbed with the zh docs intro video.
Bug fixes include the editor Tab list-indent / focus-keeping behavior
(MUL-3697), squad leader briefing now keyed by task flag (MUL-3730) plus
the inherited @mention reply skip (MUL-3744), code-block selection
stability during background re-renders (MUL-3621), local handoff-note
version gate for direct agent assigns, comment-edit save loading state
(MUL-3709), search API response parsing, and an actionable error when
self-host hosts are missing Docker Compose v2.
Localized into en / zh-Hans / ko / ja with product-language wording per the
`Issue`-only exception (`agent` -> "agent" / 智能体, `Squad` -> "squad" / 小队).
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): tighten v0.3.31 entries per review (MUL-3748)
Per Bohan's review on MUL-3748: the v0.3.31 copy was too wordy. Shorten
every bullet to a single user-facing sentence and drop the internal
details (worktree mechanics, signed webhooks, hljs spans, account-level
summary call, etc.). en / zh / ko / ja all updated; product-language
wording and the Issue / 智能体 / 小队 rule are preserved.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Clarify that desktop dev worktree isolation (renderer port + app name) is
automatic and independent of .env.worktree, which only covers backend/
frontend DB names/ports. Follow-up to MUL-3724 (#4598).
Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(squad): inject leader briefing by task flag, not issue assignee
Key squad-leader briefing injection off task.IsLeaderTask + task.SquadID
instead of issue.AssigneeType=='squad'. The old gate missed the most common
path — an @squad mention in a comment on an issue assigned to a plain agent
(MUL-3724) — so the leader booted with zero squad context and did the work
itself instead of orchestrating.
- migration 127: add agent_task_queue.squad_id (no FK) + partial index
- sqlc: CreateAgentTask stamps squad_id; CreateRetryTask inherits it
- service: thread squadID through EnqueueTaskForSquadLeader(+WithHandoff),
enqueueMentionTask, and the rerun path; all 5 call sites pass the squad id
- daemon claim: unified injection keyed on leader-task + squad_id, with a
defensive leader-identity re-check; quick-create block retained (it serves
issue-less tasks and sets resp.SquadID/SquadName)
- briefing: strengthen leader Operating Protocol opening
- tests: claim-time injection (comment-mention/non-leader/null-squad),
squad_id enqueue stamping, retry inheritance; existing fixture updated
Co-authored-by: multica-agent <github@multica.ai>
* test+docs(squad): dangling squad_id regression + clarify quick-create path
Address review nits on #4606:
- Add TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing: squad hard-deleted
after enqueue leaves task.squad_id dangling (no FK); claim still 200 and
skips injection via the err!=nil guard. This is the load-bearing contract
for dropping the FK.
- Rewrite the daemon.go injection comment to state quick-create does NOT use
the is_leader_task/squad_id columns — it routes squad via the context JSON
branch (qc.SquadID) and must not be folded into the column-based path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: 魏和尚 <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): fail early with actionable message when Docker Compose v2 is missing
The selfhost make targets hardcoded 'docker compose' (Compose v2). On hosts with only the legacy v1 'docker-compose' (e.g. apt install docker-compose) the plugin is absent, so 'docker compose -f ... pull' fell through to the top-level docker CLI and failed with the cryptic 'unknown shorthand flag: f in -f', which users mistook for a Docker version problem (MUL-3458).
Add a REQUIRE_COMPOSE preflight that checks 'docker compose version' and prints an actionable English install hint, and route every selfhost invocation through $(COMPOSE). v1 fallback is intentionally not supported: docker-compose.selfhost.yml uses compose-spec syntax (top-level name:, no version:) that v1 cannot parse.
Co-authored-by: multica-agent <github@multica.ai>
* fix(selfhost): shorten Compose v2 hint and reject legacy v1
Address review on PR #4354:
- Drop Linux-specific install commands from the runtime error; point to
the OS-agnostic https://docs.docker.com/compose/install/ and keep the
message short and stable. Per-OS steps belong in docs, not the Makefile.
- Reject Compose v1 explicitly: the preflight now also requires the
reported version to be v2, so COMPOSE=docker-compose no longer passes
and then fails later on the compose-spec file.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <agent@multica.ai>
* feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724)
Two worktrees could not run pnpm dev:desktop at once: both grabbed the
renderer port 5173 and the single-instance lock keyed by the app name
"Multica Canary". The env hooks to override each already existed
(DESKTOP_RENDERER_PORT in electron.vite.config.ts, DESKTOP_APP_SUFFIX in
src/main/index.ts) but nothing derived per-worktree values.
A new dev launcher (scripts/dev.mjs) derives both from the worktree path
for linked worktrees only — reusing the same cksum%1000 offset as
scripts/init-worktree-env.sh, so renderer port is 5173+offset and the app
becomes "Multica Canary <folder>" with its own userData/lock. The primary
checkout is untouched; explicit env vars still win. Backend targeting is
unchanged (apps/desktop/.env*). Also: brand-dev-electron honors the suffix,
turbo globalEnv passes it through, and CONTRIBUTING documents the flow.
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): make worktree dev port/suffix collision-safe (MUL-3724)
Addresses code review on #4598:
- Renderer port base 5173 → 5174 so a worktree whose offset is 0 (e.g.
cksum("/tmp/multica-3494") % 1000 === 0) no longer collides with the
primary checkout's default 5173.
- DESKTOP_APP_SUFFIX is now "<folder>-<offset>" instead of just the folder
name, so worktrees that share a basename at different paths (or names that
slug to the same fallback) get distinct single-instance locks. Without it
the second Electron was still blocked by the shared lock.
- Tests: offset-0 port guard, and same-basename-different-path disambiguation.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): add standalone Go SDK client (MVP)
Adds server/pkg/composio — a self-contained Go SDK for the Composio v3.1
REST API. Built on go-resty/resty v2; zero coupling to other Multica
packages so it can be vendored or extracted later without surgery.
MVP surface (just the endpoints Stage 2 needs):
- POST /connected_accounts/link Client.CreateLink
- POST /tool_router/session Client.CreateSession
- GET /connected_accounts Client.ListConnectedAccounts
- POST /connected_accounts/{id}/revoke Client.RevokeConnection
- DELETE /connected_accounts/{id} Client.DeleteConnectedAccount
(404 -> nil, idempotent)
- GET /toolkits Client.ListToolkits
- GET /toolkits/{slug} Client.GetToolkit
- POST /tools/execute/{slug} Client.ExecuteTool
- Webhook HMAC-SHA256 verification composio.VerifyWebhook /
VerifyHTTPRequest + ParseEvent
Other notes:
- Auth via x-api-key header (Composio v3.1 contract).
- Typed *APIError envelope with IsNotFound / IsUnauthorized /
IsRateLimited helpers; falls back to raw body when upstream returns
non-JSON.
- Webhook signature accepts the official "v1,<sig>" format and any
comma-separated multi-version list; 300s replay tolerance by default,
honors an injectable clock for tests; RFC3339 timestamps tolerated.
- README.md documents all public APIs and design choices.
Tests:
- All exercise httptest.NewServer - no real Composio calls.
- 36 tests covering happy paths, validation, 404 idempotence, error
decoding, signature verify (good / tampered / stale / multi-version /
bare / RFC3339 / missing headers / empty secret).
- go test ./pkg/composio/... -cover -> 82.2%, exceeds the >=80% bar.
Follow-ups (separate PRs):
- server/internal/integrations/composio - DB schema, REST handlers,
registration_service (CSRF), dispatch hook (MUL-3720 remainder).
- Pagination iterators, retry middleware, proxy execute, triggers.
Refs: MUL-3720, MUL-3715
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): align SDK with v3.1 wire contract (PR #4603 review)
Addresses GPT-Boy's review on PR #4603:
Must-fix
- ListConnectedAccountsRequest: switch UserID/AuthConfigID singular fields
to plural slices (UserIDs, AuthConfigIDs, ToolkitSlugs, ConnectedAccountIDs,
Statuses). The Composio v3.1 spec ships these as `*_ids` array params;
our singular form silently dropped the user-filter on the wire. Also
surfaces order_by / order_direction / account_type from the same spec.
- ExecuteToolRequest: rename ToolkitVersions -> Version with json tag
`version` (the actual v3.1 body field). Marks AllowTracing as
deprecated per the spec.
Nits
- ListToolkitsRequest.SortBy comment: `popular | alphabetical` -> the
real enum `usage | alphabetically`.
- Client constructor: when Options.HTTPClient is provided, use
resty.NewWithClient(hc) so the caller's Transport, Jar, CheckRedirect,
and Timeout all carry through — the prior code only forwarded
Transport + Timeout despite the field comment promising the full
*http.Client.
Tests
- TestListConnectedAccounts_QueryString now asserts plural query keys
(user_ids, auth_config_ids, connected_account_ids, statuses) and
explicitly guards that the legacy singular keys do not leak.
- TestExecuteTool_Success decodes the body as a raw map and asserts the
wire key is `version` (not `toolkit_versions`).
- New TestExecuteToolRequest_VersionSerialization locks the json tag.
- New TestNewClient_HonorsInjectedHTTPClient drives a request through a
recordingTransport and asserts the inbound *http.Client actually
handled it.
Verification
- go test ./pkg/composio/... -cover -> 85.1% (38 tests; was 82.2% / 36).
- go vet, go build, gofmt -l all clean.
Refs: PR #4603 review, MUL-3720
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
In a list item that cannot indent (first child / max depth), Tab was
returned unhandled, so the browser's native Tab moved focus out of the
editor onto adjacent controls. Decouple "swallow the key" from "did the
indent move anything": best-effort indent, then swallow whenever the
caret is inside the list (editor.isActive(name)) and only fall through
to focus navigation when not in a list. Covers bullet, ordered and task
lists via the shared keymap.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stock prosemirror-schema-list `sinkListItem` returns false without
dispatching whenever `range.startIndex === 0`, so selecting a list from
the top and pressing Tab did nothing. Bullet, ordered, and task lists
all routed through the same command and were equally affected.
Wrap the shared Tab keymap (PatchedListItem + PatchedTaskItem) with
`sinkListItemRange`: try stock sink first, and when it bails on a
multi-item range whose first item is the list's first child, re-run the
stock command on a selection narrowed to start inside the second selected
item. The first item stays as an anchor and the rest nest under it
(Notion/GitHub nested-list behaviour), in a single undoable transaction.
Shift-Tab / liftListItem already handles ranges and the first-item case,
so it is unchanged. No schema change.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>