Commit Graph

528 Commits

Author SHA1 Message Date
Naiyuan Qing
1d251ed3d0 refactor(spaces): remove the Space description entirely
Drop the Space `description` field across the whole stack: the
workspace_space column (migration 150), sqlc queries + generated code,
the handler request/response, core types + zod schema, the `multica space`
CLI `--description` flag, and the create/detail forms + i18n. Spaces keep
name / key / icon; workspace / project / issue descriptions are untouched.

Also reverts the short-lived "inject Space description into the agent
brief" chain (space_description through the daemon task pipeline and the
`multica space get` CLI) — with the field gone there is nothing to inject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:19:28 +08:00
Naiyuan Qing
df37fba8a2 feat(spaces): editable identifier + workspace slug; space-context brief
Space Identifier (key) is now editable on the space detail page, and no
longer frozen after the first issue: renaming bulk-writes issue-identifier
aliases under the old key (BackfillSpaceKeyAliases) so every OLDKEY-N
reference keeps resolving, and the UI confirms the rename when the space
already holds issues. Admin-only; navigates to the new /space/<key> URL.

Workspace settings:
- drop the obsolete "Issue prefix" field — numbering is per-space now, and
  identifiers come from the space key (edited on the space detail page).
- make the workspace slug (its URL) editable: validates pattern/reserved,
  409 on collision, confirms the link-breaking change, and migrates to the
  new /<slug> URL on save.

UI: add an `underline` variant to the shared Input (was a copy-pasted
override string in three places).

Agent brief: inject the Space description into the run brief alongside the
project/workspace context (space_description through the daemon task chain);
add `multica space get` so an agent can read a space's description on demand.
Known gap: quick-create tasks don't yet carry space_description (their
QuickCreateContext snapshot lacks it) — deferred.

Also bundles in-tree WIP present on this branch (space-first sidebar,
onboarding workspace step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:04:22 +08:00
Naiyuan Qing
faf260cee0 Merge branch 'main' into pr-4784-fix
Resolves the one real conflict (server/internal/daemon/execenv/runtime_config.go):
main's MUL-4297 retired the legacy verbose runtime brief in favor of the
slim brief as the only path. This branch had added a "## Space Context"
section and --space flag docs to the legacy builder; the slim builder
(runtime_config_sections.go) already carries the identical Space Context
section (writeSpaceContext) and --space references independently, so
taking main's side (delete the legacy branch) loses nothing — confirmed
by diffing this branch against main for the file before resolving.

Also fixes a real incompatibility main introduced after the conflict:
comment_reconcile_test.go (new on main, MUL-4195) creates issues via raw
SQL without space_id, which main's schema (space_id nullable) allows but
this branch's migration 132 (space_id NOT NULL) rejects. Added space_id
via a workspace_space subquery, matching the existing pattern in
workspace_scope_guard_test.go.

Verification: full go test ./... (isolated database, not the shared dev
DB) passes; go build/vet clean; core/views/web/desktop typecheck clean;
core (804 tests) and views (1703 tests) suites pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 14:13:19 +08:00
Multica Eve
0c2e48ded2 refactor: retire FF_RUNTIME_BRIEF_SLIM, make slim runtime brief the only path (MUL-4297)
The runtime_brief_slim feature flag has burned in; the slim runtime brief is now the sole path.

- execenv: buildMetaSkillContent / BuildCommentReplyInstructions delegate to the slim assembler unconditionally; delete the legacy verbose brief body and writeBackgroundTaskSafetyInstructions.
- Remove the runtime_brief_slim flag and the daemon-bound flag delivery subsystem built solely for it: execenv flag wiring (runtime_config_flag.go, server_snapshot_provider.go), the featureflagdispatch package, the DaemonFeatureFlagSnapshot heartbeat protocol field, and the server/daemon wiring in router.go, handler, daemon.go, main.go, cmd_daemon.go.
- Keep the generic server/pkg/featureflag engine (still used by composio_mcp_apps).
- Update tests to slim-only expectations and docs/feature-flags.md.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 13:48:33 +08:00
Multica Eve
75695a2e40 fix(comments): guarantee at-least-once processing of user comments (MUL-4195) (#5068)
* fix(comments): guarantee at-least-once processing of user comments (MUL-4195)

Consecutive comments on an issue were silently dropped: a new comment that
arrived while the agent already had a queued/dispatched task was discarded by
the HasPendingTaskForIssueAndAgent dedup, losing the user's follow-up
instruction with no visible trace. Comments — unlike chat — are deliberate,
addressed, persisted input and must never vanish.

This makes comment handling at-least-once while keeping concurrency bounded to
one run per (issue, agent):

- Merge, don't drop (PR1): a comment landing while a not-yet-started task
  exists is folded into that task — the prior trigger becomes a coalesced
  comment and the new one becomes the trigger, so a single run still covers
  every deliberate comment. Falls back to a fresh enqueue if the pending task
  was claimed mid-flight, so nothing is lost in the race.
- Completion reconciliation (PR2): on task completion, a member comment newer
  than the run's started_at schedules exactly one follow-up via the normal
  trigger pipeline. Loop-safe: member-authored only, capped by the existing
  per-(issue,agent) dedup, and terminating.
- Visibility (PR3): coalesced_comment_ids is surfaced on the task API and in
  the run prompt so the covered comments are explicit.

Migration 145 adds agent_task_queue.coalesced_comment_ids UUID[].

Tests: merge-not-drop preserves all three of a rapid burst and repoints the
trigger to the newest; reconciliation query gates on member/since; e2e
CompleteTask enqueues a follow-up for a mid-run member comment and does not for
none.

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

* fix(comments): address review — originator gate, agent-scoped reconcile, cross-thread coalesced prompt (MUL-4195)

Resolves GPT-Boy's Request-changes review on PR #5068.

Must-fix #1 — merge no longer inherits a stale originator/runtime context.
MergeCommentIntoPendingTask now only folds a comment into a pending task
whose originator_user_id IS NOT DISTINCT FROM the new comment's originator.
runtime_mcp_overlay / runtime_connected_apps are a pure function of
(originator, agent) and the agent is fixed, so a matching originator keeps
the stored overlay/attribution valid; a differing originator (e.g. user B
commenting on a task originated by user A) matches no row and the caller
enqueues a fresh follow-up with B's own context instead of reusing A's.
trigger_summary is refreshed to the new trigger comment.

Must-fix #2 — completion reconcile no longer re-wakes unrelated agents.
reconcileCommentsOnCompletion computes the latest member comment's triggers
and keeps ONLY the agent that just completed, instead of fanning the comment
out through the full pipeline. An @-mention of agent B during agent A's run
is triggered once at creation time and is no longer replayed (double-run)
when A completes.

Should-fix #3 — coalesced-comment prompt no longer assumes a single thread.
The claim response now carries each folded comment's thread id / author /
created_at / content (CoalescedCommentData); the prompt embeds them directly
so the agent addresses cross-thread folded comments without the wrong
"they are in the triggering thread" hint. Old servers that ship only ids
fall back to an issue-wide fetch, still without the same-thread assumption.

Tests: TestMergeCommentIntoPendingTask_OriginatorGate (query gate),
TestCompleteTask_DoesNotReTriggerOtherAgentMentionedDuringRun (reconcile
scoping), TestBuildCommentPromptCoalescedCrossThread / IDsOnlyFallback
(prompt). Existing MUL-4195 suites still pass.

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

* fix(comments): close unique-index drop + dispatched-window race in comment coalescing (MUL-4195)

Second-round review follow-up on PR #5068.

Must-fix #1 — originator-mismatch no longer drops the comment.
The previous originator gate returned ErrNoRows on a different originator and
the caller fell through to a fresh enqueue, which collided with the
idx_one_pending_task_per_issue_agent unique index (one queued/dispatched task
per (issue, agent)) — silently dropping the second user's comment. Replaced
the gate with recompute-on-merge: MergeCommentIntoPendingTask now re-stamps
originator_user_id, runtime_mcp_overlay, runtime_connected_apps and
trigger_summary to the new comment's originator. A different member's comment
folds into the single coalescing run carrying the latest instruction's own
identity/overlay (no cross-user capability bleed, no drop, no collision).

Must-fix #2 — comment arriving in the claim→StartTask window is no longer lost.
Merge now targets only PRE-CLAIM states ('queued','deferred'); a
dispatched/running task is never a merge target, so a post-claim comment is
never falsely stamped into coalesced_comment_ids as "delivered". Completion
reconcile is re-anchored on dispatched_at (the moment the claim response is
built) instead of started_at, and sweeps ALL undelivered member comments since
that anchor — replaying each through the normal enqueue path so they coalesce
into one bounded, agent-scoped follow-up run. This covers the dispatch→start
window a started_at anchor missed.

Enqueue path: on a merge miss the caller no longer blindly fresh-enqueues
(which could collide with a dispatched sibling); it defers to the active
task's completion reconcile via HasActiveTaskForIssueAndAgent, and only
fresh-enqueues when no active task exists.

Tests: rewrote the query test to
TestMergeCommentIntoPendingTask_RecomputesOriginatorAndSkipsDispatched;
added TestConsecutiveCommentsDifferentOriginatorsFullEnqueuePath (full handler
enqueue path, two distinct originators) and
TestCompleteTask_ReconcilesDispatchedWindowComment (claim→start window). All
existing MUL-4195 handler/cmd-server/daemon/service suites still pass.

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

* fix(comments): catch pre-dispatch merge-race comment in completion reconcile (MUL-4195)

Third-round review follow-up on PR #5068.

Race: a member comment is created while the task is still queued, but its
merge loses the race to the daemon claiming the task (queued→dispatched). The
merge then finds no pre-claim row (ErrNoRows), the enqueue path defers to
reconcile — but the comment's created_at is BEFORE dispatched_at, so the
dispatched_at-anchored reconcile skipped it and the comment vanished with no
task coverage.

Fix: anchor completion reconcile on the task's created_at (which always
precedes dispatch) instead of a dispatch/start timestamp, and exclude the
run's DELIVERED SET — trigger_comment_id ∪ coalesced_comment_ids. Because
merges only ever touch pre-claim rows, that set is exactly what the claim
response carried, so any member comment created since the task was made that
is NOT in it was genuinely undelivered and earns a bounded follow-up. This
catches the pre-dispatch merge-race comment and the dispatch→start comment,
while never re-firing a comment that was delivered as a pre-claim coalesced
entry.

Test: TestCompleteTask_ReconcilesPreDispatchMergeRaceComment reproduces the
race (comment created pre-dispatch, task dispatched before merge, plus a
delivered coalesced comment) and asserts exactly one follow-up, triggered by
the race comment, with the delivered coalesced comment excluded. Existing
reconcile fixtures updated to set a realistic created_at (the production
invariant that created_at is the earliest task timestamp).

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

* fix(comments): merge only into the queued task, never a deferred fallback (MUL-4195)

Fourth-round review follow-up on PR #5068.

MergeCommentIntoPendingTask targeted status IN ('queued','deferred') ordered
by created_at DESC. When a (issue, agent) pair had both an older queued task
(the run about to be claimed) and a newer deferred assignee-fallback task, a
new comment merged into the deferred row instead of the queued one — so the
comment missed the imminent run and the deferred fallback could later promote
into a duplicate/conflicting run.

This merge is only ever reached when HasPendingTaskForIssueAndAgent matched a
queued/dispatched task (it never inspects deferred), so the coalescing target
must be the queued row. Restricted the merge target to status = 'queued'
(the unique index guarantees at most one). Deferred fallbacks keep their own
fire_at/promotion escalation lifecycle and are never a merge target.

Test: TestMergeCommentIntoPendingTask_TargetsQueuedNotDeferred seeds an older
queued task + a newer deferred fallback for the same (issue, agent), merges a
new comment, and asserts it lands on the queued task (trigger repointed, old
trigger coalesced) while the deferred fallback is left untouched.

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-07-09 12:48:57 +08:00
Multica Eve
22a71bafe3 feat(server): add basic LLM API layer with OpenAI-compatible endpoints (#5138)
Integrate the official openai-go SDK (v3) as a thin, reusable LLM layer
(pkg/llm) backing lightweight utility calls that do not need the agent
runtime (chat titles, quick-create drafts, ...).

Expose two user-authenticated, OpenAI-compatible chat-completions
endpoints:
  - POST /api/llm/v1/chat/completions         (JSON response)
  - POST /api/llm/v1/chat/completions/stream  (SSE stream)

Requests decode directly into the SDK's ChatCompletionNewParams and
responses are relayed via RawJSON() for byte-exact OpenAI-format
compatibility. Base URL and API key are configurable (MULTICA_LLM_*),
and the model is taken from the request with a configurable default
fallback (MULTICA_LLM_DEFAULT_MODEL, else gpt-4o-mini). When unconfigured
the endpoints return 503.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 12:42:59 +08:00
CAVIN
3fdcdb1a39 fix(cli): retry transient assignee resolver fetches (#5078)
Co-authored-by: CAVIN <zzz163519@users.noreply.github.com>
2026-07-09 11:52:03 +08:00
Jiayuan Zhang
a51ab4d551 feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) (#5076)
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)

Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).

Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
  session selection; web + desktop routing wired up. Removes the old
  chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
  unread *count* badge (read-cursor model), presence-gated typing vs waiting.
  Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
  empty state (avatar + name + description + starter prompts), and a
  deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
  (migration 146, dedicated chat_pinned_agent table + handler/queries).
  New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
  static template).
- Design: fade the global --border token; borderless list headers on
  Chat/Inbox, kept (faded) on the conversation header.

Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230)

The "meet your new agent" chat used to insert a fake user message
("👋 Hi! Please introduce yourself …") and have the agent reply to it, so
the thread looked like the creator prompting the agent.

Drop the persisted user message. Flag the auto-created session
is_agent_intro (migration 147) and drive the intro run server-side: the
daemon builds a proactive self-introduction prompt for such sessions
(buildChatPrompt) instead of a "reply to their message" prompt. The intro
stays LLM-generated; the thread now opens with the agent's own message, as
if it reached out first.

- migration 147: chat_session.is_agent_intro
- CreateChatSession carries the flag; sendAgentWelcomeChat no longer
  persists/publishes a user message
- daemon: ChatIntro threaded from session flag → intro prompt

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

* feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080)

* feat(chat): Settings toggle for the floating chat window (MUL-4235)

Re-introduce the floating chat overlay on top of Chat V2 as an optional,
Settings-gated surface instead of deleting it outright.

- Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted
  client preference, default ON) to show/hide the floating window.
- FloatingChat wrapper owns the two gates: the preference, and the /chat
  route (hidden on the tab so the same activeSessionId isn't shown twice).
- ChatFab + a compact ChatWindow that reuse the shared useChatController and
  conversation components, so activeSessionId stays in lockstep with the tab.
- Restore use-chat-context-items so the overlay's @ surfaces the current
  issue/project (the 'current context' affordance) — the tab stays manual.
- i18n (en/zh-Hans/ja/ko), store unit tests.

typecheck: core/views/web/desktop green. tests: chat store 9, settings 82,
chat 39 pass.

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

* feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235)

Address review: give Chat its own Settings tab instead of a section inside
Preferences, and default the floating window OFF (opt-in).

- New Settings → Chat tab (chat-tab.tsx) under My Account; moves the
  floating-window toggle out of the Preferences tab.
- floatingChatEnabled now defaults OFF — only an explicit enable from the
  Chat tab mounts the FAB/overlay.
- i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko);
  revert the Preferences chat section and its test mock; store tests updated
  for the opt-in default.

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

---------

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

* refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081)

The three starter prompts (List my open tasks by priority / Summarize what
I did today / Plan what to work on next) read as filler more than help, so
remove them along with the now-unused returning_subtitle ("Try asking").

The empty state keeps its agent-aware header — avatar + "Chat with {name}"
+ optional description — and the composer stays the entry point. Locale
keys dropped across en/zh-Hans/ja/ko (parity preserved).

Based on the Chat V2 branch (parent MUL-4171, #5076), not main.

Co-authored-by: Lambda <lambda@multica.ai>

* feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082)

Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.

Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
  timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
  pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.

Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
  with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
  list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.

Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.

* feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088)

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

* fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093)

Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the
selected bg-accent reads as an inset rounded card — same treatment the
chat thread list already uses — instead of a full-bleed, sharp-cornered
highlight. Content stays 16px-inset (p-1 + px-3 == old px-4).

MUL-4253

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

* fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092)

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

* fix(chat): stop welcome intro session repeating the same introduction (MUL-4259)

The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user.

Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt.

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

* fix(chat): address review findings + unbreak CI (MUL-4171)

- task:failed now refreshes the sessions list (invalidateSessionLists), so
  the thread-list preview / unread / sort stays correct after an agent
  failure — FailTask persists a failure chat_message but only broadcasts
  task:failed, mirroring the chat:done success path.
- Self-heal stale chat deep links: once the sessions list has loaded and a
  ?session= id isn't in it (deleted / no access / never existed) with nothing
  in flight, clear the selection instead of rendering an editable empty chat
  that would POST into a nonexistent session. Freshly-created sessions are
  exempt (they carry optimistic messages + a pending task).
- CI: add the new parameterless `chat` route to link-handler's
  WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set +
  expectedSegments) — keeps the two in sync, fixes the failing @multica/core
  test.
- Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views
  typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to
  EmptyState.

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

* fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171)

Re-review follow-up: the stale-session self-heal only lived in ChatPage, so
the floating ChatWindow still entered from a persisted activeSessionId and
would render an editable empty chat (then POST into a nonexistent session)
when the selected session was deleted / lost access off the /chat route.

- Move the self-heal into the shared useChatController so every surface (tab
  and floating window) drops a dangling activeSessionId once the sessions list
  has loaded and doesn't contain it.
- Harden ensureSession: trust the current id only when it's in the loaded list
  or is a just-created session still awaiting the refetch; a dangling id falls
  through to create a fresh session instead of POSTing into a 404.
- Exempt just-created sessions via an OPTIMISTIC-write signal
  (hasOptimisticInFlight: pending task or optimistic- message), not hasMessages
  — a session deleted elsewhere with real cached history stays eligible for
  self-heal. Add a unit test for the discriminator.

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

* test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171)

The AppSidebar personal nav gained a `chat` item, so it calls
`useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't
been updated, so `p.chat` was undefined and every render threw
`TypeError: p[item.key] is not a function`, failing @multica/views#test in CI.

- Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths.
- Route the chat-sessions query key through a mutable `chatSessions` fixture.
- Add coverage for the Chat nav: renders the link, badges the summed
  unread_count, and hides the badge when all sessions are read — so this drift
  is caught next time.

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

* fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102)

Follow-up to the merged #5080, which shipped a hand-written, simplified
ChatWindow and lost the original's animations / drag-resize / expand-minimize.
The floating window is just a quick entry point — it should be the original
UI, not a rewrite.

- Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and
  use-chat-resize.ts verbatim from main (0-diff): motion animations, drag
  resize, expand/minimize and the session dropdown are back.
- Restore the empty_state.returning_subtitle + starter_prompts i18n keys the
  original window renders (V2 had dropped them); drop the now-unused
  window.open_full_tooltip key the rewrite added.
- Settings gating is unchanged: FloatingChat still wraps the original FAB +
  window, gated by floatingChatEnabled (default off) and hidden on /chat.

typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass.

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

* feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098)

Restore an archive flow as the reversible sibling of delete:
- Chat list hover now offers Archive (not Delete); pin/stop unchanged.
- A footer entry ('Archived · N') opens an Archived view listing archived
  chats; hard delete lives only there (hover -> unarchive + delete, with
  the existing inline confirm).
- Conversation header ⋯ menu mirrors this: active chats archive, archived
  chats unarchive/delete.

Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived
(SetChatSessionArchived), broadcasts status on chat:session_updated so other
tabs re-sort into the right list. SendChatMessage already refuses archived
sessions, so archived chats stay read-only until unarchived.

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

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100)

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)

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

* refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)

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

* feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265)

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

---------

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

* fix(chat): address floating-window + archived-agent review blockers (MUL-4171)

Re-review follow-up on the restored floating ChatWindow + archive flow:

1. Floating stale-session self-heal. The restored ChatWindow doesn't use the
   shared controller, so its ensureSession trusted any non-empty
   activeSessionId and there was no dangling-session cleanup — a deleted /
   no-access persisted session could send into a nonexistent session. Ported
   the same guard used for the tab: a self-heal effect that clears a dangling
   activeSessionId once the sessions list has loaded, and ensureSession only
   trusts an id that's in the list or has an in-flight optimistic write
   (hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds
   the optimistic message + pending task before setActiveSession, so a
   freshly-created session is never mis-cleared.

2. Floating dropdown bypassed archive-first safety. Its active rows offered a
   hard-delete, letting the floating window destroy active chats and skip the
   "archive first, delete only from Archived" model. Active rows now ARCHIVE
   (reversible, one-click) like ChatThreadList; the floating window offers no
   hard-delete — unarchive/delete live only in the full Chat page's Archived
   view (reachable via expand). Removed the now-dead delete-confirm machinery.

3. Orphan user message on archived-agent send. SendChatMessage created the
   chat_message before EnqueueChatTask, which rejects an archived / runtime-less
   agent — a stale client would land a user message then get a 500, orphaning
   it. Added a preflight that checks the session agent's archived / runtime
   state and returns 409 before any mutation, plus a handler test asserting the
   send is rejected with no message persisted.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 21:58:16 +08:00
Naiyuan Qing
05ad0d292f fix(server): backfill space_id in raw-SQL test fixtures + generator/mobile CI fixes
CI was red across backend/frontend/mobile:

- backend: migration 132 made issue.space_id/autopilot.space_id NOT NULL,
  but ~28 test files across internal/handler, cmd/server, and cmd/migrate
  still raw-SQL-inserted issue/autopilot rows without space_id. Backfilled
  every one via (SELECT id FROM workspace_space WHERE workspace_id = $N
  LIMIT 1), reusing the existing workspace_id positional param — matches
  the convention already established in daemon_test.go/comment_resolve_test.go.
  Seeded a workspace_space row for every test that creates its own local
  workspace (cross-workspace fixtures), mirroring setupHandlerTestFixture.
- frontend: scripts/generate-reserved-slugs.mjs had a stale hardcoded
  `/create-team` example in its generated doc comment (never updated in
  the team→space rename), so `pnpm generate:reserved-slugs`'s output
  diverged from the committed reserved-slugs.ts and CI's drift-check failed.
- mobile: agent-schema.test.ts asserted the unknown-target_type fallback
  was "team"; the schema's actual .catch() fallback is "space" — stale
  assertion, not a schema bug.

go test ./... and go vet ./... are clean; pnpm generate:reserved-slugs
produces no diff; mobile's agent-schema test passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 18:53:18 +08:00
Firer
6be496c652 refactor(cli): make issue reorder require exactly one target flag via a cobra flag group [MUL-4222] (#5095)
`issue reorder` takes exactly one target: --top, --bottom, --before, or
--after. That rule was a hand-rolled runtime count in runIssueReorder; declare
it with cobra's MarkFlagsMutuallyExclusive + MarkFlagsOneRequired (extracted
into registerIssueReorderFlags, shared with the tests) so cobra validates it
before RunE with canonical messages and shell completion drops the sibling
target flags once one is set.

Keep an explicit guard for no-op target values that cobra's presence check
cannot see: empty --before/--after, and --top=false / --bottom=false.

Follow-up to #4110; addresses the second review item from the merge comment
(the first was handled in #5072).

Co-authored-by: Nick Webster <nick@nitrad.co.uk>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:49:56 +08:00
Naiyuan Qing
f35746df8d Merge origin/main into pr-4784-fix: combine autopilot list filters
Conflict resolution:
- ListAutopilots (autopilot.sql / autopilot.sql.go): keep branch's
  space_id filter alongside main's default archived-exclusion
  (#5042 changed delete-autopilot to archive, and made the list
  default to hiding archived unless status is explicitly requested)
2026-07-08 16:19:49 +08:00
Bohan Jiang
fd3216fd6b feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213) (#5079)
* feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213)

Scan-to-bind was authorized by workspace role only, so a non-admin
member could not bind a Lark bot even to an agent they own. Authorize
the device-flow install, status poll, and revoke by the same rule that
governs every other agent-management op — canManageAgent: the agent's
owner OR a workspace owner/admin.

Backend:
- router: begin/status/revoke drop to workspace-member level; the
  per-agent check moves into the handlers (agent_id is a query param /
  installation id, which the role middleware can't see).
- BeginLarkInstall + RevokeLarkInstallation load the target agent and
  run canManageAgent.
- GetLarkInstallStatus scopes the read to the session initiator or a
  workspace owner/admin; others get 404 (no existence leak). Session
  state now carries InitiatorID for this.

Frontend:
- LarkAgentBindButton takes agentOwnerId and lets the agent owner
  through (mirrors canEditAgent).
- Agent Integrations tab gates Lark per-agent (owner or admin) while
  Slack stays workspace-admin-only, since its routes are unchanged.

Tests: begin/status/revoke authorization (owner, agent owner, unrelated
member) on the backend; agent-owner bind visibility on the frontend.

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

* fix(lark): keep orphan installation revoke available to workspace admins (MUL-4213)

RevokeLarkInstallation loaded the bound agent and ran canManageAgent
unconditionally, so once the agent was hard-deleted the load 404'd and
a workspace owner/admin could no longer disconnect the orphan Lark
installation — a documented cleanup path (ListByWorkspace lists orphans;
the active-connection query filters them; Settings surfaces "Unknown
Agent" Disconnect).

Fall back to workspace owner/admin-only revoke when GetAgentInWorkspace
finds no agent; agents that still exist keep the owner-OR-admin
canManageAgent check. A plain member gains no orphan-row cleanup rights.
No FK/cascade — resolved in the application layer.

Adds a backend regression test: orphan installation is revocable by a
workspace owner but not a plain member.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 16:19:41 +08:00
Bohan Jiang
fd58e13bec feat(runtimes): custom runtime names + searchable machine-grouped picker (MUL-4217) (#5070)
* feat(runtimes): custom runtime names + searchable machine-grouped picker

MUL-4217. Runtime names were daemon-generated ("Claude (host)") and
uneditable, so picking one at agent-create time was painful once a
workspace had many machines.

Phase 1 — create-agent RuntimePicker: add a search box (>6 runtimes) and
group options by machine (Local/Remote/Cloud, online-first, current
machine first) reusing buildRuntimeMachines/filterRuntimeMachines. Rows
show the provider under a machine header instead of a flat repeated list.

Phase 2 — custom names: new nullable agent_runtime.custom_name column,
never written by the registration/heartbeat upsert so the daemon can't
clobber it; display is coalesce(custom_name, name) via runtimeDisplayName.
PATCH /api/runtimes/:id gains custom_name (+ apply_to_machine to name every
runtime sharing a daemon_id in one action, owner-scoped for non-admins).
Rename UI on the runtime detail page; `multica runtime rename` CLI command.

Verified: go build/vet, sqlc, handler tests (incl. new custom-name single
+ machine-fanout), 1650 views + 764 core TS tests, typecheck, locale parity.

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

* fix(runtimes): address review — persist machine name on new registrations, keep custom_name in register response

Elon's review on #5070 (MUL-4217):

1. Machine name looked "lost" when a new provider registered on an
   already-named machine — the new row landed with custom_name=null and
   broke sharedCustomName. Now a fresh runtime inherits the machine's shared
   custom name at register time (ListDaemonCustomNames + sharedDaemonCustomName),
   so the machine title stays stable as providers come and go.

2. DaemonRegister rebuilt the response row by hand and dropped custom_name,
   so register returned custom_name:null — inconsistent with list/get/update.
   Both branches now carry CustomName.

Also: tighten the updateRuntime patch type to custom_name?: string (drop the
misleading `| null`, since the server treats null as "unchanged", not "clear").

Tests: register response preserves custom_name; new runtime inherits machine name.
Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): inherit machine name for failed-profile registrations too

Elon's re-review of #5070 (MUL-4217): the machine-name inheritance added
last round only covered the normal req.Runtimes path. The req.FailedProfiles
branch also upserts a daemon_id-scoped agent_runtime row (offline, profile
registration error), which shows up in the runtime list / machine grouping —
so on a named machine a failed custom-profile row landed with custom_name=NULL
and dragged the machine title back to the hostname.

Extract the inheritance into h.inheritMachineCustomName and call it from both
the normal runtime path and the failed-profile path. Add a test: named daemon
+ failed profile upsert -> the failed row's persisted custom_name is inherited.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 16:00:17 +08:00
Bohan Jiang
4ed582e3bb fix(cli): reject issue list --direction when sort is position or omitted (#5072)
position (the manual board order) is always sorted ascending server-side,
so --direction was silently dropped for the default/position sort. A passed
-but-ignored flag is a footgun, especially in scripts. Reject the combination
up front with a message that names the directional sort columns instead.

MUL-4222

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 14:49:41 +08:00
Firer
519d2aeff0 feat(cli): enable issue ordering via cli (issue reorder, --position, --sort/--direction) (#4110)
Closes #4109

MUL-4222
2026-07-08 14:35:47 +08:00
Multica Eve
b6adf23f91 feat(api): emit Content-Length header on JSON responses (#5021)
The core writeJSON helpers streamed the body via json.NewEncoder(w).Encode
after WriteHeader, which forces net/http into chunked transfer encoding and
omits Content-Length. Buffer the marshaled body first, set an accurate
Content-Length, then write — so API (and health) JSON responses advertise
their exact size. writeMeasuredJSON gets the same header. Adds a test
asserting the header matches the on-wire body length.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 14:43:19 +08:00
Naiyuan Qing
156d580688 MUL-4142: rename teams surface to spaces
Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 14:14:32 +08:00
LinYushen
566d51f1c0 perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159) (#5018)
* perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159)

ListPendingChatTasksByCreator was a top DB hotspot. Root cause: the partial
index idx_agent_task_queue_chat_pending (migration 040) only covers
status IN (queued, dispatched, running), but migration 109 added a fourth
in-flight status (waiting_local_directory) that both pending chat queries now
filter on. Postgres can only use a partial index when the query predicate is a
subset of the index predicate, so the 4-status query stopped using it and
degraded to a Seq Scan over the whole agent_task_queue.

Implements the reviewed P0-P3 plan in one PR:

P0 index fix (split single-statement CONCURRENTLY migrations per repo convention)
- 143: CREATE INDEX CONCURRENTLY idx_agent_task_queue_chat_pending_v2 covering
  all four in-flight statuses (same column list, so GetPendingChatTask still
  benefits).
- 144: DROP the superseded 3-status index, in its own migration.

P1 SQL + handler hot path
- ListPendingChatTasksByCreator now returns cs.agent_id and states
  chat_session_id IS NOT NULL so the planner can prove the partial-index subset.
- ListPendingChatTasks filters private-agent access against the already-loaded
  accessible-agent set using the returned agent_id, dropping the extra
  ListAllChatSessionsByCreator scan on the hot path.
- Regenerated sqlc.

P2 frontend request amplification
- FAB uses the new boolean has-any query gated on enabled:!isOpen, so the
  minimised button never holds the full aggregate.
- use-realtime-sync maintains the pending aggregate (list + has-any) in place
  from task lifecycle events (queued/dispatch/running/waiting_local_directory
  -> upsert; completed/failed/cancelled -> remove) instead of invalidating on
  every chat:message/chat:done, with a debounced fallback invalidate for
  reconnect / unknown payloads.

P3 boolean endpoint
- GET /api/chat/pending-tasks/has-any backed by HasPendingChatTasksByCreator
  (EXISTS). Permission filtering is baked in via agent_id = ANY($3); an empty
  accessible-agent set short-circuits to false. The detailed list stays for the
  ChatWindow history / stop-task flows.

Tests: new handler tests cover the private-agent gate on both endpoints
(hidden from a creator who lost access, visible to the agent owner) plus the
boolean status/terminal semantics.

EXPLAIN (ANALYZE, BUFFERS) on a 300k-row reproduction:
- before (3-status index): Parallel Seq Scan, ~300k rows filtered,
  shared hit=3012, 12.1 ms.
- after (v2 index): Index Scan on idx_agent_task_queue_chat_pending_v2,
  shared hit=131, 0.07 ms.

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

* fix(chat): stop optimistic cross-session pending aggregate writes from workspace-fanout task events (MUL-4159)

Review on PR #5018 flagged a real privilege-escalation bug in the P2 change:
use-realtime-sync optimistically upserted the cross-session pending aggregate
(pendingTasks / pendingTasksHasAny) from chat task:* events. Those events are a
workspace fanout delivered to every member (server still BroadcastToWorkspace,
see cmd/server/listeners.go), and the payload carries no creator / agent
visibility. So member B starting a chat task could flip member A's FAB to
has_pending=true, bypassing the server-side permission filter on
/api/chat/pending-tasks[/has-any].

Fix (option 1 from the review — the self-contained one): never optimistically
write the aggregate from task:* events. On every task lifecycle transition,
debounced-invalidate the aggregate so it is refetched through the
permission-filtering endpoint, which only returns the caller's own
creator-owned, accessible-agent tasks. The per-session pendingTask cache is
still written directly — it is keyed by chat_session_id and only rendered for
sessions the user can open (server-gated), so it is not a cross-user leak.
chat:message is still excluded from aggregate refresh, so the MUL-4159 request
storm stays fixed; task transitions are per-task and coalesced by the debounce.

- Removed upsertPendingAggregate / removePendingAggregate.
- Added exported refetchPendingChatAggregate(qc, wsId) — an invalidate, never a
  setQueryData — used by the debounced handler.
- Regression tests: refetchPendingChatAggregate leaves the cached
  has_pending/list untouched (no optimistic write) and only invalidates for an
  authoritative server-filtered refetch; no-ops without a workspace id.

Verified: @multica/core + @multica/views typecheck; full core vitest suite
(752 tests) green including the 2 new guard tests.

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

* chore(chat): address review nits on pending-tasks endpoints (MUL-4159)

- Restore the GetPendingChatTask godoc first line that was clipped when the
  has-any handler was inserted (nit#1).
- ListPendingChatTasks short-circuits to an empty list when the caller has no
  accessible agents, mirroring HasPendingChatTasks — skips the DB round-trip
  (nit#2).
- Add a cross-creator negative test: user A's in-flight task on a
  workspace-visible agent returns has_pending=false / empty list for user B,
  locking the cs.creator_id tenant gate that the agent-visibility filter does
  not cover (nit#3).

Verified: go build ./... and go test ./internal/handler -run PendingChatTasks
(7 tests) green against live Postgres.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 13:20:30 +08:00
Bohan Jiang
2747416380 MUL-4117: feat(cli): add workspace member invite command (#5017)
Closes #4967
2026-07-07 12:43:33 +08:00
Naiyuan Qing
7eec19d3df chore(teams): CLI move flag, docs, dead code, terminology
- multica issue update --team <UUID-or-key> exposes move-to-team on the CLI
- working-on-issues SKILL.md + source-map document move semantics, alias
  fallback, and creation-time-only team binding
- drop TeamPicker's unused allowedTeamIds and the uncalled ProjectHasTeam
  query (strong-association leftovers), regenerate sqlc
- zh issues.json team property uses Team; conventions glossary gains the
  Team entry (kept in English, rationale included)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:18:48 +08:00
Multica Eve
a69d969fb1 fix(sweeper): gate running-task wall clock on runtime liveness (MUL-4107) (#4978)
The server-side running-task sweeper failed rows purely on `started_at >
now() - runningTimeoutSeconds` (2h30m). By its own comment the wall clock
is "mainly for runs whose daemon died without reporting" and "only needs
to sit generously above any realistic single run" — but the predicate
does not actually distinguish a healthy long-running task from an
orphaned one. On self-hosted deployments this kills multi-hour research
/ training runs mid-flight even though the daemon is still heartbeating
and the run is actively producing output.

The daemon side is intentionally unbounded (only inactivity watchdogs:
idle 30m, tool 2h); the server backstop was silently the only wall
clock. `FailStaleTasks` is now AND-gated on runtime liveness:

  * dispatched — unchanged; already excludes rows with a live
    `prepare_lease_expires_at` (renewed every 15s by the daemon between
    claim and StartTask).
  * running — new: excluded when the task's `agent_runtime` row is
    `online` AND `last_seen_at` is within the runtime stale window
    (staleThresholdSeconds = 150s, the same signal
    sweepStaleRuntimes already uses).

Healthy long-running tasks on live daemons are no longer killed by the
wall clock. The daemon-dead case remains primarily handled by
sweepStaleRuntimes in the same tick (Redis LivenessStore + DB stale +
FailTasksForOfflineRuntimes); the wall-clock branch is now a defensive
backstop for the pathological case where a runtime row lingers online
with a stale DB heartbeat for longer than the wall clock. `runtime_id
IS NULL` is treated as "not proving liveness" so the wall clock still
fires on that (rare / historical) shape.

The 2h30m default is unchanged — this is a gate, not a threshold
change. Tests updated: 4 existing running-task tests now age out the
runtime so they still exercise the wall clock; 2 new tests cover both
new invariants (healthy runtime → skipped; stale runtime → still
killed).

Fixes #4958

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 16:29:02 +08:00
Naiyuan Qing
e301057189 Merge origin/main: port WS-1465 recent-duplicate guard into IssueService create tx
Conflict resolution on top of the team-rollout architecture:
- issueguard: keep main's LockAndFindRecentAutopilotDuplicate alongside the
  team-aware active-duplicate lock key
- handler/issue.go SearchIssues: adopt main's runSearchQuery statement-timeout
  wrapper (MUL-4059) with the team-aware scan columns (team_id/key/name)
- service/issue.go: new IssueCreateOpts.PreCreate hook — runs inside the create
  tx before numbering/insert so caller guards hold their advisory locks until
  commit
- service/autopilot.go: dispatchCreateIssue passes the WS-1465 recent-duplicate
  guard via PreCreate and links the run to the issue inside BeforeCommit (the
  guard's EXISTS clause only counts issues with a linked run)
- search_test.go: main's new MUL-4059 tests updated to the team-aware
  buildSearchQuery signature

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:28:48 +08:00
Naiyuan Qing
2aef95a829 Merge branch 'agent/walt/ee2db527' into pr-4784-fix 2026-07-06 15:16:55 +08:00
tangyuanjc
3f17c2717b WS-1465 fix autopilot duplicate issue dispatch (#4936)
Co-authored-by: JC的AI分身 <tangyuanjc@JCdeAIfenshendeMac-mini.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 13:16:54 +08:00
Naiyuan Qing
a473f9ba31 Merge origin/main into team rollout: port MUL-3963 invocation gate into relocated dispatch check
Conflict resolution:
- task.go: keep both new imports (featureflags from main, issueidentifier from branch)
- SKILL.md: keep Team-key identifier wording and main's reference-only PR link rule
- autopilot.go: keep branch refactor (enqueue moved into IssueService.Create), adopt
  main's canCreatorInvokeAgent (MUL-3963) at the relocated pre-Create gate, drop the
  superseded canCreatorAccessPrivateLeader and unused getIssuePrefix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:20:39 +08:00
Naiyuan Qing
334c143ff0 feat(teams): per-user membership model with wholesale member configuration
Membership rows gain a fractional sort_order (migration 135) — the
user's personal sidebar order, Linear's TeamMembership.sortOrder shape.
System placement is atomic and mandatory: workspace creation, invitation
acceptance, and direct member-add all join the default team (hard
failure instead of the old silent skip, so a system-placed member can
never be team-less). Team creation makes the creator lead and can pull
in members.

The member set is configured wholesale via PUT /api/teams/{id}/members
(anyone can edit anyone — membership only drives the sidebar and
personal defaults, never access): kept rows are untouched, added members
land at the end of their own order, and an empty list is rejected since
zero members means archiving, which the client does explicitly after a
confirm. GET members and PATCH membership (sidebar drag sort) complete
the surface. User-facing wording says identifier; the API keeps 'key',
mirroring Linear's own Team.key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:21:54 +08:00
LinYushen
cb68669c73 feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)

* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)

Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).

- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
  active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
  CompleteCallback (idempotent upsert), ListConnections, Disconnect
  (upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
  pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
  COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
  delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
  toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
  or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
  or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
  idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
  handlers (httptest), redact regression for Bearer mcp_ tokens

MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).

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

* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)

Address PR 4608 review (CHANGES_REQUESTED):

- callback: verify connected_account_id with Composio before mirroring it.
  The signed state only proved user/toolkit/exp, so a valid state paired with
  a tampered connected_account_id would be written verbatim. CompleteCallback
  now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
  unless the account belongs to the state's user (composio_user_id == multica
  user id) and was created under the toolkit's auth config. No row is written
  on mismatch / unknown account / upstream error.

- disconnect: short-circuit to a no-op when the local row is already revoked,
  before touching upstream. Previously a second DELETE re-hit Composio and a
  non-404 upstream error surfaced as a 502, breaking the 204-idempotent
  contract.

- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
  constraint and make duplicate selection deterministic (newest-wins, rows are
  connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
  real single-account-enforcement vs multi-account-shape decision.

Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.

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

* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)

Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.

Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
  is now resolved at request time from the project's /auth_configs (cached,
  5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
  to exactly one project; the project is resolved from the key. Only org-level
  endpoints use x-org-api-key, which this integration never calls.

Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
  is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
  custom/white-label config over Composio-managed, newest wins); BeginConnect
  and CompleteCallback resolve via it; ListToolkits fetches the full catalog
  (paginated, capped) annotated with connectable = has an enabled auth config,
  connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
  when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.

Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
  composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
  toolkit as a card with search. Connect is gated on `connectable`;
  non-connectable toolkits show a muted "not configured" hint instead of a
  dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.

Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.

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

* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)

Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.

Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.

Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.

Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.

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

* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)

Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.

Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.

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

---------

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

* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)

* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)

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

* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)

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

---------

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

* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)

* fix(composio): move OAuth callback out of the Auth group (MUL-3843)

Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.

Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.

Refs MUL-3843, MUL-3715.

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

* fix(composio): correct stale callback routing comments (MUL-3843)

The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.

Refs MUL-3843.

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

---------

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

* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)

Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.

Server side
  - Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
    BEFORE-UPDATE trigger that wipes the column on any transition into a
    terminal status (completed / failed / cancelled). A trigger is the single
    source of truth — future queries that flip status cannot bypass it.
  - composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
    emits the Claude-style { mcpServers: { composio: { type: http, url,
    headers } } } shape the daemon's existing sidecar generators consume.
    Returns (nil, nil) on zero active connections so we never burn a
    Composio session for a user with nothing to call.
  - TaskService grows a Composio ComposioOverlayBuilder seam, wired in
    router.go after composiointeg.NewService succeeds. Five enqueue paths
    (issue / mention / quick-create / chat / auto-retry) attach the overlay
    after CreateAgentTask returns and before the daemon is notified — so
    every claim reads a settled row, with no second daemon hop. Best-effort:
    a builder failure logs and proceeds with no overlay.
  - resolveInitiatorFromTriggerComment derives the initiator user from the
    trigger comment when it was authored by a member. Agent-authored
    triggers are not treated as initiators (their connected-apps view is
    empty by construction).

Daemon side
  - handler/daemon.go claim path merges task.runtime_mcp_overlay onto
    agent.mcp_config via mergeMCPOverlay before populating
    TaskAgentData.McpConfig. Overlay wins on server-name collisions
    because it carries the live user-scoped session URL. Errors fall back
    to the agent config unchanged — a bad overlay must not surprise-disable
    saved MCP tools. The existing execenv sidecar generators (cursor /
    codex / openclaw / opencode / hermes-kiro) need no changes: they keep
    consuming the merged result through TaskAgentData.McpConfig.

Tests
  - 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
    pass-through, overlay-only canonicalization, two-side merge, name
    collision (overlay wins), top-level key preservation, malformed agent
    fallback, malformed overlay fallback, non-object server rejection.
  - 4 dispatch cases (composio): zero-connections returns nil without
    CreateSession, happy-path emits the right shape with the right user
    id, empty-URL defensive branch, SDK error surfacing.
  - 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
    invalid initiator does not call the builder, nil overlay skips the
    UPDATE, builder error swallowed without panic.
  - Migration 128 verified to roll up + down + up cleanly against the test
    database.

Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.

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

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)

Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:

- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
  Owner-only read/write; non-owner GET/PUT silently redacts/drops the
  field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
  top-of-chain HUMAN at every enqueue path:
    * issue/mention comment by member  -> author_id
    * issue/mention comment by agent   -> inherit via comment.source_task_id
                                          -> parent task originator_user_id
    * quick-create                     -> requester_id
    * chat                             -> initiator_user_id
    * retry                            -> SQL-inherited from parent row
    * autopilot                        -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
  agent) and short-circuits on five gates: invalid originator,
  originator != agent.owner_id, empty allowlist, empty intersection of
  allowlist ∩ active connections, defensive empty session URL. Composio
  CreateSession is called with BOTH `toolkits.slugs` (the intersection)
  AND `connected_accounts` (the pinned account ids), narrowing the
  tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
  workspace member who can @-mention a public agent used to project the
  owner's connected apps into their run. Now the overlay only mounts
  when the human at the top of the chain IS the agent owner.

Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
  normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
  applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
  (list/empty/null), workspace-admin silent-drop, owner-only GET
  visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
  agent-authored inherits via comment.source_task_id, invalid id.

Migration 129 up/down/up verified against docker postgres.

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

* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review 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>

* fix(composio): accept nested connected account auth config

* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)

Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.

- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
  composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
  array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
  cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
  empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
  to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
  is gated to the creator (currentUserId === agent.owner_id), matching the
  backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
  empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
  non-creator hidden) with Composio + agent endpoints mocked at the boundary.

Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.

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

* fix(composio): mount remote MCP for codex

* feat(agents): agent invocation permission system (MUL-3963) (#4844)

* feat(agents): agent invocation permission system (permission_mode + invocation targets)

MUL-3963: split who may INVOKE an agent out of the overloaded visibility
column into an explicit, extensible model on feature/composio-integration.

- DB: agent.permission_mode (private|public_to) + agent_invocation_target
  table (workspace/member/team targets) + lossless backfill from visibility
  (migration 130).
- canInvokeAgent: owner-only for private (NO admin bypass, NO A2A bypass);
  public_to honours the allow-list; A2A judged by the top-of-chain originator.
- All trigger paths rewired: issue assign, comment @agent/@squad, chat,
  quick-create, autopilot, squad leader, child-done.
- Agent API: permission_mode + invocation_targets on responses and
  create/update (owner-only writes); legacy visibility kept as a derived field
  so old clients never see a permission widening.
- Composio: BuildTaskOverlay now FOLLOWS invocation permission and uses the
  agent OWNER connection (removed the originator==owner gate); front-end warns
  when a shared agent enables Composio apps.
- CLI: --permission-mode / --public-to-workspace / --public-to-member (legacy
  --visibility still mapped).
- Frontend: AccessPicker (Private / workspace / specific people / team soon),
  permission rules mirror canInvokeAgent, Composio warning banner.
- Tests: migration backfill, admin cannot invoke others private, public_to
  workspace/member whitelist, A2A by originator, Composio overlay uses owner
  connection.

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

* feat(agents): stackable, mixed public_to invocation targets (MUL-3963)

Follow-up on PR #4844: public_to now supports selecting MULTIPLE, MIXED
targets on one agent (e.g. Public to workspace + specific people + team),
with canInvokeAgent admitting on ANY matching target (OR).

- Frontend AccessPicker: reworked from a single exclusive kind into a
  stackable multi-select — an "Everyone in workspace" toggle, a member
  multi-select checklist, and a (disabled, v1) team placeholder can be
  combined freely. Emits the full union of selected targets; empty union
  collapses to Private. Existing team targets are preserved across saves.
  Added the access.public_group locale string (en/zh-Hans/ja/ko).
- Backend already supported this (agent_invocation_target is multi-row per
  agent; create/update take a target ARRAY and batch-replace the whole
  allow-list; canInvokeAgent OR-matches). Added tests to lock it in:
  mixed member+team targets, overlapping-member batch replace, and
  workspace+member stacking then narrowing.

Refs MUL-3963.

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

* fix(agents): address review on invocation permission (MUL-3963)

张大彪 review on PR #4844 — three blockers + product ruling + nits:

1. Migration 130: drop the FK/cascade on agent_invocation_target
   (agent_id, created_by) per the Multica no-FK rule; relationships are now
   maintained in the app layer (matching MUL-3515 §4). Added
   DeleteAgentInvocationTargetsByArchivedRuntimeAgents and call it before
   DeleteArchivedAgentsByRuntime in all three runtime-delete paths
   (runtime.go x2, runtime_profile.go) so hard-deleting agents can't orphan
   target rows.
2. revokeAndRemoveMember: prune the leaving member's member-target grants
   (DeleteAgentInvocationTargetsByMember) in the same tx as the member-row
   delete, so a re-invited user can't reclaim a stale invocation grant.
3. Empty public_to is a phantom — parsePermissionInput now normalises a
   public_to with no resolvable targets to a single workspace target, so
   `--permission-mode public_to` alone (and any empty target array) means
   "public to workspace" instead of "shared but nobody can run it".

Product ruling: the system/no-human-originator → workspace-target path in
canInvokeAgent is a deliberate, documented exception (webhook/system/
workspace-wide automation); member/team targets still fail closed without a
resolved originator. Documented in code + locked with a test.

Nits: refreshed the stale "originator must be owner" comments — models.go
(via migration 130 COMMENT ON COLUMN + sqlc regen for composio_toolkit_allowlist
and originator_user_id) and agent-mcp-tab.tsx — to the owner-connection +
invocation-permission rules.

Tests: member remove/re-add regression, system workspace exception + member
fail-closed, empty public_to → workspace (plus the earlier mixed/overlap/
batch-replace suite). Migration 130 applied to the test DB; Go handler/service/
composio suites green; views typecheck clean.

Refs MUL-3963.

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

* fix(agents): scope member invocation-target cleanup to one workspace (MUL-3963)

张大彪 3rd review — cross-workspace permission bug + comment nits:

- DeleteAgentInvocationTargetsByMember was a GLOBAL delete by user id, so
  removing a user from workspace A also wiped their member-target grants on
  agents in workspace B. Scoped it to a single workspace by joining through
  agent.workspace_id; revokeAndRemoveMember now passes (workspaceID, userID).
- Regression test TestRevokeMember_InvocationTargetCleanupIsWorkspaceScoped:
  same user allow-listed by agents in two workspaces; removal from one leaves
  the other workspace's target intact.
- Nits: refreshed the remaining stale "originator == agent.owner_id" /
  "owner-vs-originator" comments — CreateRetryTask (agent.sql, regenerated),
  and the AgentResponse allowlist doc + ListAgents/UpdateAgent redaction
  rationale in agent.go — to the owner-connection + invocation-permission rule.

Migration 130 applied to the test DB; Go handler/service/composio suites green;
go vet clean.

Refs MUL-3963.

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

---------

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

* fix(agents): agent access owner-only editable, read-only for others (MUL-3963) (#4853)

* fix(agents): make agent access owner-only editable, read-only for others (MUL-3963)

Interaction bug: a non-owner (incl. workspace admin) could open the AccessPicker
and set an agent public — the backend silently ignored it and the UI bounced
back to private. Access is owner-only, so non-owners must see a read-only state
and the backend must reject real changes explicitly.

Frontend:
- AccessPicker renders a static, non-interactive read-only state when the
  viewer is not the owner: the current access value + a lock affordance + a
  tooltip "Only the agent owner can change who can run this agent." No clickable
  trigger is rendered, so a non-owner can never open a control the backend would
  reject (the GitHub/Notion pattern for permission settings you can see but not
  edit). The editable multi-select picker is unchanged for the owner.
- agent-detail-inspector gates the picker on ownership specifically
  (currentUserId === agent.owner_id), NOT the general canEdit (which also admits
  admins, who may edit other fields but not access).
- New locale key access.owner_only_readonly (en/zh-Hans/ja/ko).

Backend:
- UpdateAgent now returns an explicit 403 when a non-owner submits a REAL
  permission change (permissionInputChangesAgent compares requested mode +
  target set against the persisted state); a no-op resubmit (admin PATCH-as-PUT
  echoing unchanged permission) is still tolerated so admin edits of other
  fields keep working. Replaces the previous silent-drop that caused the bounce.

Tests:
- access-picker.test.tsx: non-owner gets a non-interactive read-only display
  with the owner-only tooltip; owner gets an interactive picker; owner can pick
  a member and stack workspace + member.
- TestUpdateAgent_AccessChangeIsOwnerOnly: admin real change → 403; admin no-op
  resubmit → 200; admin editing other fields → 200; owner change → 200.

Incidental: fixed a pre-existing base typecheck break in
slash-command-suggestion.test.tsx (stray `signal` arg not in the suggestion
items type) that otherwise fails the whole @multica/views typecheck.

Refs MUL-3963.

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

* fix(agents): compare legacy visibility, not expanded permission, for no-op detection (MUL-3963)

PR #4853 review: permissionInputChangesAgent expanded a legacy-only
visibility:"private" into a real private permission and compared it against the
agent's actual permission. A member-only public_to agent derives legacy
visibility "private", so an admin PATCH-as-PUT echoing visibility:"private"
while editing another field was misread as a public_to→private downgrade and
rejected with 403 — contradicting the "unchanged permission no-op is allowed"
contract.

Fix (per review): when a request carries ONLY legacy `visibility` (no
permission_mode / invocation_targets), derive the agent's CURRENT legacy
visibility from its real targets and compare the legacy string values. Equal =
no-op (allowed); a real legacy change (e.g. "workspace") still returns 403.
Requests that carry permission_mode / invocation_targets keep the precise
mode+target comparison.

Regression test TestUpdateAgent_LegacyVisibilityNoOpForMemberOnlyPublicTo:
member-only public_to agent — admin submitting visibility:"private" + a
non-permission field → 200 with targets unchanged; admin submitting
visibility:"workspace" → 403.

Go handler/composio suites green; migration 130 applied; go vet clean.

Refs MUL-3963.

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

---------

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

* feat(composio): brief agents on connected apps

* feat(composio): gate MCP apps behind feature flag

* fix(mobile): parse agent invocation permissions

* fix(tests): update agent fixtures for access fields

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica Eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
2026-07-03 14:18:43 +08:00
MeloMei
e3e3e7e23f fix(realtime): bounded replay window for ShardedStreamRelay on restart (#4875)
ShardedStreamRelay shard readers previously started with lastID=,
which meant events published while a pod was down were silently lost.

Replace the  cursor with a bounded time-window start ID derived
from (now - ReplayGrace), defaulting to 5 minutes. The timestamp is
clamped to 0 to handle misconfigured clocks gracefully.

Key changes:
- Add ReplayGrace field to ShardedStreamRelayConfig (default 5m)
- Add replayStartID() helper with non-negative clamp
- Extract readShardOnce() from readShard() for testability
- Add REALTIME_RELAY_REPLAY_GRACE env var for runtime tuning
- Add regression tests for bounded cursor and replay behavior

Closes #4797
2026-07-03 12:16:43 +08:00
tangyuanjc
942255d283 fix(autopilot): keep create_issue runs visible when runtime offline (#4848)
Co-authored-by: JC的AI分身 <tangyuanjc@JCdeAIfenshendeMac-mini.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-03 11:59:26 +08:00
Naiyuan Qing
034f3bcb98 test(server): make raw-SQL issue fixtures team-aware
Backfill team_id (default team subselect) into test fixtures that
insert issues directly, matching the NOT NULL constraint from the
team-number cutover migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:24 +08:00
Naiyuan Qing
c0d42e5c7d Merge origin/main into team rollout: reintegrate team filter with extracted issue surfaces
MUL-3903 moved issue filter wiring into the core issue-surface query plan;
team_id filtering now flows through the shared plan instead of page-level params.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:56:32 +08:00
Naiyuan Qing
b302164f29 feat(teams): per-team issue numbering cutover migration and resolver audit
- migration 132: re-backfill, counter sync, validation, NOT NULL, unique(team_id, number)
- drop legacy workspace-number uniqueness; teams now number independently from 1
- audit and convert remaining workspace-number lookups to team scope
- retire workspace issue counter from production write paths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:13:41 +08:00
beast
0c4c3ff038 fix(cli): prevent daemon-managed CLI from silently using user tokens (MUL-3922)
Treat MULTICA_DAEMON_PORT and a workdir daemon-task marker as daemon-managed signals so a task subprocess that loses MULTICA_TOKEN / MULTICA_AGENT_ID / MULTICA_TASK_ID fails closed instead of silently falling back to the user config-file PAT (which made agent writes land as the workspace owner). Adds an actionable error naming a leftover marker for local_directory recovery. Fixes #4204.
2026-07-02 15:08:06 +08:00
Naiyuan Qing
c35e1af3a7 fix(teams): resolve review blockers in team rollout
- normalize legacy issue prefixes in migration preflight instead of aborting
- return team_ids on project search and batch project team loading
- guard team archive against active autopilots; distinguish archived team errors
- seed default workspace team in integration fixture; align test counters
- record autopilot run failure when task enqueue fails after issue create
- add null-team default-key fallback to identifier search

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:59:23 +08:00
Naiyuan Qing
7b3cad664b revert(self-host): remove source channel reporting (#4799)
* Revert "test(onboarding): cover official source reporting controls (#4782)"

This reverts commit fc88c7720f.

* Revert "fix(self-host): restore official source report endpoint (#4781)"

This reverts commit ad1afdd48d.

* Revert "feat(self-host): collect anonymous source channels mul-3878 (#4741)"

This reverts commit 26142d74aa.
2026-07-01 17:53:16 +08:00
Bohan Jiang
159d9bebb1 feat(slack): route /issue slash command through quick-create (MUL-3908) (#4793)
The Slack `/issue` slash command used to directly create a raw issue: the
typed line became the title verbatim and a `todo` issue was assigned to the
agent to work on immediately. That files a rough, unstructured issue and starts
the agent on it before it is well-formed.

Switch the slash command to the quick-create pipeline instead
(TaskService.EnqueueQuickCreateTask, the same path as the web "quick create"
modal): the invoker's natural-language description is handed to the
installation's agent as a prompt, and the agent authors a well-formed issue
(proper title + structured description) in the background, attributed to the
bound member. Because creation is now asynchronous, the ephemeral reply is an
acknowledgement ("On it…") rather than a created-confirmation with a number;
the agent's completion surfaces to the invoker as a Multica inbox notification
through the shared quick-create completion path.

Installation routing and identity/membership checks are unchanged, so the same
workspace boundary and account-binding rules apply. Scope is the slash command
only — the message-based `@bot /issue` still runs through the shared
cross-platform engine (which also serves Lark) and keeps its direct-create
behavior.

- slash_command.go: swap IssueService.Create for EnqueueQuickCreateTask via a
  narrow quickCreateEnqueuer interface; prompt is the full text (no title/body
  split); drop the now-unused splitIssueText / issueCreatedText / GetWorkspace.
- router.go: wire h.TaskService instead of h.IssueService.
- tests: cover enqueue + ack, multiline prompt pass-through, empty prompt,
  unbound, non-member, inactive, team mismatch, and enqueue-failure.
- docs (4 locales): describe the quick-create behavior.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 17:32:42 +08:00
Naiyuan Qing
ead26b6fae MUL-3906: add team ownership rollout
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 15:06:28 +08:00
Bohan Jiang
240ec4efd0 feat(slack): native /issue slash command over Socket Mode (MUL-3908) (#4780)
* feat(slack): native /issue slash command over Socket Mode (MUL-3908)

A message beginning with `/issue` is intercepted by the Slack client as a slash command and never delivered to the app, so the message-prefix /issue never worked on Slack (no event, no 👀, no issue).

Register /issue as a real slash command in the app manifest and handle EventTypeSlashCommand over the existing per-installation Socket Mode connection. It is a one-shot issue creation (no chat session / agent run) that reuses the shared IssueService and the same installation-routing + identity/membership checks as the message path, replying privately via the command's response_url (ephemeral) since a slash command has no message to react to.

Docs: register the command in the manifest and describe the slash-command behavior across all four locales.
Co-authored-by: multica-agent <github@multica.ai>

* docs(slack): add commands scope to /issue manifest; fix chat-run wording (MUL-3908)

Review follow-up: the manifest examples registered features.slash_commands but omitted the commands bot scope, so updating + reinstalling could still fail to grant the /issue command. Add - commands to oauth_config.scopes.bot in all four locales and document it in the permissions table.

Also correct the misleading "no agent run" wording in the slash-command header and router comment: a todo issue assigned to the agent still triggers it via maybeEnqueueOnAssign (issue-assignment), like the message /issue — the slash command only skips the chat session / chat run.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 14:47:44 +08:00
Naiyuan Qing
26142d74aa feat(self-host): collect anonymous source channels mul-3878 (#4741)
* feat(self-host): collect anonymous source channels

* feat(self-host): include other source text

* feat(self-host): report source channel domains

* feat(self-host): add source domain reporting control

* fix(self-host): simplify source reporting copy

* fix(self-host): simplify source channel reporting gate

* fix(self-host): limit source reporting triggers

* chore(self-host): point source reporting at staging
2026-07-01 13:31:25 +08:00
Bohan Jiang
dbf11f0958 fix(attachments): relax frame-ancestors on local /uploads static route (MUL-3821) (#4777)
Self-hosted local-disk deployments serve document previews straight from
the public /uploads/* static route. That route inherited the global
`frame-ancestors 'none'` CSP from the middleware, so iframe-based previews
(PDF/HTML) were blocked by the browser — only the /api/attachments/*
download endpoint had been exempted (#4635 / #4679).

Serve /uploads/* through a new Handler.ServeLocalUpload that applies the
same preview security headers as the download endpoint
(setAttachmentPreviewSecurityHeaders), so the relaxed, config-aware
`frame-ancestors 'self' <configured origins>` policy applies to both
same-origin and split frontend/backend origin setups. Inline <img>
rendering is unaffected (frame-ancestors does not gate images); cloud
storage (S3/CloudFront) never hits this route.

Adds regression tests covering the relaxed CSP on /uploads and the
non-local-storage 404 guard.

Refs #4477

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 13:28:44 +08:00
Bohan Jiang
3a6d3522c8 feat(slack): two-command channel reads — chat history (overview) + chat thread [id] (MUL-3871) (#4762)
Replaces the single scoped `multica chat history --scope` read with two clean
noun-commands so the agent can navigate a channel with many threads (e.g. read
the specific thread a user referred to):

- `multica chat history` — the channel OVERVIEW: recent top-level messages, each
  thread tagged with thread_id + reply_count + latest_reply (it does NOT expand
  thread contents). Backed by GET /api/chat/history + slack.History.ChannelOverview
  (conversations.history).
- `multica chat thread [id]` — read one thread: no id = the thread you're in,
  an id = a specific thread IN THE SAME channel. Backed by GET /api/chat/thread +
  slack.History.Thread (conversations.replies; DM falls back to history).

The channel stays server-pinned to the session; a thread id is only a
within-channel locator, so the security boundary (no cross-channel reads) is
unchanged. `--scope` is removed.

The prompt now teaches both commands and, via a new chat_in_thread signal
(derived from the binding: last_thread_id != last_message_id), tells the agent
which to start with — `chat history` for a top-level @mention, `chat thread` for
an in-thread one.

Tests: slack ChannelOverview/Thread (current/by-id/DM-fallback/no-binding/clamp),
handler both endpoints + auth, prompt top-level vs in-thread guidance.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 12:46:47 +08:00
Bohan Jiang
50a48cef1e feat(slack): unified multica chat history pull for channel backfill (MUL-3871) (#4747)
* 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>
2026-06-30 16:48:13 +08:00
MeloMei
ff286dcfac MUL-3848: fix(server): skip CLIENT SETNAME for managed Redis compatibility
Closes #4627
2026-06-30 15:51:45 +08:00
Bohan Jiang
b90816264e feat(skills): import skills from a .skill/.zip archive (#4735)
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
2026-06-30 14:46:46 +08:00
Bohan Jiang
e5995c423f feat(slack): typing reaction on inbound message (MUL-3874) (#4737)
* 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>
2026-06-30 14:21:08 +08:00
Multica Eve
f892e03e41 feat(cli)!: drop short UUID prefix resolution for multica issue (MUL-3838) (#4732)
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>
2026-06-30 12:31:15 +08:00
Bohan Jiang
d970b68ce7 feat(autopilot): View/Write permission layer + member access delegation (MUL-3807) (#4695)
* 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>
2026-06-30 12:29:11 +08:00
Multica Eve
5d79696fb5 MUL-3794: rewrite comment routing cascade 2026-06-30 12:24:57 +08:00
Naiyuan Qing
b336f07617 Revert "feat(analytics): anonymous self-host onboarding source beacon (MUL-37…" (#4712)
This reverts commit 63eb6f73ad.
2026-06-29 19:01:14 +08:00
Bohan Jiang
2b940046d7 fix(slack): build the binding link from the web app URL, matching Lark (MUL-3666) (#4703)
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>
2026-06-29 17:13:41 +08:00