Commit Graph
82 Commits
Author SHA1 Message Date
Janez ŠtuparandJanez Stupar bfe32f5e39 fix(runtime): wake daemon for local skill requests (#7713)
Co-authored-by: Janez Stupar <[email protected]>
2026-08-31 15:31:29 +08:00
YYClaw 9e2d9b6d1c MUL-6661 feat(channels): add /new and /clear conversation controls (#7468)
* feat(channels): add new and clear conversation controls

* fix(dingtalk): resolve bot identity before dispatch

* feat(chat): expose channel-created chats consistently

* refactor(channels): snapshot task delivery routes
2026-08-25 18:28:01 +08:00
YYClaw 8c563b49be MUL-6555 feat(issues): preserve source context for sub-issues (#7461)
* feat(issues): preserve source context for sub-issues

* fix(issues): address source context review findings

* fix(migrations): guard source context rollback preflight
2026-08-25 12:00:49 +08:00
YYClaw 5e2d349ec4 MUL-6426: refactor(dingtalk): safely retire group routing and enhance discovery (#7241)
Retire mutable DingTalk group routing: inbound messages always target the installation's configured Agent, and observed groups become read-only inventory in dedicated tables with persisted activity counters.

Migrations 382-389 clean stale cross-Agent bindings, create and backfill dingtalk_group_presence and dingtalk_bot_identity, and install a mirror trigger so writes from an older process during a rollback window are not lost. The legacy dingtalk_group_route table and its indexes are retained for expand/contract; a later release drops the trigger and table together.

Closes #7239
2026-08-21 14:26:48 +08:00
cb6b0c885f MUL-6396: remove unused task message clipping flag (#7292)
* refactor(realtime): remove unused task message clipping flag

Co-authored-by: multica-agent <[email protected]>

* refactor(realtime): remove task message clipping consumers

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 15:24:15 +08:00
9900e96686 MUL-6458: feat(issue-statuses): sync the status catalog over the realtime channel (#7287)
* feat(issue-statuses): sync the status catalog over the realtime channel

An admin editing the workspace status catalog was invisible to every other
tab and device for up to the query's 5 minute staleTime: the protocol had no
issue_status event at all. Other clients kept offering a status the server
had archived, and kept painting a renamed one under its old name.

Server: one `issue_status:changed` event, published after create, edit,
archive and reorder. One event rather than a verb per write because every
client answers all four the same way — re-read the catalog — and reorder has
no single row to name. Archive and reorder publish after the commit, and the
already-archived no-op publishes nothing.

Client: the event invalidates the catalog and nothing else. Issue caches are
deliberately not dragged along — a row stores the status KEY and resolves its
name and colour from the catalog at render time, so refetching the catalog is
what repaints the boards.

The catalog mutations now install the row (or, for reorder, the list) the
server returned instead of invalidating, because the event refreshes the
writing tab too. That keeps a catalog edit at ONE catalog read per client
rather than two for the admin who made it, and drops the workspace-wide
board/list/table refetch a rename used to trigger.

Reconnect recovery invalidates the catalog as well, so an edit missed while
disconnected is not held behind the staleTime.

Co-authored-by: multica-agent <[email protected]>

* fix(issue-statuses): stop a slow mutation response from reverting a newer catalog

Installing a mutation's response body into the cache is a last-write-wins
merge by arrival order, and the HTTP response and the realtime frame are
independent channels — the realtime refresh is debounced 100ms on top. So
this ordering is legal: A renames, A's response stalls, B renames, A's
refetch picks up B's version, A's response lands and rolls the row back to
its own older snapshot. Nothing corrects it until the next event or the
5 minute staleTime, in exactly the concurrent-editing case this feature
exists for. Reorder was the worst of them: its response is the whole
catalog, so one late arrival reverted every concurrent edit at once.

No mutation writes its response body any more. The realtime refetch is what
converges the cache, and it is ordered correctly by construction — the event
is published after the write commits, so the refetch can only read
post-commit state. What each mutation writes locally is instead something
that stays true whatever else landed in between:

- create inserts only when the id is absent. Holding it already means a
  refetch read it back, so the cached copy is never the older one.
- update and reorder keep their optimistic patches and add nothing on
  success.
- archive patches only archived_at. It is monotonic — there is no unarchive,
  and the server refuses to edit an archived row — so it is safe on any row,
  where the full snapshot would revert a rename that raced it.

Regression tests drive each case through a controlled promise: the newer
catalog lands while the write is still in flight, and the assertion is that
the newer one survives.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 14:32:42 +08:00
67c6aa05c4 MUL-6166: feat(telegram): add Telegram channel integration (#6944)
* feat(telegram): add Telegram channel integration

Co-authored-by: multica-agent <[email protected]>

* chore(telegram): rebase onto main and renumber migrations to 366-367

349/350 collided with main's agent_task_queue_chat_terminal_resume_index
and agent_task_queue_chat_retired_session_index. Renumber the Telegram
origin pair to the next free prefixes, update the cross-references inside
the SQL comments, and point telegram_origin_migration_test.go at the new
filenames. Contributor-facing behavior is unchanged.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-08-19 23:04:26 +08:00
0c0baf0505 perf(realtime): stop the task:message fanout from flooding every client (MUL-6396) (#7227)
* perf(realtime): stop the task:message fanout from flooding every client (MUL-6396)

`task:message` is broadcast to every client in the workspace for every run,
and the client cached all of it: the handler's `(old = [])` default built a
timeline entry on first sight, so each client accumulated the full transcript
of runs its user would never open, held for the life of the run. Tool input is
unbounded on the wire, so a single large Write shipped its whole body to every
open client and was retained there.

Three changes, each useful on its own:

- Client drops frames for tasks no mounted view is rendering. Presence in the
  query cache is the signal, which registers when a view mounts and before its
  fetch resolves, so a frame landing mid-backfill is still kept. Rows are
  persisted before they are broadcast, so a dropped frame costs nothing —
  whoever opens the task next fetches it.

- Server clips oversized `input` / `output` out of the broadcast copy and marks
  it `truncated`. Persisted rows and the REST list responses are untouched. A
  client seeing the flag repairs that task's timeline from the DB, once per
  flush window rather than once per frame.

- Client coalesces surviving frames into one cache write per 100ms window,
  turning a burst of streamed messages into one merge and one render. The
  window is fixed, not a resetting debounce, so a continuous stream still
  flushes instead of being starved until it pauses.

Mobile keeps its own per-session updater and was already scoped to mounted
sessions, but shares the wire format, so it refetches on `truncated` too.

The remaining half of the batching idea — one WS frame per reported batch
instead of one per message — is deliberately not here. The client identifies
itself on the upgrade URL but the server does not retain that version, so a
shape change would silently break every older client. It belongs with the
client scope-subscription work that `listeners.go` is already waiting on.

Co-authored-by: multica-agent <[email protected]>

* fix(realtime): keep broadcast clipping off the wire, and stop a late fetch dropping live seqs

Addresses both must-fix findings from review.

1. Clipping the broadcast copy is not something an existing client can ignore:
   it changes what `input` / `output` mean, and every deployed client already
   consumes them. A build without the `truncated` reader caches the clipped
   copy under staleTime:Infinity and never refetches, so its execution log
   would stay incomplete until the window is reloaded — the PR even proposed
   shipping the server half first, which is exactly backwards.

   Clipping now sits behind MULTICA_CLIP_TASK_MESSAGE_BROADCAST, off by
   default, so the reader ships first and the flip is a later, revertible ops
   step. Capability routing would be the principled fix, but the hub does not
   retain the client_version it is handed at upgrade and frames cross nodes
   through the Redis relay as serialized bytes — the same protocol work
   per-task scope routing is already waiting on.

2. Not caching unopened tasks made first open of a live task fetch where it
   previously found a pre-seeded cache. That fetch replaced the array
   wholesale, so any frame written while it was in flight was dropped — and
   with staleTime:Infinity nothing would ever fetch it again.

   Every write to the timeline cache now folds through one rule, installed as
   the query's own structuralSharing: union by seq, server data winning on
   conflict, rows the response never mentioned kept rather than deleted. That
   subsumes the backfill's hand-rolled eviction, so the three write paths
   (fetch, backfill, realtime batch) can no longer disagree.

Also documents why mobile's truncated branch is unreachable today: its
task:message handler gates on chat_session_id, which the server's payload does
not carry, so mobile appends nothing at all. Pre-existing, reported separately,
and the branch stays so whichever side is fixed first is not left caching
clipped rows.

Co-authored-by: multica-agent <[email protected]>

* fix(realtime): re-check the timeline is still held before writing a batch

Holding was verified when a frame entered the batch, but the write happens up
to a window later — and `setQueryData` does not postpone garbage collection.
query-core arms that timer when the last observer leaves and never re-arms it
on write, so closing a transcript while its run keeps streaming has the entry
collected mid-window with a batch already in flight.

Writing then rebuilt the entry holding only that batch. Under the app-wide
`staleTime: Infinity` the next open reads the stub as fresh and never fetches,
so everything before it is missing until the window is reloaded — silent,
permanent, and not self-healing. Agent runs routinely outlive the 10 minute
gcTime, so "opened a running task, closed it, it kept going" reaches this on
an ordinary day rather than in a race.

The flush now re-checks immediately before each write and drops the batch if
the entry is gone, dropping its repair fetch with it. `backfillTaskMessages`
guards its own post-await write for the same reason. Dropping costs nothing:
the rows are persisted, so the next open fetches the whole timeline — which
the regression test asserts rather than just checking the entry stays absent.

Also corrects the doc comment on isTaskMessageTimelineHeld, which described
the entry as a mounted view. It means "opened at some point and not yet
collected", and the bounded tail is one gcTime.

Co-authored-by: multica-agent <[email protected]>

* docs(realtime): describe the task:message gate as it actually behaves

The overview still said frames are dropped unless a mounted view holds the
task, and that the old behaviour retained a transcript for the life of the
run. Neither matches the code: the gate is timeline-entry presence (opened at
some point, not yet collected), and writes do not postpone GC, so retention is
bounded by gcTime rather than the run.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 19:25:05 +08:00
YYClaw 6377dc2656 feat(dingtalk): show linked identities to workspace admins (#7138) 2026-08-19 01:37:51 +08:00
4d495056e8 [MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add private Exa example

Co-authored-by: multica-agent <[email protected]>

* docs(plugins): remove private plugin guide

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): stabilize remote MCP tool digests

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): preserve remote MCP notification status

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make Exa example runnable end to end

Co-authored-by: multica-agent <[email protected]>

* fix(codex): record MCP tool calls in agent log

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add community skills example

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): support multi-file skill bundles

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add Mobbin MCP example

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): bundle full Matt Pocock skill set

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add remote MCP OAuth connections

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): gate enable on remote MCP readiness

Co-authored-by: multica-agent <[email protected]>

* fix(plugin): authenticate remote mcp credential broker

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden remote mcp oauth flow

Co-authored-by: multica-agent <[email protected]>

* refactor(plugins): use a single feature flag

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): clean up remote MCP OAuth state

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): remove Matt Pocock example

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-16 04:54:32 +08:00
063698f571 fix: gate worktree mode on a declared capability, not a version string (MUL-5707) (#6904)
* fix: gate worktree mode on a declared capability, not a version string (MUL-5707)

Two tasks ran in the user's own directory despite the resource being set to
worktree mode. The daemon was v0.4.23 — built before the worktree code — so
it json-skipped execution_mode and took the in_place path. The claim gate
existed to stop exactly that and did not fire: the runtime reports
`v0.4.21-24-gcd3c0bb89`, a git-describe dev build, and the version floor
deliberately exempts those so `make daemon` stays unblocked. The exemption
is fine for a gate whose failure mode is a missing field; it is wrong for
one whose failure mode is writing to somebody's working copy.

Version strings cannot answer "does this binary implement worktree mode".
The daemon now answers it directly: `local-worktree-v1` joins the existing
X-Client-Capabilities negotiation, and all three gates read that instead.

- Claim time (authoritative): decided from the capability on that very
  request. A daemon without it gets the task cancelled with the reason on
  the row — never a silent fallback to editing the directory, which is the
  outcome this mode exists to prevent.
- Save time and the UI cannot see a live request, so registration now
  records the advertised capabilities on the runtime row and both read that.
  One signal everywhere instead of three different guesses.

Absent capabilities means an older daemon, so every path fails closed. The
error names the machine's directory and says to update the Multica app
there, rather than quoting version numbers the user did not choose.

MIN_LOCAL_WORKTREE_CLI_VERSION stays only as the number shown in the
existing 422 payload; nothing gates on it any more.

Co-authored-by: multica-agent <[email protected]>

* fix(handler,views): judge daemon capability by the newest runtime row (MUL-5707)

Review follow-up. Both gates asked "did ANY runtime row for this daemon
advertise the capability", which never stops being true: deregistering a
runtime only flips the row to offline, its metadata survives, and
ListAgentRuntimes returns every row. A machine that once ran a capable
daemon and then downgraded keeps an old capable row next to the fresh
incapable one, so the save gate and the UI would go on offering worktree
mode. The claim gate still refused the task — no directory was ever at risk
— but the user only found out after the run instead of at the moment they
could act on it.

Both sides now read the daemon's most recently seen runtime row, and a row
missing the capability is never skipped: being newest is what makes it
authoritative, not whether its answer is convenient. A row that never
reported sorts oldest so a live one always wins. The selection lives in one
place per side (daemonAdvertisesWorktree / daemonSupportsLocalWorktree) with
matching semantics, because a UI that disagrees with the API offers a mode
the API refuses.

Also pins the bridge the whole gate rests on, which nothing covered:
DaemonRegister round-trips the advertised header onto the runtime row, and
re-registering WITHOUT the header clears it — the exact downgrade the save
gate has to notice. The daemon's own identity-header test now asserts
local-worktree-v1, so dropping it from the advertised list can never be a
silent change.

Corrects three places that still described the version floor as the gate:
version.go, the projects-and-resources source map, and the floor test, which
now pins the two surfaces that merely DISPLAY the version to quote the same
release.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 17:23:51 +08:00
mxbao063-baoandmxbao063-bao 8060aa7277 MUL-5947: feat(dingtalk): route groups to different agents (#6660)
Route each DingTalk group discovered by one Stream robot to its own agent, with a revision-fenced cutover so a reassigned group never inherits the previous agent's session, transcript, or pending outbound card.

Co-authored-by: mxbao063-bao <[email protected]>
2026-08-13 14:09:25 +08:00
d467cc9069 MUL-6075: complete Plugin V1 execution chain (#6853)
* feat(server): add Plugin V1 lifecycle foundation

Co-authored-by: multica-agent <[email protected]>

* feat(server): complete Plugin V1 execution chain

Co-authored-by: multica-agent <[email protected]>

* feat(web): add temporary plugin settings demo

Co-authored-by: multica-agent <[email protected]>

* refactor(plugin): keep foundation free of demo content

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 02:29:49 +08:00
leroy-chenandseacen 040bb97b0f feat(wecom): WeCom (企业微信) smart-bot integration via the Channel engine (MUL-5226) (#5833)
Adds the WeCom (企业微信) smart-bot integration built on the unified Channel engine — inbound chat, /issue, inbox notifications, install/revoke UI and an explicit user-binding flow.

Co-authored-by: seacen <[email protected]>

Follow-up: #6547
2026-08-07 11:02:13 +08:00
3c9b861de3 MUL-5765 feat(chat): starter cards under Mika's onboarding opening (#6505)
* MUL-5765 feat(chat): starter cards under Mika's onboarding opening

Replace the LLM quick-action chips on Mika's onboarding opening with three
product-fixed starter cards (board / delegate / digest), so a new member
never starts from a blank canvas. Clicking a card sends its fixed prompt as
a visible member message through the existing quick-action path; cards
follow the chips' disabled rule while a task runs and stay clickable in
history. Older clients keep the LLM chips untouched.

The multica-onboarding skill gains matching starter plays (one-question
budget each) and a digest-only exception to the no-autopilot rule.

Co-authored-by: multica-agent <[email protected]>
(cherry picked from commit 32364cea89)

* MUL-5765 refactor(chat): drop pre-release compatibility around starter cards

The onboarding flow has not shipped, so no old client exists to protect
(per review): the server now skips the quick-actions pass for the opening
turn (new TaskHasOnboardingKickoffInput gate) instead of generating chips
the new client would hide, and the multica-onboarding opening points at the
cards directly — beat 4 is the mock's bridge line again.

Co-authored-by: multica-agent <[email protected]>
(cherry picked from commit 26e5242d0b)

* MUL-5765 fix(chat): stamp the onboarding opening so starter cards actually render

The cards never showed in the field: the client detected the opening by
finding the hidden kickoff row, but visibleChatMessages strips that row from
list responses — and the client zod schema coerced unknown message kinds to
'message' anyway, so the signal died twice before reaching the detector.

The opening now self-describes: writeChatCompletionOutcome stamps the reply
to a kickoff-input task with message_kind 'onboarding_opening', the schema
and type unions carry the new kind, and the client keys the cards off it
directly — pagination-proof, and the hidden kickoff stays hidden. The
explicit quick-actions eligibility gate is gone: a non-'message' kind
already skips the pass at both call sites. The kickoff prompt also forbids
pre-reply narration ('I'll load the onboarding skill first.' leaked into the
field opening).

Co-authored-by: multica-agent <[email protected]>
(cherry picked from commit ffacd3d02c)

* MUL-5765 fix(chat): stamp the opening on the input owner, not the retry clone

The kickoff check asked TaskHasOnboardingKickoffInput about task.ID. An
auto-retry clone gets a fresh id while inheriting the root's
chat_input_task_id (MUL-4351), and the kickoff user row stays bound to the
root -- so the child answered false.

An opening that only succeeded after a retriable failure (provider_network,
runtime recovery) therefore persisted as a plain 'message': no starter cards,
and a chips pass generated for a turn whose copy invites the member to pick
one of the cards that never rendered.

Keyed on chatInputOwnerID now, which exists for exactly this provenance
question. The query doc states that its argument is the input-owning id, so
the next caller does not reintroduce task.ID.

The regression test drives the real completion write with
child.ID != child.ChatInputTaskID; it fails on the old call site with
kind "message".

Co-authored-by: multica-agent <[email protected]>

* MUL-5765 fix(onboarding): give the digest schedule a timezone to name

The digest starter play proposed "every morning at 09:00" while the kickoff
profile carried only workspace name, role and use case. `autopilot trigger-add`
defaults an absent --timezone to UTC (DefaultAutopilotTriggerTimezone, and the
flag's own help), so a member outside UTC could confirm a morning summary and
receive an afternoon one -- a wrong assumption that recurs daily.

The member's IANA timezone now travels in the profile block, stated as a value
either way and emitted before the skipped-questionnaire early return: the
digest card is clickable whether or not the questionnaire was answered, and
"unknown" is the case the skill has to handle rather than paper over. What to
do with it lives in the skill, since that block declares itself data and never
a command.

The digest play must now quote the whole time ("09:00 Asia/Shanghai") and pass
--timezone; when the zone is unknown it spends the one allowed question there
instead of falling back to UTC silently.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 16:54:45 +08:00
YYClaw c3577cb04b feat(dingtalk): add DingTalk bot integration (MUL-3958) (#4829)
Adds a DingTalk (钉钉) bot integration on the bring-your-own-app model: a
workspace admin creates their own Stream-mode robot and pastes its AppKey /
AppSecret, so no public webhook or OAuth redirect is required. Each agent gets
its own bot identity, so several agents can be distinct, separately
@-mentionable contacts in one DingTalk organization.

Supports DMs, @-mentions in groups, inbound images, /issue quick-create, and
/new. Built on the shared channel engine (ForceFresh/BareFresh, MediaResolver /
MediaRef and the intent ledger) rather than a private implementation.

Off unless MULTICA_DINGTALK_SECRET_KEY is set. Docs in en/zh/ja/ko.

Closes #4791.

Community-maintained: @yyclaw is the code owner for
server/internal/integrations/dingtalk/.
2026-08-06 15:36:02 +08:00
b99b04bb86 feat(onboarding): Mika issue-first onboarding (#6378)
* feat(onboarding): Mika issue-first onboarding

Replaces the starter-agent welcome with one conversation: onboarding creates
Mika, the workspace's built-in Chief of Staff, and opens a real chat whose
first turn is a product-authored kickoff hidden from the transcript. Every
workspace — first and subsequent — is created through this flow.

Mika is a system agent, not an agent-template instance. Her product prompt is
//go:embed-ed and composed at claim time, so a release updates it without
touching any workspace's row; the row holds only the workspace's own notes.
Creation is server-owned and idempotent under a per-workspace advisory lock,
and archiving a system agent is rejected.

This is the pre-merge half of the branch, squashed while rebasing onto main.
Replaying its fifteen commits individually meant re-deriving each one against
a main they were never written for; the net change reconciles against today's
main in four files, so it is reconciled once, here.

Three of those four are main moving under the branch: MUL-5573 took
quick-actions generation server-side and dropped QuickActionsDisabled /
RegenerateQuickActionsFor from the task payload and the SendDirectChatMessage
signature, so this takes main's shape and keeps only the onboarding entry
point. The fourth keeps main's OnboardingLogoutButton wrapper around the
flow's new mode/onCancel props.

Co-authored-by: multica-agent <[email protected]>

* feat(onboarding): let quick-action chips carry the opening's examples

Chat now renders agent-suggested follow-up actions as buttons under a reply
(MUL-5149), and the onboarding kickoff qualifies for that suggestion pass
with no extra wiring — it is a direct chat turn on a web session with a
non-empty reply.

That made the opening's fourth beat redundant: Mika wrote a three-to-five
line menu of example tasks, then three chips appeared underneath offering
the same thing. The prose menu is the worse half — a member has to retype a
line they read, but can send a button — so the beat is gone and the opening
budget drops with it. Measured on a real local run: the first reply went
from 307 to 208 characters, and the chips still arrived 21s after the
kickoff.

The questionnaire profile stays in the kickoff. The suggestion pass resumes
the same provider session, so the profile now steers the chips as well as
the reply; only the sentence naming its purpose changes.

Co-authored-by: multica-agent <[email protected]>

* refactor(onboarding): cut the step rails down to what the screen can't already show

The right rail carried more than twice the words of the column members were
actually working in — 42 vs 36 on the workspace step, 91 vs 34 on the runtime
step — and it got heavier the further in the flow went, which is backwards.

Step 3's rail was the clearest case. Its "Good to know" section promised the
runtime was swappable and that more could be added later; the step's own lede
already said both in nine words rather than forty-two, on the same screen. Its
60-word definition of "agent runtime" sat beside a list of named, online
runtimes under a headline reading "This computer is connected" — by then it
answers a question the member has stopped asking. What survives is the one
thing the screen does not show: what that background process is.

Step 2's rail keeps the workspace preview card, which does show something not
otherwise visible, and drops the bullet lists — promises the product is about
to keep on its own.

The freed words did not move to the rail; one moved into the main column. On
the create path "Mika" was never introduced before Step 3 used the name twice,
once on the primary button, so the lede now names the role in an appositive
right above that button. Mika stays ungendered, as everywhere else in the
product.

Net across the three regions: 167 words to 89.

Co-authored-by: multica-agent <[email protected]>

* refactor(onboarding): drop the right rail from the workspace and runtime steps

Every remaining rail item was either something the screen already showed or
something the product was about to do anyway, so the column was costing a
member's attention without answering a question they had. Removing it leaves
each step a single full-width column — the shape the questionnaire step has
always had, and the only step nobody has complained reads as sparse.

Gone with it: RuntimeAsidePanel, the workspace preview card and its entity
rows, and 27 copy keys per locale. The two runtime paths (desktop
runtime-connect, web platform-fork) shared that panel, so both lose it in one
move and stay identical.

The welcome step keeps its column — it holds an illustration, not prose.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): put every step's header and content on one measured axis

Removing the right rail left the four steps geometrically inconsistent in
four ways, all of which the member sees as things moving while they advance.

The header carried the horizontal padding itself, so it ran flush to the
window edge while the content column stayed centred. A 480px rail had been
absorbing that difference; without it the two were 267px apart on a 1283px
window, and since StepHeader is justify-between the step indicator floated
off at the far right, ~270px from anything it labelled.

The rest compounded it: the measure changed between steps (920px on the
questionnaire, 620px after), the header bar and content block used different
vertical padding per step, and padding living inside a max-w box made the
reading width jump from 508px to 620px at the lg breakpoint.

All four now come from step-shell.tsx. Padding belongs to the gutter, never
to a measured box, so the reading width is constant from ~700px up. The
header measures on STEP_FRAME on every step, so the one element that survives
each transition never moves; content picks STEP_FRAME or STEP_COLUMN by what
it holds, and both centre, so a step that needs the width still sits on the
header's centreline. Vertical rhythm is one value.

The header was near-identical in four files, which is how it drifted in the
first place, so it is now one component. Its test pins the invariant that
broke — padding out of the measured box, header measured on the frame — and
fails if either is put back.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): give the runtime step the frame measure and cut its copy

Nine runtimes in a 620px column meant truncated names, five rows, and
scrolling to reach the last four, with ~330px of dead space on each side.
This is the case step-shell's wider measure exists for, so the step takes
STEP_FRAME — which also puts it on exactly the header's measure — and the
card grid gains a third column at lg. Nine runtimes now land in three rows.

Copy went with it. The headline was two sentences over two lines; the first
one, "This computer is connected", is already said louder by the "9 agent
runtimes · all online" row directly beneath it, so only the instruction
remains. The lede was 44 words and five lines — I had grown it myself adding
Mika's introduction — and is 20 now, still naming the role. The
remote-computer note drops from 25 words to 15.

Prose stays capped at 620px inside the wider frame; a 920px measure is for
the card grid, not for reading.

The found-phase test keyed on the headline copy, so a copy edit read as a
behaviour regression. It now asserts the runtime count row, which is the
signal the test is actually about.

Co-authored-by: multica-agent <[email protected]>

* refactor(onboarding): put the workspace step on the shared frame

It was the only step still measuring at STEP_COLUMN, so its eyebrow, headline
and footer CTA started and ended ~150px inside where every other step's did —
the page margins visibly moved when you advanced from step 1 to step 2.

The step now sits on STEP_FRAME like the other two, with a new STEP_MEASURE
capping the prose and the form inside it. Matching the frame is not the same
as widening the field: a workspace name does not want an 800px input, so the
form keeps its reading measure and left-aligns to the frame instead. The
footer row spans the frame, which is what puts the CTA in the same place on
all three screens.

STEP_MEASURE deliberately does not centre — centring would pull the content
off the frame's left edge, undoing the alignment. Its test pins that.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): close the gap above the step CTA and put Log out back on one row

Two defects that read as one thing on screen: a large empty block sitting
directly above the primary button.

The card list was capped at STEP_MEASURE along with the form. That cap exists
so a workspace name does not get an 800px input — a good reason for a text
field and a bad one for selection cards, which are a list like the runtime
grid. Capped, they stopped 299px short of the CTA, leaving a void above the
button. The cards take the frame now; only the form keeps the reading measure.

Log out came in from main as `fixed right-8 top-8`, pinned to the window
corner. Its own comment says the fixed position exists to survive the flow's
full-bleed layouts — which is what the measured frame replaced, so it landed
outside the measure and above Back / Step N of N as a second header row. It
now rides the header row on the frame.

StepShellHeader takes it as a `trailing` slot rather than rendering it:
calling useLogout inside the shared header forced a QueryClient into five
step test files just to render a header bar. The flow injects it, matching
how runtimeInstructions is already threaded, and the header stays
presentational.

Co-authored-by: multica-agent <[email protected]>

* feat(onboarding): make step 3 about Mika, with the runtime as a sub-decision

The step was titled after its dependency — "Pick an agent runtime" — while
the thing actually being created was named only in the grey lede. A member
reached a button reading "Start with Mika" without having been told who that
is, and said so: being confused there is not a failure to read carefully, it
is the page putting the lead role in a footnote.

So the subject changes rather than the step count. The headline names the
outcome, a card carries the introduction with a mark on it, and the runtime
list drops to a labelled sub-section under "Where should Mika run?". Reading
order becomes: who you are getting, she needs a machine, pick one, start.

MikaIntro sits above the phase switch so the subject holds still while the
runtime block below cycles through scanning / found / empty, and each phase's
own heading drops from h1 to h2 now that the page has a real h1.

Mika does not exist yet at this point — she is created on commit — so the
card cannot render her stored avatar. It reuses the mark the Runtimes page
already uses for "Start with Mika", so the two entry points read as the same
thing.

No fourth screen: the introduction and the only decision on this screen are
one beat, and splitting them would add a step between finishing setup and the
payoff. The defect was never a missing screen, it was an invisible
introduction.

Co-authored-by: multica-agent <[email protected]>

* feat(onboarding): pick the runtime and the model from dropdowns

Nine runtimes as cards took three rows and left nowhere to put a second
decision. Two dropdowns fit both on one screen, and the model is a real
choice the card grid had no room for.

Both controls already existed on the agents surface — RuntimePicker and
ModelDropdown, the same pair create-agent-dialog uses — so this reuses them
rather than growing a parallel picker. ModelDropdown owns its own discovery,
grouping and unsupported-runtime states, and selecting a different runtime
clears the model because models are per-runtime.

The model now reaches the agent: POST /api/agents/mika takes an optional
model, CreateSystemUserAgent writes it to the column the agent table already
had, and empty still means "whatever the runtime defaults to" — which is what
every deployment without per-agent model support gets anyway.

Also drops the "No local runtime, or prefer a remote computer?" note. It said
in twenty-five words what the Skip button next to it says by existing, and it
appeared on all three phases.

currentUserId comes in as a prop rather than from the auth store: reading the
store inside the step broke six tests that render it without one, the same
coupling the header slot avoided.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): stop gendering Mika in the intro card

Mika is ungendered everywhere else in the product — the agent instructions,
the onboarding skill and every other locale string avoid a pronoun. The intro
card I added last round reintroduced one in two languages ("she turns it into
an issue" / "她会把它变成一个 issue"), which the Chinese screenshot made
obvious. All four locales now read around it.

Co-authored-by: multica-agent <[email protected]>

* feat(ui): port the ReUI stepper primitive

Groundwork for rebuilding onboarding on the @reui/onboarding-3 interaction
model: a persistent vertical stepper with named steps and click-to-return
navigation, which our horizontal "Step N of 3" dots cannot express.

Routed to components/ui rather than the vendor's components/reui namespace —
it is our code now — and rewritten to the role-named type scale (text-xs ->
text-caption, text-sm -> text-label / text-caption, dropping the leading-none
the token already supplies). No other convention fixes were needed: shadcn had
already rewritten the imports, "use client" survived because main's
components.json now sets rsc: true, and it pulls no npm dependency we do not
already have (@base-ui/react is declared).

The block's other twelve registry dependencies are primitives we already own,
so only this one was installed.

Co-authored-by: multica-agent <[email protected]>

* feat(onboarding): rebuild the step shell as a named progress rail

Onboarding's chrome was a row of dots and a "Step 2 of 3" counter. It told
a member how much was left but never what was coming, so every step arrived
unannounced -- the same reason the runtime step read as a surprise even after
it was retitled "Meet Mika". Naming all three steps up front makes onboarding
legible as a whole before the first field is filled in.

StepShellHeader becomes StepShell, and it owns the window rather than a strip
at the top: rail on the left, the step's own content scrolling on the right.
That lets the four steps drop an identical wrapper/DragStrip/header/<main>
preamble, including the scroll-fade wiring that was duplicated verbatim in
all four and is now set up once.

The rail is built on the ported ReUI stepper, but deliberately not as a
tablist: that component defaults to role=tablist with each trigger owning
aria-controls on a panel id, which is right for a stepper that renders its
own panels and wrong here, where the panel is a routed step and those ids
would dangle. It uses the presentational slots and marks position with
aria-current instead.

Only completed steps are clickable -- moving forward has to run the current
step's validation and submit, so the rail would skip it. New-workspace mode
gets no rail navigation at all: it enters at the workspace step and, once
that workspace exists, every step behind it is gone. Same invariant
runtimeStepBack already enforced for the Back button.

step_header.step_of is replaced by step_nav across all four locales; Mika
stays ungendered in each.

Co-authored-by: multica-agent <[email protected]>

* feat(onboarding): standardise the steps on the shared UI primitives

Follow-up to the rail, fixing what the rail exposed.

The workspace form was three hand-rolled `flex flex-col gap-1.5` stacks with
their own label sizing and a bare <p> for the slug error -- exactly what
Field/FieldLabel/FieldError/FieldDescription standardise. The manual version
had already drifted: its labels were caption-sized and muted while every
other form in the product labels at body weight.

The platform fork wrapped on STEP_COLUMN while the steps before it wrapped
on STEP_FRAME. Both measures centre, so 620px and 920px put their left edges
~150px apart and the headline visibly jumped right on arrival. It now sits on
the frame and caps its own content, which is the pattern step-shell already
documents.

The About you eyebrow read "About you" -- now the rail's label for that very
step, so the page said its own name twice, once in grey caps and once in the
headline under it. Dropped, along with the locale key. The other steps keep
theirs because they say something the rail doesn't ("Connect a computer",
"Workspace creation is disabled").

Log out is `inline` on every step, and inline now means "on the rail", which
is an inverted surface -- the muted/destructive pair it used unqualified is
mixed from the light palette, so it was rendering as near-invisible grey on
black.

e2e: the smoke spec asserted "Step 1 of 3", text the rail replaced. It now
asserts the rail's named steps and which one is aria-current, and carries on
into the runtime step so all three get captured. Two pre-existing bugs in
that spec surfaced while fixing it: the zh-Hans case pinned its locale cookie
to a hardcoded port, and it advanced with getByRole("button").first(), which
is the pinned Log out button -- so that case had been signing the user out
and asserting against a login redirect.

Co-authored-by: multica-agent <[email protected]>

* feat(onboarding): rebuild the rail as the ReUI inset panel

The first pass kept the block's idea -- a named vertical rail -- and dropped
most of its structure. Side by side with onboarding-3 the gap was obvious:
no brand lockup, no inset panel, no texture, steps jammed against the top
edge, numbered chips instead of the ring/check/dot progression, stub
separators instead of one continuous track, and a bare Log out where the
block has a footer row.

This ports the structure properly:

- Inset panel -- the aside carries the padding and the panel is rounded with
  a hairline ring, instead of a dark rectangle bleeding to the window edge.
- Brand lockup top-left (the existing MulticaIcon plus a wordmark), Back
  demoted to an icon button top-right, which is where the block puts it.
- Step list centred in the remaining height rather than stacked under Back.
- Indicators follow the block: filled + check when done, ring + dot when
  current, faint ring when upcoming. Numbers move to sr-only text, since the
  ring already encodes position and the digit was redundant next to a label.
- One continuous hairline behind each row instead of a stub between rows.
- DotSphere ported to packages/ui as the panel's texture. It is decorative,
  reads no product state, and already honours prefers-reduced-motion.

Two fixes fell out of doing it properly:

`.dark` is a plain class selector in our token sheet, so scoping it to the
panel redefines the custom properties for that subtree and `bg-background` /
`text-muted-foreground` mean the right thing inside it. That replaces the
hand-mixed `text-background/60` shades of the first pass -- which is what had
made Log out near-invisible -- and it is why the button is back on ordinary
tokens here.

StepperSeparator hardcodes a 3rem height for vertical navs, so an absolutely
positioned track overshot its row and drew the line straight through the next
indicator. Overridden with the same variant rather than !important, so
tailwind-merge drops theirs.

DotSphere cycles three constant arrays by `index % length`. Under
noUncheckedIndexedAccess that is `T | undefined`, so they are typed as
non-empty tuples and index 0 is the fallback -- no non-null assertions.

Co-authored-by: multica-agent <[email protected]>

* feat(onboarding): put the content pane on the block's type and layout

The rail matched onboarding-3 and the pane it sat next to did not, so the
two halves read as different products: serif display headlines and a grey
uppercase eyebrow on the right, the block's sans hierarchy on the left.

Typography now maps onto the block exactly, and it lands on our scale
without rounding -- ReUI's `text-xl/7` heading is our `text-title-lg`
(20/28) and its `text-sm/5` supporting line is our `text-body` (14/20).
StepHeading owns both, so no step hand-rolls a headline again.

The eyebrows are gone. The block has no such slot, and with the rail naming
every step, a grey uppercase label above a headline saying the same thing
was the third name for one screen. The workspace step's disabled-state
wording was the only eyebrow carrying information the headline lacked, and
that variant already exists as its own headline copy.

Geometry collapses from three competing measures -- a 920px frame, a 620px
column, an in-frame cap -- to one 28rem column. Three measures is what let
the platform fork sit ~150px right of every other step. STEP_MEASURE stays
for capping a single control inside the column.

Actions move into StepFooter: full-width, stacked, pinned to the bottom of
the column. In a 28rem column the old right-aligned inline bar left the
primary action floating mid-screen instead of where the eye finishes the
form. The column is `min-h-full` rather than centred by the pane, because
`items-center` on a scroll container clips the top of anything taller than
the viewport and these steps do overflow on short windows.

Questionnaire options become the block's wrapping chips. They were
full-width cards in a 4-column grid; inside a 28rem column that grid had
nowhere to go, and stacking all 18 as rows turned one screen into a long
scroll. Chips are the block's own answer for a many-option question. This
also reaches the workspace source-backfill prompt, which shares the
component -- intentionally, it is the same question in the same style.

MikaIntro moves onto StepHeading + Item for the same reason.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): drop the rail clear of the macOS traffic lights

The rail is a dark inset panel and it started at the window's top-left
corner, which is exactly where macOS draws the traffic lights -- so the
close/minimise/zoom buttons sat on top of the dark surface.

The underlying mistake was structural. The desktop shell rule asks for a
single DragStrip as the first flex child of a full-window view; this had two
hand-rolled strips instead, one inside each pane, and neither was first. The
sidebar's was 28px of internal padding trying to duck under the traffic
lights from inside a panel that had already begun above them, which cannot
work -- the panel's own background was the thing being overlapped.

One DragStrip now spans the window above both panes. The panel starts below
it (48px strip + the aside's inset, measured at 64px on a wide window against
traffic lights that end around 32px), the whole band stays draggable, and the
two ad-hoc strips are gone.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): make every block in the column share one width

Reported on the workspace step: the name field ended short of the
description above it. Measured rather than eyeballed -- the heading,
description and footer all render at 448px, the form at 384px, because the
FieldGroup still carried STEP_MEASURE.

STEP_MEASURE made sense against the old 920px frame, where a full-width
input would have been absurd. Against a 28rem column it does nothing but
misalign, so both remaining uses are gone -- the workspace form and the
runtime picker -- and the constant with them. A single measure that no step
can locally narrow is the whole point of the column; leaving the knob
exported invites the same drift back.

Two stale measures went with it. The runtime phase views still capped their
ledes at max-w-[620px], inert inside a 448px column, and sized them
text-body-lg against StepHeading's text-body; their h2 was text-title-lg,
the same size as the h1 above it. Both now match the shared scale.

Guarded in e2e rather than a unit test. The shell test renders a stub child,
so asserting "no narrow cap inside the column" there would pass whatever the
real steps do. The new spec walks all three steps and compares rendered
geometry -- it fails on the reported bug and passes after the fix, which is
what makes it worth keeping.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): stop the whole window re-fading on every step change

Every step rendered its own <StepShell>. Because each step is a different
component type, React tore the shell down and built a new one on each
transition: the "persistent" rail remounted, its canvas restarted, and the
shell replayed `animate-onboarding-enter` -- a 0.4s fade from opacity 0
across the entire window. That full-window re-fade is the flash.

Measured before changing anything: tagging the live <aside> and switching
steps showed the attribute gone, and the shell root still reporting
animation-name `onboarding-enter` after the switch.

The upstream block has no step-change motion at all -- no animate-*, no
transitions, no framer-motion, no AnimatePresence. Its sidebar and section
live in one component and only the step body swaps. This does the same: the
flow owns a single StepShell and the steps render content. The shell's
entrance fade now runs once, on entering onboarding, which is what it was
for.

`backDisabled` was the one thing blocking the hoist -- the shell needs it but
only the workspace step knows the create request is in flight. It reports
upward through `onBusyChange`, with an unmount cleanup: a successful create
advances immediately, so without clearing the flag the next step would open
with Back and the rail dead.

Guarded in e2e by tagging the rail and content nodes and asserting they
survive a step change, plus a single DotSphere canvas rather than one per
visited step.

Co-authored-by: multica-agent <[email protected]>

* chore: drop a stray commit-message file

Co-authored-by: multica-agent <[email protected]>

* i18n(zh): retranslate onboarding and fix cross-file inconsistencies

Reported as "生硬" on the onboarding Chinese. Reviewed all 217 onboarding
strings against the Chinese voice guide in conventions.mdx, then swept the
other 24 locale files for the same classes of problem.

Three were mistranslations, not stiffness. The worst: an agent runtime was
described as the AI coding tool "我们接管的" — take over — where the English
says "we connect to". Also a "推荐" that exists in no source string, and
"12 calls" (user interviews) rendered as "12 通电话".

Punctuation, decided from the repo's own zh docs rather than guessed:
「」 is forbidden by the guide and appeared 16 times; 破折号 spacing was
split 26 spaced vs the rest unspaced, and the docs run 152 unspaced to 35,
so unspaced wins. Four strings had a stray space inside Chinese text
("正在跳转到 工作区").

67 English strings had two or more Chinese translations. Most are
legitimate — weekday pickers use single characters where labels spell them
out, 飞书/Lark are deliberate regional variants, and "Name" is 姓名 for a
person and 名称 for an object. 42 were arbitrary and are now unified.

ja/ko got the same consistency pass on the clear-cut cases only, kept
parallel with the zh choices. Their punctuation was deliberately left alone:
「」 is standard Japanese quoting, so the zh rule does not transfer.

Edits are applied to the raw file text rather than through a JSON
round-trip, which reformatted compact one-line objects and turned ~20 real
edits into a 97-line diff.

Co-authored-by: multica-agent <[email protected]>

* chore: drop a stray commit-message file

Co-authored-by: multica-agent <[email protected]>

* feat(mika): use the unicorn emoji as Mika's placeholder avatar

Mika shipped with a hand-rolled data-URI SVG — a sparkle glyph on a dark
rounded square — which only that one constant knew how to produce. Agents
already have an emoji avatar convention (`emoji:` marker in avatar_url,
owned by agentEmojiAvatarPrefix on the server and parseAvatarEmoji on the
client), so this reuses it instead: ActorAvatar renders the emoji as text and
no surface needs to special-case her.

The two cards that stand in for Mika before the agent row exists move with
it — the onboarding intro card and the Runtimes "Start with Mika" card. Both
were drawing the same sparkle mark, and leaving them would mean a member sees
one face during onboarding and a different one the moment Mika is created.
They now share MIKA_PLACEHOLDER_EMOJI, which carries a pointer to the server
constant so the two cannot drift apart silently.

The dark square went with the sparkle: it was built to frame a white line-art
glyph, and an emoji on it reads badly. These use bg-muted, matching how
ActorAvatar already frames an emoji avatar.

Placeholder until Mika has real artwork.

Co-authored-by: multica-agent <[email protected]>

* i18n: stop the skip path promising things it does not do

Two claims in the runtime-skip copy that the code does not back, both found
while walking the flow end to end.

"Enter your workspace in read-only mode" — there is no workspace read-only
concept on the server. The only READ ONLY in handlers is Postgres transaction
isolation on the issue-table and search queries. What actually exists is
admission.go's ReasonAgentRuntimeRequired, which blocks dispatch when no
runtime is connected. So the sentence's second half was already true and
enforced; the first half promised a restriction that does not exist — a
member who skips can create issues, comment, edit fields and invite people
exactly as normal. Same over-claim in cloud_waitlist.intro_warning.

"We've added one task" — the skip path creates an *issue* (it lands on the
Issues board as NOR-1), and task/issue are distinct entities in this product.
Now says issue.

All four locales.

Co-authored-by: multica-agent <[email protected]>

* i18n: reframe Mika as your first agent teammate

Requested copy change on the step 3 headline.

The rail's description for that step moves with it. It is the step's own
subtitle and sits on the same screen as the headline, so leaving it saying
"Your Chief of Staff" would have put two different framings of Mika side by
side on one page.

Three "Chief of Staff" references are deliberately untouched, because
dropping the title everywhere is a positioning call rather than a copy fix:
the role chip on the intro card (name + title reads fine under the new
headline), and the web fork's lede, which is a different screen.

Chinese follows the glossary (Agent -> 智能体) rather than the mixed-language
phrasing in the request.

Co-authored-by: multica-agent <[email protected]>

* fix(runtimes): let the member pick the runtime before Mika is created

"Start with Mika" provisioned immediately on
`runtimes.find(online) ?? runtimes[0]`. One machine commonly registers every
agent CLI installed on it — nine on the box this was reported from — so "the
first online one" is an arbitrary pick, and Mika could end up bound to a CLI
the member never intended to run their Chief of Staff on. Rebinding after the
fact is more work than choosing up front.

The action now opens a dialog with the same two controls onboarding already
uses for this decision, RuntimePicker and ModelDropdown, so the same choice
reached from a different entry point is asked the same way. The old heuristic
survives only as the dialog's initial selection, which makes it visible and
changeable instead of silent.

Model resets when the runtime changes, because models are per-runtime and a
value picked for the previous one may not exist on the next.

Co-authored-by: multica-agent <[email protected]>

* refactor(runtimes): one component for "which runtime should Mika use"

Three surfaces asked this same question and had drifted apart: desktop
onboarding and the Runtimes page offered a runtime plus a model, while the
web CLI dialog offered only a runtime. `step-platform-fork` called
`onNext(picker.selected)` with no second argument, so connecting through the
web terminal path silently created Mika on whatever model the runtime
defaulted to, while the desktop step let you choose. Two of the three also
re-implemented "changing the runtime clears the model"; the third simply
lacked it.

MikaRuntimeChoice now owns the pair and that reset rule, so no caller can
forget it and the three entry points cannot drift again. The web CLI path
gains model selection, which is the behaviour change here.

`layout` is a prop rather than a single unified presentation because the
difference is real: the CLI dialog lists machines because that is the moment
they appear one at a time after `multica setup`, and a collapsed dropdown
hides exactly the feedback that dialog exists to give. Everything below the
list is identical.

compact-runtime-row moves from onboarding/ to runtimes/ so imports only flow
onboarding -> runtimes rather than both ways.

Creation is deliberately left alone. A and B still funnel through
`handleRuntimeNext`, which also runs saveQuestionnaire, completeOnboarding
and onComplete; the Runtimes page must not do any of that, since that member
is already onboarded. Merging those would leak onboarding completion into a
non-onboarding surface.

The platform-fork test now needs a QueryClientProvider, because the dialog
renders a model dropdown that queries the runtime's model list, and its
onNext assertion moves to the two-argument signature.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): stop the download card claiming a result it cannot know

Clicking "Use this computer" set `downloaded` unconditionally, which flipped
the card to "Opening the download page..." / "Opened in a new tab." Neither
is knowable: `window.open` is called with `noopener`, and per spec that
returns null whether the tab opened or a popup blocker ate it, so a blocked
click still produced a card asserting a tab had opened.

The same flag was also write-once, so the transient "Opening..." became a
terminal state — come back to the tab later and it still says the page is
opening. And because the swapped title wraps to two lines, the card grew
10px and pushed the two cards under it down, which is what made the click
read as a page refresh in the first report.

The card now states its intent up front — "Opens in a new tab — pick your
platform there" — which is true before the click, after it, and when the
popup never appears. The state, both `_after` strings and `hint_downloaded`
are gone from all four locales.

Its test asserted the flip, so it now asserts the opposite: the mocked
window.open returns null (the blocked case) and the card must be unchanged.
Measured after the change: primary card 330px before and after, and the
cards below it do not move.

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): stop one worktree in a thousand booting into a blank window (#6436)

Worktree renderer ports are `5174 + cksum(path) % 1000`, a 5174-6173 window
that contains exactly one port Chromium refuses to navigate to: 6000, the X11
port on its restricted list. A worktree whose path hashes to offset 826 gets a
healthy Vite server on 6000 and an Electron window that fails the load with
ERR_UNSAFE_PORT -- so it reads as a renderer bug, not a port one, and the only
way out was setting DESKTOP_RENDERER_PORT by hand.

Restricted ports in the window are now remapped into the block immediately
above it (6000 -> 6174). Sending them past the end rather than shifting them by
one keeps the offset -> port mapping injective, so two worktrees still cannot
land on the same port and race for it.

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): close the Mika multi-member and failure-recovery gaps

From Emacs's review of #6378. All seven findings reproduced.

The two blocking server bugs were the same shape: Mika is one agent per
workspace but sessions and ownership are per member.

- StartMikaOnboarding required the agent owner, so the member who lost
  the CreateMikaAgent race — the race that handler's advisory lock exists
  to survive — got a valid Mika, opened a valid session, and then a 403.
  Mika is created workspace-visible and workspace-invocable; the session
  gate and canInvokeAgent were already the checks that matter.
- The onboarding session was resolved client-side by listing sessions and
  creating one on a miss, matched on the localized title.
  LockWorkspaceForChatSessionCreate is FOR KEY SHARE precisely so
  concurrent creators do not block, so two tabs each opened their own
  conversation with its own kickoff, and switching language between a
  failed attempt and its retry opened another. It is now get-or-create
  server-side under a per-(workspace, member) advisory lock, keyed on
  (workspace, creator, agent), returned alongside the agent.

Also:

- The skipped-runtime welcome dismissed itself silently when provisioning
  the guide issue failed. The signal is not persisted and onboarding is
  already complete, so a blip was terminal. It now offers a retry.
- The Runtimes recovery card gated on `agents.length === 0`, so creating
  any ordinary agent hid the only surface that can mint a Mika — the
  generic endpoint accepts no system_key. Gated on Mika's absence.
- CompactRuntimeRow ignored `disabled`; the CLI dialog was already
  passing it, so the runtime could change mid-submit. It is a real
  <button> now, which also gets focus and Enter/Space for free.
- The rail never went below 15rem while the content pane kept its gutter,
  leaving ~87px of form at 375px. It is hidden under md, where a compact
  bar carries the step name and the Back button instead.
- Dropped a stray __pycache__ artifact I had committed by accident.

Each new test was checked against the bug it covers: reinstating the
owner gate, the title-keyed lookup, or the dropped disabled prop makes
the corresponding test fail.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): make the Mika entrypoint survive a partial bootstrap

From Emacs's second review of #6378.

Bootstrapping Mika is three server steps — provision the agent, open the
member's session, enqueue the opening turn — and the last two can fail
after the agent commits. Two things then conspired: the server reported
success with the session omitted, and the Runtimes card gated on the
agent existing. The agent's own `agent:created` broadcast invalidates the
agent list, so the card (and its open dialog) was torn down the instant
step one succeeded, and never came back on reload — the agent is durable,
the rest was not. The member was left holding a Mika they could not start.

- The endpoint now fails when the session cannot be resolved. Every step
  is idempotent, so a retry converges; handing back a half-built flow the
  caller cannot distinguish from a finished one does not.
- The entrypoint is gated on the member's own state — does this member
  have a Mika conversation that was actually kicked off — rather than on
  the workspace having an agent. That is the question the card answers,
  and it is true again for every partial state above.

Also restores the Log out escape hatch below `md`. Hiding the rail last
round took its footer with it, which stranded every step but Welcome with
no way out on a narrow screen; the compact bar now renders the same slot,
so `sidebarFooter` is `chromeFooter`.

Each new test was checked against its bug: the old agent-only gate fails
three of the memberNeedsMikaSetup cases, and dropping the footer prop
fails the chrome test.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 14:21:02 +08:00
YYClaw 24d209794e fix(channel): attach /issue media to created issues (#6388) 2026-08-06 12:39:46 +08:00
4fe94a6d40 revert: "MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)" (#6314)
This reverts commit aa349fed02.

Preflight flagged the server/daemon mixed-version gate as a release blocker for
today's v0.4.17 window (MUL-5655): a managed, non-inheriting mcp_config claimed
by a daemon that does not advertise authoritative-mcp-v1 fails the task with
mcp_config_daemon_outdated, and that failure is not auto-retryable. Reverting to
unblock the release; the fix should return once daemon capability coverage in
production is confirmed.

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 17:43:03 +08:00
aa349fed02 MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)
* fix(mcp): treat agent mcp_config as an authoritative allowlist

An agent's saved mcp_config was silently widened with the runtime host's
own user-level MCP servers, so an explicitly empty `{"mcpServers":{}}`
resolved to the COMPLETE host set instead of no servers at all — the
opposite of what the operator configured (GitHub #6283).

`--strict-mcp-config` was being passed correctly; the merge happened
before it, in the daemon, so strict mode constrained an already-widened
set. Introduced by #5277 and present in v0.4.16 through main.

Restore the three-state contract in resolveEffectiveMcpConfig:

  null / unset        -> inherit the provider's native MCP configuration
  {"mcpServers":{}}   -> strict empty, no host servers
  non-empty object    -> strict allowlist, exactly those servers

Two explicit inherit paths keep the additive behaviour reachable without
weakening the default:

- runtime_config.mcp.inherit_runtime = true opts an agent back in.
- The claim response now carries mcp_config_overlay_only so the daemon
  can tell an agent-authored config from a per-task Composio overlay.
  Without it, enabling an integration on an agent that never configured
  MCP would have stripped the host servers it was already inheriting.

Both decode paths fail closed: malformed runtime_config never enables
inheritance, and a failed runtime merge falls back to the agent's own
config.

The web MCP tab and the `agent create/update --mcp-config` help text
described the old additive behaviour, which is how a tightened config
could look correct while exposing every host server; both now state
which mode is in effect.

Note for rollout: the fix lives in the daemon, so self-hosted users must
upgrade the daemon — a server/UI upgrade alone does not apply it.

Co-authored-by: multica-agent <[email protected]>

* fix(mcp): close review gaps in the authoritative mcp_config change

Addresses the four must-fix findings from review of #6292.

1. Deleting the last managed server no longer widens access.
   removeManagedMcpServer cleared the config to null, which now means
   "inherit the host's MCP servers" — so a delete took the agent from one
   allowed server to every server on the host. It now leaves an explicit
   `{"mcpServers":{}}`. Restoring inheritance moved to a separate
   clearManagedMcpConfig action behind its own confirmation that states the
   widening. The delete dialog no longer claims "Runtime servers are not
   affected", which was the opposite of the truth.

2. The UI no longer promises a boundary an old daemon does not enforce.
   The strict semantics live in the daemon, so a config saved against an
   older daemon is not yet in effect. Adds the authoritative-mcp-v1 daemon
   capability:

   - The daemon advertises it and reports authoritative_mcp on the
     runtime-capabilities response.
   - The claim path fails closed: a managed, non-inheriting mcp_config
     claimed by a daemon without the capability cancels the task and
     returns 412 with an actionable message, instead of letting that daemon
     merge the host's servers in. runtime_config.mcp.inherit_runtime is the
     documented escape hatch, and it is honest — it declares that the
     operator accepts the host's servers.
   - The MCP tab shows "needs upgrade" rather than "Not exposed" while the
     bound runtime lacks the capability.

3. Saving OpenClaw settings no longer drops the inherit opt-in.
   parseOpenclawRuntimeConfig discarded unknown keys and the tab persisted
   the result as the whole runtime_config, so one unrelated routing save
   silently deleted mcp.inherit_runtime. Unknown keys now round-trip
   through OpenclawRuntimeConfig.passthrough, excluded from the dirty check
   so they cannot make the form look edited.

4. Documents the new semantics in the built-in creating-agents skill and
   its source map: the three states, the persisted
   runtime_config.mcp.inherit_runtime field, and the claim-time capability
   gate.

Also corrects the PR's rollout claim: there is no database migration, but
this does add a persisted JSON field and change the meaning of an existing
one.

Co-authored-by: multica-agent <[email protected]>

* fix(mcp): stop the authoritative-daemon gate from blocking valid claims

CI's backend job failed three handler claim tests with the new 412. Two
distinct problems, both real:

1. The gate fired for a non-object mcp_config. 66 handler fixtures seed
   `[]`, which is not a valid MCP config and cannot carry `mcpServers`, so
   it expresses no boundary to protect. An old daemon does not widen it
   either: mergeRuntimeAndAgentMcpConfig fails to unmarshal a non-object
   and falls back to the agent config alone (verified directly). Gating
   these blocked tasks with no security benefit, so the gate now requires a
   JSON object.

2. The shared daemon test-request helper advertised no capabilities, so
   every claim test was accidentally simulating a pre-#6283 daemon. It now
   defaults authoritative-mcp-v1 on, matching what every current daemon
   sends. Only that capability — skill-bundles / coalesced-comments / rpc
   are feature negotiations whose absence tests real legacy behaviour, so
   they stay opt-in per test.

Adds claim-level coverage for the gate itself, which is what the unit tests
alone could not catch: an outdated daemon gets 412 with an actionable
message and the task is cancelled; a capability-advertising daemon gets
200; the inherit_runtime opt-in lets an outdated daemon through; and an
unmanaged or non-object config is never gated.

Verified against a real migrated schema this time (throwaway Postgres),
which is how the three failures were reproduced locally and confirmed
fixed: `go test ./internal/handler ./internal/daemon` both ok.

Co-authored-by: multica-agent <[email protected]>

* fix(mcp): surface the daemon-upgrade refusal and stop gating safe providers

Addresses the second review round on #6292.

1. The refusal is now visible wherever the operator looks. The default
   claim path is the machine-level BATCH endpoint, which skips build
   failures and still answers 200 {"tasks":[]}, so the previous bare
   CancelTask showed a task that vanished with no stated reason — turning an
   explicit upgrade requirement into an unexplained failure. The claim path
   now fails the task with a new classified reason,
   mcp_config_daemon_outdated, plus the actionable message. That reaches the
   user on all three claim paths and on any daemon version, which a new
   response field could not: the audience is by definition a daemon too old
   to read one. The per-runtime path keeps its 412.

   The reason is deliberately not auto-retryable — the same outdated daemon
   would claim the retry and fail it again.

2. The gate no longer cancels safe tasks. It applied to every provider, but
   only claude / codebuddy / codex / cursor / opencode / openclaw were ever
   merged with host MCP by an old daemon (loadRuntimeMcpServerConfigs).
   Qwen was never merged and already had strict semantics, so its tasks were
   being failed for a risk that does not exist. Scoped via
   providersOldDaemonsMergedRuntimeMcp; an unknown provider does not gate,
   because the gate should only fire where the old behaviour is concrete.

3. The new authoritative_mcp flag now goes through the API schema layer.
   Both local-skills responses were returning raw network JSON, so the flag
   that decides whether the UI may assert an MCP boundary rested on an
   unchecked type assertion. Adds RuntimeLocalSkillListRequestSchema with
   authoritative_mcp and mcp_supported defaulting to FALSE — the fail-closed
   direction — and a MALFORMED_ fallback that cannot express a guarantee.

Claim-level tests now cover all three paths, which is what the previous
helper-only tests missed: per-runtime 412, batch recording the refusal on
the task while still delivering the healthy tasks in the same batch, WS RPC
refusing and accepting, the qwen negative case, the inherit_runtime escape
hatch, and unmanaged / non-object configs.

Verified against a real migrated schema (throwaway Postgres):
go test ./internal/handler ./internal/daemon ./pkg/agent ./pkg/taskfailure
./internal/service all ok.

Co-authored-by: multica-agent <[email protected]>

* fix(mcp): register the new failure reason and wire its copy into the UI

Addresses the third review round on #6292.

1. mcp_config_daemon_outdated was declared but never registered in
   taskfailure.allReasons, so metrics.NormalizeFailureReason missed the
   known-value map and fell through to free-text Classify() — relabelling a
   platform-side refusal as `agent_error.unknown` (verified directly) and
   leaving the Prometheus series un-pre-warmed. Registered it, canonical
   count 22 → 23 (platform 8 → 9), with the wire value and IsAgentError split
   pinned. New test pins the WHOLE canonical set through
   NormalizeFailureReason so forgetting the next reason fails a test instead
   of quietly mislabelling a metric; NormalizeFailureReason had no coverage
   at all before.

2. The upgrade copy was dead. The locale strings landed last round but
   neither consumer mapped the reason: chatFailureCopy fell back to generic
   failure text with the actionable detail buried in the collapsed raw
   error, and task-failure.ts rendered the bare wire value
   `mcp_config_daemon_outdated` in the agent activity list and issue
   execution log. Both are mapped now, with regression tests, plus the
   runtime class pinned in failure-class.test.ts. This directly contradicted
   the claim in the claim-path comment that every path reaches the user, so
   that is now actually true.

3. providersOldDaemonsMergedRuntimeMcp is documented as what it is: a FROZEN
   record of what pre-capability daemons merged, not a mirror of the daemon's
   current provider switch. The old "keep the two lists in lockstep" note was
   actively harmful advice — runtime MCP discovery for a new provider can only
   ship in a daemon that already advertises the capability (never gated), so
   adding it here would fail tasks on old daemons that never merged for it,
   re-creating the qwen false-positive. Pinned with a test.

Also corrects a stale count in task-failure.ts (7 → 9 platform reasons).

Verified against a real migrated schema (throwaway Postgres): full backend
suite green apart from the pre-existing environmental cmd/multica guard; all
9 TestMcpGate_* integration tests pass.

Co-authored-by: multica-agent <[email protected]>

* docs(taskfailure): correct taxonomy counts and finish the reason registration

Non-blocking nits from the fourth review round on #6292.

- Taxonomy counts now say 23 reasons / 9 platform-side. Registering
  mcp_config_daemon_outdated last round updated the assertions but not the
  prose. Swept the whole repo rather than only the flagged lines, which
  turned up four more that were already stale at 21 and drifted further:
  handler/dashboard.go, daemon/poisoned.go, core/types/agent.ts, and the
  db/queries/task_usage.sql comment sqlc copies into the generated file.

  The generated file's comment was updated by hand to match its source.
  Running `sqlc generate` churned 58 lines across 47 unrelated files — the
  local sqlc version differs from the one that produced the checked-in
  output — so that churn was reverted and only the one intended line kept.

- failure_test.go's `required` list now includes
  ReasonMcpConfigDaemonOutdated. Length and label assertions already covered
  the reason, but the list is documented as the complete canonical set, so
  the omission contradicted its own comment.

- Restored the line break in chat-message-list.test.tsx that a previous edit
  of mine collapsed.

Comment, test-fixture and formatting only; no behaviour change.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 16:06:07 +08:00
f13969b996 refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573) (#6214)
* refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573)

Follow-up suggestions were produced by a second, full provider CLI invocation
per chat turn: the daemon resumed the just-finished session and ran a
suggestion-only pass. That pass inherited the main turn's exec options, so its
20s budget had to cover process spawn, every MCP handshake, session replay, and
model reasoning at the agent's own thinking level — typically 8-15s of visible
skeleton, and every turn paid two provider cold starts.

Generate them here instead, through the same pkg/llm layer that backs chat
auto-titling. Suggestions need no tools, workdir, or agent identity — only the
tail of the conversation — so a bounded 8s call on the deployment's small model
replaces the whole resumed turn.

Quality changes that came with the move:

  - The prompt now states the frame explicitly ("you write FOR THE USER"). The
    old pass ran inside the agent's session and inherited the runtime brief's
    identity, which drifted suggestions toward agent-operations actions.
  - Previously-offered labels are replayed as ALREADY SUGGESTED. The old
    architecture had the opposite effect: on providers that append on resume,
    each pass saw its predecessor's JSON and anchored on it.
  - A failed generation broadcasts failed=true. Before, a timeout delivered an
    empty array — indistinguishable from "nothing worth suggesting", so every
    slow pass read as a quality problem.
  - The in-band footer is still stripped from replies but its actions are now
    discarded, so a pre-upgrade session is not pinned to the retired
    suggestions with the replacing pass suppressed.

The refresh path no longer enqueues an agent task: it validates the target and
calls the same generator, which also drops the not-resumable refusal — a session
whose runtime was rebound can now be refreshed. Client contract is unchanged
(chat:done pending flag, chat:quick_actions supplement); the only frontend
change is the pending window, resized from 30s to 12s to match the new budget.

Also removes the daemon's TMPDIR-after-cleanup hazard by construction: the old
pass started after runTask's defers had already deleted the temp dir it was
still pointed at.

Co-authored-by: multica-agent <[email protected]>

* refactor(chat): drop the quick-actions opt-out setting (MUL-5573)

Suggestions are always on. The Settings → Chat toggle is removed along with
the whole per-turn opt-out path it fed: the persisted client preference, the
quick_actions_enabled send field, the quick_actions_disabled task stamp, and
the eligibility gate that read it.

The toggle predates server-side generation, when it could only hide chips a
provider pass had already paid for. Now that generation is a bounded call the
server decides on, an off switch buys nothing a user would miss, and it was
the last piece of UI implying the feature might be unavailable.

agent_task_queue.quick_actions_disabled is no longer written (dropped from
CreateChatTask's INSERT; the column keeps its false default). Left in place
alongside regenerate_quick_actions_for for a later drop migration — removing
columns an already-running binary still inserts would break mid-deploy.

Co-authored-by: multica-agent <[email protected]>

* fix(chat): address quick-actions review findings (MUL-5573)

Four defects from review of the server-side generation change.

1. Automatic failures were reported as refresh failures. The generator
   broadcast failed=true on any LLM error, but the client turns every
   failed=true into a "couldn't refresh" toast — so an automatic timeout
   popped a toast for an action the user never took. This also contradicted
   ChatQuickActionsPayload.Failed, which documents false for the automatic
   pass. The caller now passes its origin; only an explicit refresh reports.

2. Generation context was not bound to the target turn. The pass re-read the
   session's newest messages while always writing to the task it was handed,
   so a turn landing between the completion callback and the detached read
   supplied the context for a reply it did not belong to. Worse, a user
   typing a follow-up in the second after a reply left the window ending on
   a user row, which the old code treated as "nothing to build on" — that
   turn silently never got pills. The window is now anchored on the target
   assistant message and queried strictly before it.

3. No concurrency or idempotency bound on generation. Refresh stopped
   creating a task, so the busy check could not see a pass already running:
   two refreshes both returned 202, spent two upstream calls, and raced to
   write one row. Nothing bounded generation process-wide either. Adds a
   per-session in-flight guard (refresh now 409s on a duplicate) and a
   process-wide ceiling; a shed pass still resolves the client placeholder
   so no skeleton hangs on work that never started.

4. A new daemon could not safely talk to an older server. The refresh task
   discriminator was deleted, so a regenerate task from such a server fell
   through to the ordinary chat path: no user message, but the agent would
   answer anyway and the server would persist it as a real reply. The field
   is restored as a refusal marker only — the task completes empty, which is
   the shape the retired pass produced and which that server writes no row
   for. Not a restored execution path.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-31 15:59:50 +08:00
0fdc38704e MUL-5149: add agent-generated Chat quick actions (#5766)
* feat(chat): add agent-generated quick actions

Co-authored-by: multica-agent <[email protected]>

* fix(chat): preserve mid-response quick-action fences

Co-authored-by: multica-agent <[email protected]>

* fix(chat): drop quick actions on empty reply to keep no_response fallback

An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.

Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(chat): generate quick actions via daemon suggestion pass

Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(chat): deliver quick actions async with skeleton placeholders

Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(chat): make the quick-actions toggle stop generation, not hide pills

The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(migrations): renumber quick-action migrations onto current main

Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:

- 219_chat_message_quick_actions        -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled

Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(mobile): render async chat quick actions via chat:quick_actions

The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.

Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): cancel in-flight messages refetch before quick-actions patch

The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).

applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).

Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)

Co-authored-by: multica-agent <[email protected]>

* fix(chat): address quick-actions re-review (MUL-5149)

- Ack alignment: refresh request carries the target message_id; server
  atomically confirms it is still the session's latest turn (409 stale
  otherwise), so the client marker always matches the resolving
  chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
  (sweeper/orphan) now resolves it, and the daemon reports a failed supplement
  so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
  instead of a component-local flag that only masked the UI; drop the skeleton's
  and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.

Co-authored-by: multica-agent <[email protected]>

* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)

Third-round review of the refresh button surfaced three issues; all three
are addressed here.

§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.

§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.

§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.

Co-authored-by: multica-agent <[email protected]>

* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)

Two re-review blockers on 2dc9404d.

§1 (deferred window): HasActiveChatTaskForSession only treated
queued/dispatched/running/waiting_local_directory as in-flight, so a chat
auto-retry armed with a backoff fire_at — inserted 'deferred' by
CreateRetryTask, as provider_network's ~5s final attempt is — slipped past
the busy check. In that window the failed turn has no assistant row yet, so
the old turn is still latest-persisted and refreshable; the regen would then
resume a session the retry is about to advance and pin the new turn's
suggestions onto the old one. Add 'deferred' so the set matches the
canonical in-flight status list the rest of the queries already use
(agent.sql has-active-task checks). New regression test covers a deferred
active turn.

CI (text-contrast gate): the refresh icon button used
text-muted-foreground/70 (transparency standing in for a text tone), which
the frontend-test contrast gate rejects. Switch to the solid
text-faint-foreground token — the tone the gate recommends for icons/glyphs,
already used repo-wide and clearing WCAG 1.4.11.

Co-authored-by: multica-agent <[email protected]>

* test(chat): assert quick-actions pending marker carries expires_at (MUL-5149)

The chat:done supplement-flow test still expected the 2-field marker from
before the absolute-deadline change; applyChatDoneToCache now stamps
expires_at, so the deep-equal failed on frontend-test. Assert the deadline is
present (expect.any(Number)) rather than a wall-clock-dependent value — its
timing semantics are covered by the pending-timeout hook.

Co-authored-by: multica-agent <[email protected]>

* fix(db): renumber regenerate-quick-actions migration 237 -> 240 (MUL-5149)

main merged Issue Quick Actions (MUL-5465) taking migrations 237/238/239
(quick_action, quick_action_workspace_index, comment_quick_action). This
branch independently took 237 for agent_task_queue.regenerate_quick_actions_for.
The two 237s do not textually conflict (different filenames) so the PR reads
mergeable, but the merged tree would carry two migration 237s. Renumber this
one to 240 so it applies after main's chain. The migration is a standalone
`ALTER TABLE agent_task_queue ADD COLUMN IF NOT EXISTS` — order-independent,
touches a column none of main's migrations reference.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Walt <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Naiyuan Qing <[email protected]>
Co-authored-by: NevilleQingNY <[email protected]>
2026-07-30 21:25:03 +08:00
f0110da555 feat(inbox): mark a notification unread from the row context menu (MUL-5496) (#6137)
The inbox auto-marks a notification read the moment it is selected, so
"opened" and "handled" were the same signal — a row you glanced at and
meant to come back to was gone from the unread count with no way back.

Right-click any inbox row for a shared context menu: Mark as read /
Mark as unread, plus Archive (Unarchive in the archived view).

- POST /api/inbox/{id}/unread + MarkInboxUnread query, publishing
  inbox:unread. Item-scoped, mirroring mark-read: the list renders one
  row per issue carrying that group's newest item, so flipping the whole
  group would resurrect siblings the user already dealt with.
- useMarkInboxUnread patches both lists optimistically and re-pulls the
  cross-workspace unread summary on settle.
- One shared menu per list rather than a Base UI root per row (the same
  shape IssueContextMenuProvider uses): only one is ever open, and a
  per-row root would unmount with its menu when the row scrolls out of
  the virtualized viewport.
- The read toggle is main-view only — archived rows deliberately render
  as read and the unread count excludes them, so a toggle there would
  report success and change nothing on screen.
- Parking the row that is currently open holds the auto-read effect off
  that one item while it stays selected; re-opening it later marks it
  read again.
- Mobile subscribes to inbox:unread so the unread dots agree across
  clients.

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-30 19:18:36 +08:00
c25a82eee0 perf(agents): fast model discovery on runtime switch (MUL-5444) (#6098)
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444)

Switching runtime in the agent creation form left the model picker
spinning for ~8-20s. Two costs stacked up:

- the list-models request sat in the store until the daemon's next
  scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and
- the daemon then enumerated the catalog locally (static for claude,
  but a CLI/ACP round trip up to ~15s for everyone else).

Both are addressed with the two standard techniques for a slow,
low-frequency, read-only operation: push instead of poll, and
stale-while-revalidate.

Push (removes the heartbeat wait):
- new additive `daemon:pending_work` hint, runtime-scoped, delivered
  through the existing daemon WS hub and the Redis relay so the API node
  holding the socket does the delivery.
- the daemon answers a hint with ONE immediate heartbeat and dispatches
  what it claimed. The hint deliberately carries no work, so nothing has
  to be un-claimed when delivery fails and a duplicate hint cannot
  duplicate work - PopPending stays the atomic claim.
- per-runtime coalescing plus a 1s floor keeps a caller-triggered hint
  from becoming a heartbeat amplifier.

Cache (removes the discovery wait on repeat opens):
- server-side per-runtime catalog cache (in-memory single-node, Redis
  multi-node) written on every successful report.
- a snapshot younger than 15min answers the POST immediately as an
  already-completed request; older than 60s it also enqueues a
  background refresh that only warms the cache.
- only supported, non-empty catalogs are cached; a completed-but-empty
  report invalidates instead, while a failed report keeps serving the
  last known good list.

Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited
in the same session renders from cache and revalidates in the background
instead of showing the spinner again.

Compatibility: every wire change is additive. Old daemons ignore the
unknown hint type and keep using the scheduled heartbeat; new daemons
against an old server simply never receive one. The cached response is
shaped exactly like a completed live discovery apart from the optional
`cached` / `cached_at` markers.

Co-authored-by: multica-agent <[email protected]>

* fix(agents): address review on model discovery SWR (MUL-5444)

Sol-Boy's review on #6098 found the client cache could outlive the
server's own staleness promise, and that the two changed endpoints were
still cast rather than validated.

Must-fix 1 — client freshness now derives from the served answer.
`staleTime` was a flat 5min, so a 14-minute-old snapshot (which the
server returns while queueing its own refresh) was held as fresh for
another 5min: observable staleness became server window + client window,
and the refreshed catalog never reached the tab that triggered the
refresh. `staleTime` is now a function of the query data: a `cached`
answer is stale on arrival (bound stays the server's window alone, and
the next mount/focus picks up the refreshed snapshot), while a live
discovery — which just measured the truth — is trusted for the full 5min
so a cold runtime is never re-enumerated inside one form session.
`gcTime` stays 30min, so a revisited runtime still renders from cache and
revalidates in the background; the pickers gate their spinner on
`isLoading`, which stays false throughout.

Must-fix 2 — both model-discovery responses go through a zod schema.
`POST /api/runtimes/{id}/models` and its poll companion were casting
network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md
API-compatibility rules forbid. Added a lenient schema (`status` stays
`z.string()`, `supported` defaults to true, `.loose()` keeps unknown
fields) plus a fallback record whose `status` is `failed`: a malformed
body now surfaces "discovery failed" with manual entry still usable
instead of a fabricated empty catalog or an endless spinner.
`resolveRuntimeModels` was tightened to match — only an explicit
`completed` is a catalog, so an unrecognised status is an error rather
than a silent empty list, and `supported` can no longer be `undefined`.

Nit — the in-memory catalog cache now deep-copies each entry's
`Thinking` (and its level slice) and `ServiceTiers`, so it delivers the
independent value its comment promises and matches the Redis backend's
JSON round-trip semantics.

Tests: staleTime policy for cached/live/no-data; a QueryObserver test
proving the refreshed catalog reaches the same client with no blank
loading state; unknown-status and omitted-`supported` handling; schema
tests for live, cached, old-backend and nine malformed shapes; client
tests that both endpoints degrade to an explicit failure; nested-field
mutation isolation for the cache.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-29 16:03:26 +08:00
dixonl90andBohan 581d9527ba feat(vcs): self-hosted Git providers (Forgejo, Gitea, GitLab) alongside GitHub (MUL-3772) (#5006)
Adds self-hosted Git provider support (Forgejo, Gitea, GitLab) alongside GitHub:
per-workspace token connection, a provider-dispatched webhook, PR/MR and CI
mirroring, and the shared issue auto-link / auto-close machinery. Off until
MULTICA_VCS_SECRET_KEY is set, so existing deployments are unaffected.

Co-authored-by: Bohan <[email protected]>
2026-07-24 15:01:27 +08:00
a3fe6d91dd MUL-5150: add project context to Chat (#5765)
* feat(chat): add project context

Co-authored-by: multica-agent <[email protected]>

* fix(chat): resolve MUL-5150 review blockers

- Renumber project-context migrations to unique prefixes after current main:
  206_chat_session_project -> 212 (column), 207_chat_session_project_index ->
  213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills
  and main's 207-211 client_usage_daily set.
- Add the 4 missing chat input.project_context keys to ja/ko locales so the
  locale parity test passes (en/zh-Hans already had them).
- Lock the project-context control while a send is in flight (isSubmitting),
  not just while the agent is running. A brand-new chat creates its session
  lazily during send bound to the project at click time; switching project
  mid-send would create the session against the stale project and clear the
  editor as if the send landed on the new selection. Add a regression test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): complete project context handling

* fix(chat): pin fresh chat to open session's agent on project switch

Switching an existing session to a different project opens a fresh chat but
only cleared the active session, dropping selection back to the stored
`selectedAgentId`. When that preference was stale (open session belongs to
agent B while the persisted pick is still agent A), the lazily-created session
and its first send bound to the wrong agent (agent A).

Extract the project-switch decision into a shared `planProjectContextChange`
pure helper in use-chat-controller.ts and route both chat surfaces (the chat
tab controller and the floating ChatWindow) through it, so the fresh chat is
pinned to the open session's agent and the rule cannot drift between the two
copies. Add a dual-entry regression test (pure-fn guard + controller
integration) covering the stale selectedAgentId case.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* chore(ci): re-trigger required checks on latest head

The prior push updated the branch ref but GitHub did not emit a pull_request
synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the
commit carrying the stale-agent project-switch fix. Empty commit to force a
fresh synchronize on a head that includes it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): renumber project migrations to 213/214 after main added 212

Current main added 212_agent_service_tier; the PR's 212/213 chat migrations
collided with it on the merge ref, failing TestMigrationNumericPrefixesStay
UniqueAfterLegacySet. Merge current main and move the chat column migration to
213 and the concurrent index migration to 214 (column before index preserved).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): lock ProjectPicker clear control during send (keyboard path)

The send-pending lock only put pointer-events-none on the wrapper, which
blocks the mouse but leaves ProjectPicker's inline clear button in the tab
order — a keyboard user could Tab to "Remove from project" and press Enter
mid-send, detaching the project after the lazily-created session already went
out with the old one (reopens the mid-send retarget path via keyboard).

Add an explicit `disabled` capability to the shared ProjectPicker that locks
the trigger, the menu (forced closed), and the inline clear button (disabled +
out of the tab order). Defaults to false, so issue/create/autopilot callers
keep their hover/keyboard clear. ChatInput passes disabled while the project
selection is locked.

Tests: real-ProjectPicker regression (keyboard activation of the clear control
is inert when disabled; still works when enabled) + ChatInput wiring assertion
that the picker is disabled mid-send.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Walt <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Naiyuan Qing <[email protected]>
Co-authored-by: NevilleQingNY <[email protected]>
2026-07-24 11:30:27 +08:00
6b2097ccbb feat(inbox): archived notifications sub-view (MUL-3736) (#5518)
Adds an "Archived" sub-view to the Inbox, reachable from an entry at the
bottom of the main list, with per-row unarchive. Mirrors chat's archived
sub-view so the two surfaces share one mental model.

Backend:
- GET /api/inbox/archived and POST /api/inbox/{id}/unarchive. Kept off the
  existing GET /api/inbox so installed clients keep their contract and the
  unbounded archive never rides along with the main list.
- The archived query excludes any issue that still has an active row. Archiving
  is issue-level, so a new notification on an archived issue leaves old archived
  rows beside a fresh active one — without the guard the issue renders in BOTH
  lists. The exclusion lives in SQL so neither list depends on the other's cache.
- Unarchive is issue-level (mirroring archive) and leaves `read` untouched, so a
  restored unread item raises the unread badge again.
- v1 ships no pagination: LIMIT 200, newest-first, so truncation drops the
  oldest rows and never hides a group's newest one.
- inbox:unarchived event, fanned out to the recipient like the other personal
  inbox events.
- Two CONCURRENTLY-built indexes; inbox_item previously had none covering
  workspace/archived/created_at.

Frontend:
- Separate TanStack cache per list; every inbox event invalidates the workspace
  prefix, since any of them can move an item across the boundary.
- View persisted as ?view=archived, so refresh, back/forward, and the mobile
  detail-back all return to the list the user was in.
- Batch actions stay main-view only — they archive from the MAIN inbox, so
  offering them over the archived list would do the opposite of what it reads.
- Mobile subscribes to inbox:unarchived (its list gains the restored row); its
  own archived view remains follow-up.

Known debt: no pagination, so an archive past ~200 rows is truncated silently
in the UI; the entry's count is the deduplicated count of the rows returned.

Verified: pnpm typecheck/test/lint (0 errors), go build/vet, Go inbox suite
against a real Postgres, migrations up+down, and EXPLAIN confirming both new
indexes serve the query.

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-16 14:58:42 +08:00
f2c4b2cf50 MUL-4775: fix(daemon): negotiate websocket RPC support
* fix(daemon): negotiate websocket RPC support

* fix(daemon): bind RPC negotiation to WS connection

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): gate claims without constraining WS transport

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-15 15:29:05 +08:00
b85bb71a58 feat: custom issue properties — typed workspace-defined fields with list-surface support (MUL-4463) (#5335)
* feat(server): custom issue properties — definitions, typed values, CLI (MUL-4463)

Workspace-level property definitions (issue_property table; 7 types:
text/number/select/multi_select/date/checkbox/url) plus a typed value bag
on each issue (issue.properties JSONB keyed by definition UUID, mirroring
the metadata machinery: single-key atomic writes, 16KB cap, GIN index).

- Definitions: owner/admin only; agent actors rejected (agents propose,
  humans confirm). 20 active per workspace, 50 options per select,
  reserved built-in names blocked, archive instead of delete.
- Values: any member or agent; per-type validation with self-correcting
  error messages that enumerate legal option ids.
- API: /api/properties CRUD + PUT/DELETE /api/issues/{id}/properties/{propertyId};
  issue responses always emit the properties bag.
- CLI: multica property list/get/create/update/archive/unarchive and
  multica issue property list/set/unset with name→id translation.
- Events: property:created/updated, issue_properties:changed.

Co-authored-by: multica-agent <[email protected]>

* feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)

- Settings → Properties: definition management mirroring the Labels tab
  (list with type badges/option chips/usage counts, create/edit dialog
  with option editor, archive/restore, 20-cap indicator). Admin-gated;
  members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
  props' progressive disclosure — set values render as rows with
  type-appropriate editors (select/multi-select pickers, calendar,
  yes/no, inline input for text/number/url), unset ones live in the
  same '+ Add property' menu behind a separator. Archived definitions
  render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
  compat), api client methods, React Query hooks with optimistic
  single-key value writes, ws-updaters + realtime wiring for
  property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.

Co-authored-by: multica-agent <[email protected]>

* fix(properties): address MUL-4463 review round 1 — mobile CI, option guard, mutation safety, schema tolerance

- mobile: EMPTY_ISSUE_FALLBACK gains the required properties field (mobile
  typecheck was the red CI check).
- server: PATCH /api/properties/{id} rejects config updates that remove
  select options still referenced by issues (409 with a per-option usage
  census via jsonb ?); renames keep ids and pass. Integration test included.
- core: property value mutations are serialized per workspace via mutation
  scope, snapshot the bag from detail OR list caches (board surfaces have no
  detail cache — the old path overwrote whole bags with one key), roll back
  to the snapshot or invalidate on error, and the last settled mutation does
  an authoritative detail+catalog invalidate (usage counts reconcile).
- schemas: unknown-shaped property values (future server types) are dropped
  per-entry in a preprocess step instead of failing the whole IssueSchema
  and blanking lists through parseWithFallback; test updated to lock the
  tolerant behavior.
- realtime: reconnect invalidation covers the property catalog; every
  issue_properties:changed event also refreshes catalog usage counts.
- ui: number editor accepts decimals (step=any); settings usage count
  pluralizes (issue/issues) with CJK-safe plural keys.

Co-authored-by: multica-agent <[email protected]>

* fix(migrations): renumber issue properties to 179 and build the GIN index concurrently

main's migration sequence advanced twice under this PR (167 collision, then
an upstream renumber wave that claimed 178), so issue properties now sits at
179 — verified against main's current tip by the prefix-uniqueness lint.

The properties GIN index moves to its own single-statement migration (180)
using CREATE INDEX CONCURRENTLY — a plain CREATE INDEX on the hot issue
table would block writes for the duration of the build. Mirrors the
119_user_created_at_index pattern; full-chain dry-run on a fresh database
passes through 180.

Co-authored-by: multica-agent <[email protected]>

* feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)

Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:

- Filter: per-definition sections in the Filter dropdown (select /
  multi_select options with color dots and counts; checkbox as Yes/No
  pseudo-options). OR within a definition, AND across definitions;
  client-side in applyIssueFilters, mirrored into filterAssigneeGroups
  for the assignee-grouped board. Included in active-filter count and
  Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
  chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
  Server keeps position order (fixed sort enum); the surface controller
  re-sorts client-side, swimlane/gantt reuse the same comparator.
  Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
  definitions — one column per option (definition order) plus a
  trailing No-value column, option-colored headings. Drag-drop moves
  position via UpdateIssue and applies the value through
  useSetIssueProperty/useUnsetIssueProperty (properties are not part
  of UpdateIssueRequest). Stale persisted property groupings fall back
  to status columns.

View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.

Co-authored-by: multica-agent <[email protected]>

* fix(properties): address MUL-4463 review round 2 — desc sort, option bucketing, archived-state reconciliation

- sort: direction now applies to value comparison only; issues without a
  value sort last in BOTH directions (the whole-array reverse flipped them
  to the front on desc). Test covers the desc+missing case.
- board: values referencing an option removed from the definition bucket
  into the No-value column instead of vanishing (unmatched column ids
  dropped the issue entirely). Defense-in-depth behind the new server-side
  in-use guard; drag-utils test locks both behaviors.
- controller: persisted propertyFilters keyed by archived/deleted
  definitions are stripped before reaching the filter predicates, and a
  persisted property sort on a non-active definition degrades to manual
  order — previously both kept silently applying while the header claimed
  otherwise. The filter badge counts only active-definition filters.

Co-authored-by: multica-agent <[email protected]>

* feat(properties): server-side property filtering and sorting on list endpoints

Property filter/sort now execute in the database, so results are correct
across the full issue set — not just the loaded 50-per-status window
(closes MUL-4493 item 1's filter/sort half; requested on MUL-4463).

- New `properties` query param on ListIssues and ListGroupedIssues:
  JSON {definitionId: [values]} compiled to an AND-of-ORs containment
  check (double NOT EXISTS over jsonb_array_elements). One value expands
  to every storage shape it could match — string (select), array element
  (multi_select), boolean (checkbox) — so the handler stays type-agnostic.
  Guarded at 20 definitions / 50 values.
- `sort=property:<definitionId>` resolves the definition and orders by a
  typed expression (numeric CASE cast for number, NULLIF text for
  date/text/url); missing values sort last in both directions. Malformed
  ids 400; unknown/archived definitions degrade to position order instead
  of breaking stale clients.
- Frontend: the property filter and property sort ride the IssueSortParam
  window bag, so every surface (workspace + my-issues variants), query
  key, and per-status load-more page carries them automatically. The
  client-side re-sort layer is gone; applyIssueFilters keeps its property
  predicate as an optimistic-update backstop.
- Regression test seeds 55 issues and proves a match at position 55 is
  returned by a filtered 50-row page, plus sort order/missing-last,
  AND-across-definitions, and the 400/fallback sort paths.

Co-authored-by: multica-agent <[email protected]>

* fix(properties): address review round 3 — cache reconcile, merged-scope order, GIN-indexable filter, pool loader

- Cache reconciliation: property value writes (mutation settle + WS event)
  now invalidate every issue window whose server-side shape depends on
  property values — queries filtered by `properties` or sorted by
  `property:<id>` (detected via query-key predicate), covering flat lists,
  assignee groups, and my-issues variants. Windows without property params
  keep the cheap in-place patch. Fixes stale ordering/membership/counts
  under staleTime:Infinity.
- My Issues "All" scope: merged assigned/created/involves results are
  re-sorted with a comparator mirroring the server ORDER BY semantics
  (including property sorts and missing-last, created_at DESC tiebreak) in
  both the flat and assignee-grouped merge paths — relation concatenation
  no longer overrides the user's sort.
- Filter predicate rebuilt as plain bind-parameter containment ORs
  (AND across definitions): EXPLAIN now shows BitmapOr over
  idx_issue_properties_gin (the correlated jsonb_array_elements form
  defeated the index). Alternatives capped at 256 bind params.
- Property-grouped board gains a pool loader strip: one sentinel per
  status that still has server rows, keeping every issue reachable until
  per-column pagination lands (MUL-4493).
- Windowing regression test hardened: explicit positions + an assertion
  that the unfiltered first page excludes the target (the old fixture tied
  at position 0 and the created_at DESC tiebreak put the target on page
  one, proving nothing).
- Rollback safety: /api/properties 404 (old server) degrades to an empty
  catalog instead of a query error, which also keeps property params from
  ever being sent to pre-property servers; migration 179's CHECK
  constraints switch to NOT VALID + VALIDATE so the exclusive lock is
  instantaneous.

Co-authored-by: multica-agent <[email protected]>

* fix(properties): harden concurrency and cache coordination from clean-room review

Backend (MUL-4762 F1/F4/F5):
- withPropertyLock: pg_advisory_xact_lock helper; definition create/update
  and value writes now serialize config-vs-value and cap-vs-insert races
  (workspace-level 'props:' lock + per-definition 'prop:' lock, ordered).
- propertySortExpr degrades archived definitions to position sort.

Frontend (F2/F3/F6):
- onIssuePropertiesChanged invalidates plain assignee-group caches too.
- Property value mutations cancel list refetches in onMutate and roll back
  only the touched key against the current bag (concurrent WS writes to
  other keys survive a failed write).
- useUpdateIssue reconcile drops the stale properties bag from the server
  snapshot; the property pipeline owns that field.
- Surface controller passes persisted property filters/sorts through
  until the catalog query settles (cold cache no longer strips them).

Co-authored-by: multica-agent <[email protected]>

* fix(properties): open_only branch honors the properties filter

ListOpenIssues takes the parsed AND-of-ORs containment groups as a single
jsonb properties_filter param and unrolls them with a static double
NOT EXISTS; previously the open_only path parsed the properties param and
silently dropped it (clean-room review F7a).

Co-authored-by: multica-agent <[email protected]>

* fix(properties): toast on failed board drag to a property column

Property-column drags rolled the card back silently on failure; mirror
the status/assignee drag path (use-issue-surface-actions) so the
snap-back is explained (clean-room review F3, drag half).

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-15 12:00:28 +08:00
YYClaw 9eddcaff10 fix(chat): defer cancellation-time finalization until the task transcript is stable (#5246)
A quick Stop before the agent's first token no longer races a late reply. Started-but-empty cancellations defer the empty/non-empty judgment until the daemon acks its transcript flush (or a grace-period sweeper fires), then settle to a single outcome. Empty outcomes persist a durable, creator-authorized draft restore (fetched/consumed via a dedicated endpoint, reconnect-safe and at-most-once) instead of broadcasting the prompt over the workspace bus.

Closes #5219
2026-07-15 00:52:27 +08:00
ac62f72c2a MUL-4480: make daemon workspace sync event-driven (#5354)
* feat(daemon): make workspace sync event-driven

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): preserve trailing workspace changes

Co-authored-by: multica-agent <[email protected]>

* fix(workspace): reconcile failed creates

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-14 11:56:30 +08:00
c3dd9ec845 Machine-level batch task claim endpoint (MUL-4257) (#5193)
* feat(daemon-claim): machine-level batch task claim endpoint (MUL-4257)

Collapse the per-runtime /tasks/claim poll fan-out into a single machine-level
batch claim to cut /api/daemon claim request volume.

Server:
- agent.sql: = ANY(runtime_ids) batch variants of the claim queries
  (ListQueuedClaimCandidatesByRuntimes, PromoteDueDeferredTasksForRuntimes,
  ReclaimStaleDispatchedTasksForRuntimes); runtime.sql: GetAgentRuntimes(= ANY)
  so a whole machine's runtimes are resolved/promoted/reclaimed/listed in a
  constant number of queries instead of N.
- service.ClaimTasksForRuntimes: claim up to max_tasks across a runtime set,
  preserving per-(issue,agent) serialization, the concurrency cap, the
  empty-claim cache short-circuit, and every dispatch side effect. Batch
  promote replays the per-row side effects (task:queued + empty-cache Bump).
- handler.ClaimTasksByRuntime (canonical POST /api/daemon/tasks/claim, with a
  transitional /claim alias): validates daemon_id (required; must match the
  mdt_ token) and rejects runtimes bound to a different daemon (group-ownership
  check mirroring the WS path); resolves+authorizes each runtime_id; claims;
  and finalizes each task through the SAME FinalizeTaskClaim as the per-runtime
  endpoint (atomic token + delivered_comment_ids receipt), requeueing the exact
  claim and omitting it on failure. buildClaimedTaskResponse is extracted from
  the per-runtime handler and returns the delivered-comment ids plus a
  structured *claimBuildFailure so both paths share identical payload building
  and failure semantics (workspace-isolation, chat-input load/empty).
- max_tasks: negative -> 400, zero -> empty (never coerce to 1), positive
  capped at 32. runtime_ids parsed with non-panicking util.ParseUUID.

Daemon:
- Client.ClaimTasks posts daemon_id + runtime set + free-slot count to the
  canonical path under a short request-scoped timeout, bounding the
  head-of-line coupling the per-runtime pollers avoid (MUL-1744).

Tests: service batch drain / max_tasks cap / deferred-promote receipt /
finalize-failure rollback+requeue; handler routing + token, cross-workspace
skip, cross-daemon skip, daemon_id required, owner-missing cancel,
max_tasks=0/negative, invalid-uuid skip, comment delivery receipt, stale-reclaim
replacement receipt; client posts/parses (daemon_id + canonical path).

Follow-up: cut the daemon pollLoop over to a single batched poller (flips the
MUL-1744 isolation contract; needs its concurrency tests redesigned).

Co-authored-by: multica-agent <[email protected]>

* feat(daemon-ws): generic WS request/response transport for daemon RPC (MUL-4257)

Add a generic daemon->server request/response layer over the existing WS
control connection, the transport for WS-first claim (HTTP fallback):
- protocol: daemon:rpc_request / daemon:rpc_response envelopes with a
  correlation request_id + method + body, and an rpc-v1 capability gate.
- daemonws.Hub: SetRPCHandler + goroutine-dispatched handleRPCFrame (bounded
  by a per-connection in-flight cap) that echoes the request_id; missing
  handler / saturation return non-2xx so the daemon falls back to HTTP.
  Read limit raised to 64KB for rpc requests carrying a runtime set.
- hub tests: round-trip, handler-error->non-2xx, no-handler->503.

Co-authored-by: multica-agent <[email protected]>

* feat(daemon-ws): WS-first task claim over the generic RPC transport (MUL-4257)

Bind claim to the WS request/response layer, with HTTP fallback:
- server: handler.DaemonRPCHandler adapts a daemon:rpc_request (method
  tasks.claim) to the existing HTTP ClaimTasksByRuntime via a synthetic
  in-process request carrying the WS connection's identity (daemon_id +
  workspace + capabilities), so all auth / payload-building / finalization is
  reused unchanged. Wired via daemonHub.SetRPCHandler. ClientIdentity now
  captures X-Client-Capabilities so capability gating matches the HTTP path.
- daemon: wsRPCClient correlates responses by request_id over the shared WS
  connection; attached to the live connection's write channel (guarded so a
  Call racing teardown never sends on a closed channel) and detached on
  disconnect. rpc_response frames are routed in the read loop.
  Daemon.ClaimTasksWSFirst issues tasks.claim over WS and falls back to the
  HTTP claim endpoint on any transport failure (no conn / buffer full /
  timeout) — wired into the poller at the poller cutover.
- tests: handler tasks.claim RPC end-to-end (claims + dispatches) + unknown
  method 404; daemon wsRPCClient round-trip / timeout / unavailable /
  server-error / detach-fails-pending (all under -race).

Co-authored-by: multica-agent <[email protected]>

* feat(daemon): cut claim poller over to machine-level ClaimTasksWSFirst (MUL-4257)

Replace the per-runtime HTTP poll loop with a single batch poller: each cycle
acquires all free execution slots (slot-before-claim) and issues ONE
ClaimTasksWSFirst across every runtime the daemon hosts (WS-first, HTTP
fallback), dispatching each returned task to its runtime. Wakeups (targeted /
catch-up / runtime-set change) collapse to one nudge. Removes runRuntimePoller
+ runtimePollOffset. The WS handshake now advertises the same capabilities as
HTTP (+ rpc-v1) so WS-built claim payloads keep skill-ref / coalesced-comment
gating.

Trades per-runtime isolation (MUL-1744) for one request, bounded by the short
per-request WS timeout / client timeout. Tests: batch poller claims across
runtimes + skips-at-capacity + pollLoop shutdown drain (replacing the
per-runtime poller tests); heartbeat isolation + runtime-set watcher kept.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon-ws): WS RPC disconnect-race panic + batch stale-comment-plan repair (MUL-4257)

Two PR #5193 review blockers:

1) WS RPC send-on-closed-channel race, both ends:
   - server: give each connection a cancelable ctx (cancelled on readPump
     teardown) and run the RPC handler under it, so a slow claim stops on
     disconnect; guard c.send with sendMu/sendClosed (trySend) so a late RPC
     response goroutine never writes to the closed channel. Heartbeat ack routed
     through the same guard.
   - daemon: wsRPCClient.deliver now sends under the mutex, serialized with
     attach(nil)'s close+delete, so a delivered response can't hit a channel
     the detach path just closed.
   - regressions (-race): daemon deliver-vs-detach; server
     disconnect-during-handler-response.

2) batch claim now runs the stale-comment-plan repair: extracted the
   per-runtime handler's repair (trigger deleted, only coalesced survive ->
   cancel + replay survivors) into shared repairStaleCommentPlanIfNeeded, called
   by both claim paths. Prevents the batch path (now the default poller) from
   finalizing+dispatching a task with no comment input and silently dropping the
   surviving user comment. Regression: batch omits the stale task, cancels it,
   and rebuilds the survivor into a new trigger plan.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon-ws): server-side RPC deadline + legacy claim fallback (MUL-4257)

Two review blockers:

1) WS RPC timeout/fallback (GPT-Boy): the daemon's WS wait didn't cancel
   server-side claim, so a slow WS claim could commit after the daemon fell
   back to HTTP, leaking dispatched tasks and breaking the free-slot bound.
   Fix: RPC envelope carries TimeoutMs; the server bounds the handler ctx by it
   (so ClaimTasksByRuntime's tx is cancelled/rolled back at the deadline), and
   the daemon waits budget + grace so a claim that committed before the deadline
   still reports back. A committed-then-unreported claim degrades to the same
   stale-reclaim safety net as HTTP, never a double effective claim. Regression:
   server-side TimeoutMs cancels the handler.

2) Backward compat (Terra-Boy): a new daemon against a server without the batch
   route (/api/daemon/tasks/claim 404) couldn't claim. Fix: ClaimTasksWSFirst
   falls back to the legacy per-runtime ClaimTask loop on a batch 404 and caches
   'batch unsupported' (reset on WS reconnect to re-probe after a server
   upgrade). Regression: server exposing only the legacy route.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon-ws): no double-claim on WS teardown/detach (MUL-4257)

Sol-Boy review blocker: on reconnect, teardown failed the pending RPC (→ HTTP
fallback) but then flushed the queued tasks.claim frame to the still-alive
socket, so the server committed the WS claim on top of the HTTP one — double
claim, WS batch orphaned to stale reclaim, breaking the free-slot bound.

- Teardown now closes the connection FIRST, so runWSWriter discards the queued
  RPC frame (write error path) instead of delivering it.
- A detach while a claim's frame is already in flight now returns a distinct
  errWSRPCUncertain; ClaimTasksWSFirst does NOT HTTP-fall-back on uncertain (the
  WS claim may have committed) — it skips the cycle and lets reclaim / the next
  poll recover. Genuine 'not sent' / timeout still fall back (safe: the
  server-side deadline guarantees no uncommitted claim by budget+grace).
- Regression: detach during an in-flight WS claim asserts zero HTTP claims
  (at most one path claims); plus the existing detach/deliver-race and
  server-timeout tests.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon-ws): cancelable RPC frames close the backpressure double-claim (MUL-4257)

Sol-Boy review blocker: the client's response budget starts at enqueue, but
the socket write is async (10s write deadline). A backpressured writer could
hold a tasks.claim in the local queue past the client timeout — the daemon
HTTP-fell-back, then the writer woke and delivered the stale WS frame, so the
server committed it too: same free slots claimed twice. No detach occurs, so
the prior errWSRPCUncertain fix did not cover it.

- WS frames are now cancelable (wsOutbound{sent,canceled} under a mutex). The
  writer calls beginWrite() before WriteMessage and skips cancelled frames.
- On give-up (timeout / detach / ctx), Call cancels the queued frame: if it was
  still pending the cancel wins and the frame is guaranteed never delivered
  (errWSRPCUnavailable → safe HTTP fallback); if the writer already began
  sending it the cancel loses and the outcome is errWSRPCUncertain (no
  fallback). The decision is atomic, so at most one transport claims.

Tests: wsOutbound cancel-before-write vs write-before-cancel; Call timeout
cancels an unsent frame (writer then drops it) vs uncertain when already sent;
plus the updated detach and existing timeout/race tests.

Co-authored-by: multica-agent <[email protected]>

* fix(batch-claim): return partial success instead of dropping committed claims (MUL-4257)

Sol-Boy review blocker: ClaimTasksForRuntimes reclaims (step 2) and claims per
agent (step 6) in independent transactions, but a step-4 candidate-SELECT error
or a mid-loop ClaimTask error did 'return nil, err' — discarding tasks already
committed as dispatched. The handler 500s; the daemon sees a definite (non-
uncertain) 500 and HTTP-falls-back, claiming a SECOND batch into the same free
slots while the first batch waits for stale reclaim — the double-claim this PR
removes.

- Both error paths now prefer partial success: if any task has already
  committed (claimed non-empty), return it (nil error) so the handler finalizes
  and returns 200; the errored candidates stay queued for the next poll. The
  remaining error is logged. Only a genuinely empty result still returns the
  error (safe: no committed claim to lose, HTTP fallback just re-fails).

Regression (internal/service, DB-backed, fault-injected):
- PartialSuccessOnSecondAgentClaimFailure: fail the 2nd ClaimTask's Begin →
  the first agent's committed task is returned, not dropped.
- PartialSuccessOnCandidateQueryFailureAfterReclaim: a stale dispatched task is
  reclaimed, then the candidate SELECT fails → the reclaimed task is returned.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-14 11:53:42 +08:00
cb87dd106b feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) (#5195)
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)

Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:

- agent_task_queue.chat_input_task_id makes a task the owner of the user
  messages it must consume; the send path creates the task + user message +
  attachment bindings + session touch in one transaction, and the daemon is
  notified only after commit. A claim reads exactly that batch, so a message
  that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
  created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
  transaction: a normal message, or a visible no_response outcome (with a
  non-empty English fallback) when the final output is empty. The write failing
  rolls the completion back and the handler returns 5xx so the daemon retries;
  the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
  (keeping the tool timeline), suppress Copy, keep it unread, and keep the
  session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
  selector, so a rolling deploy never replays Slack/Lark history.

Co-authored-by: multica-agent <[email protected]>

* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)

Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
  direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
  the prior behavior: empty output writes no assistant row, so chat:done carries
  empty content and the channel outbound silently drops it — the no_response
  fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
  input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
  and preserves the dispatched task for redelivery instead of cancelling a valid
  task on a transient DB error.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-10 16:22:42 +08:00
bf161f2f9c fix(tasks): preserve merged comment delivery (#5192)
Track actual claim-time delivery, support legacy daemons, and repair comment
batches across claim, retry, edit, and delete races.

MUL-4348

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-10 14:10:10 +08:00
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 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-09 13:48:33 +08:00
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* 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 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

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

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* 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 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* 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 <[email protected]>

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

Co-authored-by: multica-agent <[email protected]>

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

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-08 21:58:16 +08:00
11a3cf206b feat(slack): bring-your-own-app install + per-installation Socket Mode (MUL-3666) (#4566)
* feat(slack): single app-level Socket Mode connection routed by team_id (MUL-3666)

Reshape the Slack adapter from the stage-3 per-installation Socket Mode model
into the multi-tenant B2 connection model: ONE deployment-level Socket Mode
connection (app-level xapp- token, env MULTICA_SLACK_APP_TOKEN) receives the
Events API stream for every installed workspace and routes each inbound event
to its channel_installation by team_id — the existing
GetChannelInstallationByAppID routing, unchanged.

- AppConnector: the single shared connection (slack/app_connector.go). No leader
  election — per the design "one (or a few)" connections are fine: each replica
  opens one, Slack delivers each event to one of them, and the existing
  (installation, message_id) two-phase dedup guarantees exactly-once processing.
  Resolves the per-team bot user id (via the same app_id query) to detect/strip
  @-mentions, since one connection serves many workspaces.
- Inbound translation (Events API -> channel.InboundMessage) extracted to
  slack/inbound.go as free functions parameterized by the per-team bot identity.
- channel.go trimmed to the outbound Send-only sender; per-installation config
  (config.go) no longer carries an app-level token — installs hold only the
  per-workspace bot token (xoxb-) for outbound, since xapp- can't be OAuth'd.
- engine.Supervisor now skips channel types with no registered Factory, so Slack
  installs (driven by the app-level connector, not per-installation channels) no
  longer churn the lease/Build loop.
- Wiring: router.go builds the connector when MULTICA_SLACK_APP_TOKEN is set;
  main.go runs it alongside the Supervisor. Feishu untouched; channel_* schema
  unchanged.

Verified: go build ./..., go vet ./..., gofmt, and
go test ./internal/integrations/... all pass.

Co-authored-by: multica-agent <[email protected]>

* feat(slack): OAuth self-serve install backend (MUL-3666)

Add the in-product OAuth install flow that creates Slack installations, the
keystone the B2 connector consumes.

- slack.InstallService: Begin (build authorize URL, seal workspace/agent/
  initiator into the OAuth state), Complete (verify state, exchange code via
  oauth.v2.access, upsert channel_type='slack' install with the bot token
  encrypted at rest, auto-bind the installer's Slack id so their first message
  is not dropped), plus List/Get/Revoke. State is stateless: sealed with the
  deployment secretbox + an embedded expiry, no session store.
- HTTP handlers (handler/slack.go): member-visible list, admin-only begin +
  revoke, and the public OAuth callback (recovers context from the sealed state,
  redirects the browser back to Settings → Integrations with a result flag).
- Routes + wiring: workspace-scoped list/begin/revoke mirror the Lark
  admin/member split; the callback is a public route like GitHub's. Built from
  MULTICA_SLACK_CLIENT_ID/SECRET (+ redirect derived from MULTICA_PUBLIC_URL,
  override MULTICA_SLACK_REDIRECT_URL; scopes via MULTICA_SLACK_SCOPES).
- Realtime: slack_installation:created / :revoked events.

Verified: go build ./..., go vet, gofmt, and go test ./internal/integrations/slack/...
all pass (new install_test.go covers state sign/verify/expiry/tamper, authorize
URL, code exchange + encrypted upsert + installer bind, and oauth error paths).

Co-authored-by: multica-agent <[email protected]>

* feat(slack): in-product OAuth install UI for web + desktop (MUL-3666)

Add the "Connect Slack" self-serve install UI mirroring the Feishu/Lark
integration, completing the in-product install half of B2. Slack's OAuth flow
is a redirect (not a device-code QR poll), so the UI is simpler than Lark's.

- core: SlackInstallation / List / Begin types; api.listSlackInstallations /
  beginSlackInstall / deleteSlackInstallation; slackKeys + slackInstallationsOptions
  query; realtime invalidation on slack_installation:* events.
- views: slack-tab.tsx (SlackTab settings panel + per-agent SlackAgentBindButton
  + connected badge + disconnect confirm). Connect calls beginSlackInstall and
  hands the authorize URL to openExternal (system browser on desktop, new tab on
  web); Slack bounces to the backend callback which lands the install, and the
  realtime event refreshes the list. Wired into the Settings → Integrations tab
  and the agent-detail Integrations tab alongside Lark.
- i18n: en + zh-Hans settings.slack.* strings.

Verified: pnpm typecheck (full monorepo, 6/6) and pnpm lint (@multica/core,
@multica/views — 0 errors) pass.

Co-authored-by: multica-agent <[email protected]>

* feat(slack): outbound Replier + user-binding redeem flow (MUL-3666)

Fill the stage-3 Replier=nil tail so non-installer Slack users can onboard and
get status feedback — completing B2 end to end.

- slack.OutboundReplier (engine.OutboundReplier): on NeedsBinding it mints a
  single-use binding token and DMs/replies a "link your account" prompt with the
  redeem URL (wrapped as <url|label> so formatMrkdwn doesn't mangle the
  base64url token); on AgentOffline/AgentArchived it posts a status notice; on an
  /issue-created Ingest it confirms the new issue. Plain chat stays silent (the
  agent's own reply lands via EventChatDone). Reuses the bot-token Send path and
  reads the installation row from ResolvedInstallation.Platform — no new transport.
- slack.BindingTokenService: Mint + transactional RedeemAndBind over the generic
  channel_binding_token / channel_user_binding queries (channel_type='slack'),
  mirroring lark.BindingTokenService. 15-min TTL, SHA256-hashed tokens, the
  three typed failure modes (invalid/expired, already-assigned, not-member).
- HTTP: POST /api/slack/binding/redeem (public, session-authed) maps the failures
  to 410/409/403. NewSlackResolverSet now takes the replier (nil disables it).
- Frontend: /slack/bind redeem page (packages/views/slack + apps/web route) +
  api.redeemSlackBindingToken + en/zh slack_bind copy.

Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/...
(new replier_test.go covers all outcome branches + the prompt URL), plus full
pnpm typecheck (6/6) and pnpm lint (0 errors).

Co-authored-by: multica-agent <[email protected]>

* fix(slack): address review must-fixes — connector leak, team-keyed install, /issue copy (MUL-3666)

Three fixes from Niko's review:

1. AppConnector.connectOnce leaked the Socket Mode goroutine/connection on a
   handler error: it ran sm.RunContext on the long-lived ctx and returned the
   error without cancelling it, so a transient DB/router error left the old
   connection alive (consuming events into an unread channel) while Run opened a
   second one. Each connection now runs under its own cancellable context and a
   deferred cancel + join tears it down on every exit path before reconnect.

2. Slack re-install collided with the (channel_type, app_id) unique index:
   connecting the same Slack team to a different agent failed because the upsert
   conflict key was (workspace_id, agent_id, channel_type). Add a team-keyed
   UpsertChannelInstallationByAppID (ON CONFLICT on the (channel_type, app_id)
   index, updating agent_id) and use it for the Slack OAuth install, so
   re-connecting a workspace moves the bot to the chosen agent instead of
   erroring. Feishu's per-agent upsert is unchanged.

3. /issue clarified: it is not a registered Slack slash command (no `commands`
   scope), so Slack never routes one to us. Issue creation runs through the
   message path — `@bot /issue <title>` in a channel or `/issue <title>` in a
   DM — which the engine parser handles. Documented in the connector and the
   user-facing copy (en + zh).

Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/...,
make sqlc, plus pnpm typecheck (6/6) and pnpm lint (0 errors).

Co-authored-by: multica-agent <[email protected]>

* fix(slack): make OAuth install transactional — agent-move binding consistency + cross-workspace guard (MUL-3666)

Address Elon's review: the team-keyed upsert kept the same installation row and
only flipped agent_id, but engine session reuse matches purely on
(installation_id, channel_chat_id) and each chat_session is permanently tied to
the agent it was created under — so after moving a Slack team from Agent A to
Agent B, existing DMs/threads kept routing to Agent A; only brand-new
channels/threads reached B. Cross-workspace re-install was worse: the SQL also
moved workspace_id while the application-layer user/chat-session bindings stayed
behind, inheriting the previous workspace's relations.

InstallService.Complete now runs one transaction (lookup → upsert → retire →
installer-bind), all application-layer per the no-FK rule:

- Look up the existing installation by team_id (config->>'app_id').
- Reject a silent cross-workspace ownership change (ErrTeamOwnedByAnotherWorkspace
  → callback redirects with slack_error=team_in_other_workspace). The owning
  workspace must disconnect first.
- On an agent change within the same workspace, retire the installation's
  chat-session bindings (new DeleteChannelChatSessionBindingsByInstallation) so
  the next message creates a fresh session under the new agent. The chat_session
  rows are preserved for history; user bindings stay valid (same users/workspace).
- Installer auto-bind moves into the tx; an already-bound-elsewhere id is a
  benign skip, a real DB error aborts the whole install.

InstallService now takes a TxStarter; the queries seam gains WithTx (dbInstallQueries
adapter) so Complete stays unit-testable with a fake tx.

Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...
(new tests: agent-move retire, same-agent no-retire, cross-workspace reject,
fresh-install no-retire).

Co-authored-by: multica-agent <[email protected]>

* fix(slack): atomic cross-workspace install guard + green up frontend CI (MUL-3666)

Two things: address Elon's review and fix the failing frontend CI job.

Review (atomic cross-workspace guard): the previous guard was a SELECT before
the upsert, which loses the concurrent-OAuth race — two workspaces can both read
no rows, one inserts, the other's ON CONFLICT update then silently re-points the
team. Move the guard into the upsert itself: ON CONFLICT ... DO UPDATE ... WHERE
channel_installation.workspace_id = EXCLUDED.workspace_id, and map the empty
RETURNING (pgx.ErrNoRows) to ErrTeamOwnedByAnotherWorkspace. The pre-SELECT now
only feeds the agent-change cleanup. Also corrected the error copy: a team stays
bound to its first Multica workspace (revoke is soft, keeping the row + unique
index), so migration is an operator action, not "disconnect first".

CI (frontend vitest, @multica/views#test):
- The agent IntegrationsTab now renders the real SlackAgentBindButton, whose
  connected badge calls useQueryClient — absent from integrations-tab.test.tsx's
  react-query mock. Hoisted the owner/admin gate above the per-platform sections
  (one role notice instead of one per platform), made the agents members_note
  generic (en/zh/ja/ko), and updated the test (mock @multica/core/slack, stub
  SlackAgentBindButton, assert both platforms).
- Added slack-tab.test.tsx covering the real SlackAgentBindButton / SlackTab.
- locale parity: added the slack (settings) + slack_bind (common) blocks to ja
  and ko so every EN key has a translated counterpart.

Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...;
pnpm --filter @multica/views test (1478 pass), pnpm typecheck (6/6), pnpm lint (0 errors).

Co-authored-by: multica-agent <[email protected]>

* fix(slack): surface agent-page Slack entry points when Lark is off (MUL-3666)

The agent-detail Integrations tab and the inspector's Integrations section
only considered Lark, so a Slack-only deployment (Lark disabled) showed neither
the Integrations tab nor a Connect-Slack button — the per-agent entry points
were unreachable.

- agent-overview-pane: gate the Integrations tab on Lark OR Slack configured
  (new slackInstallationsOptions query), not Lark alone.
- agent-detail-inspector: render SlackAgentBindButton alongside LarkAgentBindButton
  in the Integrations section.
- regression test: the Integrations tab appears when only Slack is configured.

Verified: pnpm typecheck (6/6), pnpm --filter @multica/views test (1478+ pass),
pnpm lint (0 errors).

Co-authored-by: multica-agent <[email protected]>

* feat(slack): BYO-app install backend — paste xoxb+xapp, per-app install keyed by real app id (MUL-3666)

Adds the bring-your-own-app install path so multiple agents can each have
their own bot identity in the SAME Slack workspace (hosted B2 caps at one
agent/workspace). User pastes their app's bot token (xoxb-) + app-level
token (xapp-); we validate the bot token via auth.test, parse the real
Slack app id from the xapp- token, encrypt both tokens, and persist a
per-app installation keyed by that app id (real 'A…' ids never collide
with hosted 'T…' team ids in the existing unique index — no schema change).

- config.go: add app_token_encrypted (BYO discriminator + per-app socket token)
- install.go: extract shared persistInstall (atomic cross-ws guard + agent-move retire)
- byo_install.go: RegisterBYO + auth.test + app-id parse
- handler + route: POST /api/workspaces/{id}/slack/install/byo (admin-only)
- tests: keying, encryption, invalid tokens, auth.test failure, cross-ws, agent move

Follow-ups (separate commits): per-app Socket Mode connector that consumes
the stored app token; in-product BYO install dialog (video + paste form).

Co-authored-by: multica-agent <[email protected]>

* refactor(slack): drop OAuth, unify on BYO per-installation model (MUL-3666)

Per product decision, Slack drops the hosted-app OAuth path entirely and
unifies on bring-your-own-app (BYO): every installation carries its OWN
app-level token and gets its OWN Socket Mode connection, so multiple agents
can each have a distinct bot identity in one Slack workspace.

- Remove OAuth install (Begin/Complete/code-exchange/sealed state/OAuthConfig/
  default scopes), the OAuth callback + begin handlers + routes, and the
  MULTICA_SLACK_CLIENT_ID/SECRET/REDIRECT/APP_TOKEN env wiring.
- Replace the single deployment-level AppConnector with a per-installation
  slackChannel (authenticated with its own xapp- token) registered as a channel
  Factory, so the engine Supervisor drives one Socket Mode connection per
  installation (exactly like Feishu). inbound/outbound/resolvers reused as-is.
- Route inbound by the event's api_app_id (== the installation's real app id),
  not team_id.
- InstallService slims to at-rest encryption + the shared persistInstall +
  list/get/revoke; install is the BYO paste path only (byo_install.go).
- Tests: drop the OAuth tests; slack + handler + engine all green.

Follow-up (frontend): replace the OAuth "Connect Slack" button with the BYO
paste dialog (the begin endpoint it calls is now gone).

Co-authored-by: multica-agent <[email protected]>

* fix(slack): verify BYO bot + app tokens are from the same app, and the app token is live (MUL-3666)

Niko review: RegisterBYO only parsed the app id from the xapp string and
auth.test'd the bot token, so pasting app A's bot token with app B's app
token would 'connect' but be broken (inbound on B's socket, outbound with
A's identity). Now: resolve the bot's owning app id via bots.info (on the
bot_id from auth.test) and require it to equal the xapp's app id; and live-
validate the app token via apps.connections.open. Reject (no persist) on
mismatch or a dead app token.

Co-authored-by: multica-agent <[email protected]>

* feat(slack): in-product BYO install dialog (paste bot + app tokens) (MUL-3666)

The OAuth begin endpoint was removed server-side, so the "Connect Slack"
button now opens a dialog where the admin pastes the bot token (xoxb-) and
app-level token (xapp-) of the Slack app they created, and submits to the
BYO install endpoint. Includes an optional setup-video link (URL constant,
left empty until the walkthrough is recorded).

- core: drop beginSlackInstall / BeginSlackInstallResponse; add
  registerSlackBYO + RegisterSlackBYORequest.
- views: SlackAgentBindButton opens the BYO dialog; refreshed comments and
  install_supported docs (now means "configured", no OAuth).
- i18n: new slack.byo_* keys + refreshed page_description in en/zh-Hans/ja/ko.
- tests: dialog submit path; views vitest (1479), typecheck, lint, locale
  parity all green.

Co-authored-by: multica-agent <[email protected]>

* fix(slack): Elon review — team_id routing guard, per-agent reconnect, users:read hint (MUL-3666)

1. Inbound routing keys on api_app_id (the APP, not the Slack workspace), so
   additionally require the event's team_id to match the installation's stored
   team. A distributed BYO app installed into another Slack workspace emits the
   same app id and would otherwise mis-route to this Multica installation.
   Extracted installationServesTeam() + unit test.

2. BYO install is now agent-keyed (UpsertChannelInstallation, conflict on
   workspace_id+agent_id+channel_type): one bot per agent. Disconnect →
   reconnect a NEW app for the SAME agent now UPDATES that agent's row in place
   instead of violating the (workspace, agent, channel) unique. A unique
   violation on the (channel_type, app_id) routing index → ErrTeamOwnedByAnother-
   Workspace (the app is already connected to another agent/workspace). No
   chat-session retire is needed: a row's agent_id never changes.

3. UX: bots.info (the same-app check) needs the users:read scope — the connect
   dialog now lists the required bot scopes including it, and the error text
   says so.

Backend build/vet/gofmt/test + views vitest + typecheck + locale parity green.

Co-authored-by: multica-agent <[email protected]>

* fix(slack): publish slack_installation:created on BYO connect; refresh stale comments (MUL-3666)

Niko final review: RegisterSlackBYO wrote the response but never published
EventSlackInstallationCreated, so only the installer's own tab refreshed —
other open clients (Settings, Agent Integrations, other tabs) did not see the
new bot in realtime, inconsistent with the revoke event and Lark. Now publishes
it on success via a small publishSlackInstallationCreated helper, with a unit
test (Bus.Publish is synchronous).

Also refreshed comments that still described the removed hosted-OAuth /
single deployment-level AppConnector model (handler SlackInstall field,
channel.go / inbound.go / outbound.go / byo_install.go). PR title updated
separately to the BYO per-installation Socket Mode model.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-29 14:09:34 +08:00
1ac3a03e5d MUL-3618: dispatch daemon feature flag snapshots (#4509)
* MUL-3618: dispatch daemon feature flag snapshots

Co-authored-by: multica-agent <[email protected]>

* MUL-3618: narrow daemon flag snapshots to process scope

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-24 16:19:30 +08:00
5038c983c0 MUL-3281: Add daemon skill bundle refs (#4445)
* feat: add daemon skill bundle refs

Co-authored-by: multica-agent <[email protected]>

* fix: tighten skill bundle resolve safeguards

Co-authored-by: multica-agent <[email protected]>

* feat: add task prepare lease

Co-authored-by: multica-agent <[email protected]>

* fix: isolate prepare lease concurrent index migration

Co-authored-by: multica-agent <[email protected]>

* fix: keep prepare lease active through start

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-23 16:19:16 +08:00
LinYushen 77ac17ef49 Make custom runtimes appear immediately (#4234)
* Make custom runtimes appear immediately

* Scope daemon profile refresh by authorized runtimes

* Relay runtime profile refresh hints

* Localize runtime profile close label
2026-06-17 16:00:22 +08:00
Antoine GIRARD 9f21d0b634 feat(transcript): add timestamps to run transcript entries (MUL-3174) (#3951)
Threads the existing task_message.created_at column through the full stack (Go protocol -> REST/WS handlers -> TS types -> transcript dialog) so agent run transcripts show per-entry timestamps, helping users spot stalled runs. Additive, no migration.
2026-06-09 19:53:30 +08:00
8c98940b79 Lark Bot integration MVP: migration + service boundary (MUL-2671) (#3277)
* feat(db): add Lark integration migration (MUL-2671)

Introduces seven tables for the 飞书 Bot integration MVP — per-agent
PersonalAgent installations, user/chat bindings, inbound dedup +
non-content drop audit, outbound card mapping, and short-lived
single-use member binding tokens.

Schema notes:
- chat_session schema unchanged; Lark routes through a separate
  binding table rather than adding a metadata JSONB column.
- Outbound card mapping is task/message scoped so multiple runs on
  the same session can't stomp each other's cards.
- lark_inbound_audit stores routing / identity / drop_reason ONLY,
  never message body — the audit channel for unbound users and group
  messages that don't address the Bot.
- app_secret stores ciphertext (encryption helper lands in a follow-up
  commit on this branch); DB never sees plaintext.

Co-authored-by: multica-agent <[email protected]>

* feat(util): add secretbox AES-256-GCM helper for at-rest secrets

First consumer is lark_installation.app_secret (MUL-2671 §4.4), but
the helper is intentionally generic — future per-tenant secrets that
must not appear in a DB dump can reuse it.

Construction: AES-256-GCM with a per-message random nonce, providing
authenticated encryption. Tampered ciphertext fails Open instead of
silently decrypting to garbage. Master key loaded from a base64 env
var via LoadKey; key rotation is not in scope yet.

Co-authored-by: multica-agent <[email protected]>

* refactor(issues): extract IssueService.Create as single create entry (MUL-2671)

Establishes the service-layer boundary mandated by Elon's 二审 of
MUL-2671 §4.8: issue creation no longer lives inside the HTTP
handler. Both the HTTP POST /issues handler and the future Lark
/issue command call into service.IssueService.Create, so duplicate
guard, issue numbering, attachment linking, broadcast, analytics,
and agent/squad enqueue stay aligned.

Handler responsibilities shrink to parsing the HTTP request, doing
actor resolution / validation (transport-specific), and converting
service results into the IssueResponse + 201. The transaction-wrapped
core, attachment link, event publish, analytics capture, and
agent/squad enqueue all move into service.IssueService.Create.

A BroadcastPayload callback on the service keeps the WS broadcast
shape (the full IssueResponse) without forcing the service to
depend on handler-layer response types.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations): add Lark package skeleton (MUL-2671)

Establishes the architectural boundaries Elon's 二审 mandated as
first-PR blockers without dragging in OAuth, WebSocket, or
card-patching code (those land in follow-up PRs):

- ChatSessionService interface — channel-aware chat-session entry
  point for Lark, deliberately separate from the HTTP SendChatMessage
  handler. The HTTP handler's single-creator guard (creator_id ==
  request user_id) is correct for the browser client but rejects
  group chat_sessions by construction; Lark needs its own service.
- AuditLogger interface — the only path for recording dropped events.
  Its signature deliberately omits message body, enforcing the
  drop-audit policy (MUL-2671 §4.7) at the type level: unbound users
  and non-addressed group messages can't accidentally end up in
  chat_session.
- Typed IDs (OpenID, ChatID) prevent UUIDs from being conflated with
  Lark-side identifiers at compile time.
- DropReason constants align dashboard/audit queries across callers.

Co-authored-by: multica-agent <[email protected]>

* refactor(issues): move parent/project workspace check into IssueService (MUL-2671)

Parent existence and project workspace membership now live inside
IssueService.Create, inside the same transaction as the duplicate guard
and counter increment. The HTTP handler stops re-implementing the
lookup; every future create entry (Lark /issue, MCP, API keys) inherits
the same boundary without copy-pasting the SQL.

Adds two error sentinels (ErrParentIssueNotFound, ErrProjectNotFound)
so transports can translate to their own error shapes. Handler-level
cross-workspace tests guard the boundary against future regressions.

Co-authored-by: multica-agent <[email protected]>

* fix(db): harden Lark migration safety底座 — TTL cap + workspace FK (MUL-2671)

Two storage-layer hardenings that move the must-fix line off "the app
layer enforces it" and onto the schema itself, so future write paths or
hand-inserted rows cannot regress the invariants.

1) lark_binding_token TTL cap. The DB CHECK was 1 hour as
   defense-in-depth while the app constant was 15 minutes; the CHECK
   now matches the product cap (15 minutes). Application constant
   docstring updated to reflect that storage enforces the same bound.

2) lark_user_binding workspace membership. The table previously only
   FK'd to workspace / user / installation independently, so a binding
   could exist for a user no longer in the workspace, or claim a
   workspace different from its installation's. Two composite FKs
   close the gap structurally:

   * (installation_id, workspace_id) → lark_installation(id, workspace_id)
     — guarantees a binding's workspace_id always matches its
     installation's workspace_id. A new UNIQUE (id, workspace_id) on
     lark_installation is added as the FK target.

   * (workspace_id, multica_user_id) → member(workspace_id, user_id)
     with ON DELETE CASCADE — when a user is removed from the
     workspace, the binding cascades away in the same transaction.
     There is no longer a path where lark_user_binding outlives
     workspace membership.

These two FKs are the schema-level proof for §4.3's "unbound or
non-workspace members cannot leak content into chat_session" invariant.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): inbound services + /issue dispatcher (MUL-2671)

Lands the inbound service layer for the Lark Bot MVP, sitting on top
of the migration + service-boundary scaffold from the previous commits.
What ships:

- sqlc queries for all seven lark_* tables (idempotent dedup insert,
  CAS WS-lease, single-use binding-token consume, etc.) plus
  GetMostRecentUserChatMessage for the /issue fallback.
- AuditLogger backed by lark_inbound_audit; signature deliberately
  body-free so callers cannot leak content into the drop log.
- ChatSessionService: find-or-create chat_session via the binding
  table (winner-takes-all on the UNIQUE race), append-with-dedup, /issue
  parser, "previous user message" fallback for bare `/issue` invocation.
- Dispatcher orchestrates the inbound pipeline in one place:
  installation routing → group-mention filter → identity check → ensure
  session → append+dedup → /issue → enqueue chat task. Group sessions
  use the installer as creator (stable workspace identity); p2p uses
  the sender. Agent-offline path falls through with OutcomeAgentOffline
  so the WS adapter can reply with the offline notice from §4.6.
- BindingTokenService: random URL-safe token, SHA-256 stored hash,
  15-min TTL pinned at the application AND the DB CHECK; Redeem
  returns the same opaque error for all rejection cases (no timing
  oracle on replay).
- Unit tests for the parser (13 cases), dispatcher (8 cases via fake
  Queries/Chat/Audit/IssueCreator/Enqueuer), and binding-token
  hash/entropy. Real-DB integration tests for OAuth + token redeem
  land alongside the HTTP handlers in the next commit.

Out of scope for this commit (next ones on the same feature branch):
OAuth callback, HTTP routes, WebSocket hub, outbound card patcher,
frontend.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): installation HTTP surface + secretbox-gated wiring (MUL-2671)

Lands the HTTP boundary on top of the inbound services from the
previous commit. What ships:

- InstallationService.Upsert: the only path that writes
  lark_installation. Encrypts app_secret with the secretbox passed in
  at construction time; refuses to fall back to plaintext storage
  (returns an error from the constructor if no Box is supplied), so a
  misconfigured dev environment cannot accidentally land a row with
  cleartext credentials. Revoke flips status without DELETE so audit
  trail survives.

- HTTP handlers under /api/workspaces/{id}/lark/:
  * GET  /installations           — member-visible (Integrations tab
                                    renders for non-admins). Soft 200
                                    with empty list + configured:false
                                    when MULTICA_LARK_SECRET_KEY is
                                    unset, so the tab does not error
                                    on self-host that has not opted in.
  * POST /installations           — admin-only; 503 when not configured.
                                    Re-validates agent_id ∈ workspace
                                    before accepting credentials so a
                                    cross-workspace agent UUID is
                                    rejected.
  * DELETE /installations/{id}    — admin-only; workspace-scoped lookup
                                    so one workspace cannot revoke
                                    another's installation by UUID
                                    guess.

- POST /api/lark/binding/redeem (user-scoped, no workspace context):
  the only path that mints a lark_user_binding row from user action.
  Redeemer identity comes from the session, not the token, so a stolen
  link cannot bind an open_id to an attacker's Multica user. The
  composite FK on lark_user_binding cascades the binding away if the
  user is not (or no longer) a workspace member, so a non-member who
  steals the link gets 403 at the DB layer.

- Two new event-bus types in protocol.events:
  EventLarkInstallationCreated, EventLarkInstallationRevoked.

- Router wiring: MULTICA_LARK_SECRET_KEY drives a conditional
  initialization of h.LarkInstallations + h.LarkBindingTokens. When
  unset, the integration disables itself with an INFO log and the
  rest of the server boots normally.

- Handler tests cover all four not-configured short-circuits.
  Happy-path integration tests (real DB, full create→list→revoke
  cycle and token mint→redeem) ship alongside the WS hub PR.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): close binding-token rebind & typed task errors (MUL-2671)

Two must-fixes from PR review on HEAD 87ad15e1:

1. Binding-token redeem could be used to grab an already-bound Lark
   open_id. Two changes harden the path:
   - lark.sql `CreateLarkUserBinding` now gates ON CONFLICT DO UPDATE
     on `multica_user_id = EXCLUDED.multica_user_id`, so a cross-user
     rebind via a second valid token returns zero rows instead of
     silently switching ownership.
   - `BindingTokenService.RedeemAndBind` consumes the token and writes
     the binding row inside one transaction. A failed bind no longer
     burns the token; a successful bind never leaves a consumed-but-
     unused token. Distinct typed errors: ErrBindingTokenInvalid (410),
     ErrBindingAlreadyAssigned (409), ErrBindingNotWorkspaceMember
     (403). The handler maps each to its own status code.

2. Dispatcher collapsed every `EnqueueChatTask` error to
   `OutcomeAgentOffline`, hiding infra failure and misusing the
   "offline" label for cases (e.g. archived agent) where it doesn't
   fit. Now:
   - `service.EnqueueChatTask` returns `ErrChatTaskAgentNoRuntime` and
     `ErrChatTaskAgentArchived` as sentinel errors; DB / load / insert
     failures stay wrapped as ordinary errors.
   - Dispatcher uses `errors.Is` to map only the productizable cases
     (`OutcomeAgentOffline`, new `OutcomeAgentArchived`); any other
     error is returned to the WS adapter so it can retry or page
     instead of disguising the outage as an offline card.

A daemon that's merely disconnected is still NOT an error — as long
as `agent.runtime_id` is set the chat task enqueues and waits for the
daemon to claim it on next online (returns `OutcomeIngested`).

Co-authored-by: multica-agent <[email protected]>

* ci: re-trigger workflow on lark MVP must-fix HEAD

Co-authored-by: multica-agent <[email protected]>

* ci: re-trigger workflow on lark MVP must-fix HEAD (retry)

Co-authored-by: multica-agent <[email protected]>

* test(integrations/lark): guard binding-token sentinel contract (MUL-2671)

Two unit tests that document and protect the must-fix invariants
without requiring a DB:

1. TestRedeemAndBindRequiresTxStarter — if a future refactor wires
   up BindingTokenService without a TxStarter, RedeemAndBind must
   fail fast with a clear error rather than nil-panic on Begin.
   The atomicity contract (consume + bind commit together) depends
   on that transaction existing.

2. TestBindingErrorSentinelsAreDistinct — the HTTP handler maps
   ErrBindingTokenInvalid → 410, ErrBindingAlreadyAssigned → 409,
   ErrBindingNotWorkspaceMember → 403. Accidentally aliasing them
   (e.g. var ErrBindingAlreadyAssigned = ErrBindingTokenInvalid)
   would silently regress the response codes without any other
   test catching it.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): WS hub orchestrator + outbound card patcher (MUL-2671)

The hub owns one supervisor goroutine per active installation. Each
supervisor acquires the WS lease via the existing CAS query, runs an
EventConnector (interface — real Lark wire protocol lands in a follow-up
behind it), renews the lease on a tighter cadence than the TTL, and
backs off (with jitter) on connector failure. Lease loss tears the
connector down cleanly; revocation is reaped on the next sweep. Per-
process node id satisfies §4.4 multi-replica safety: at most one Hub
globally holds the lease for any installation.

The patcher subscribes to task / chat-done events on the existing
events.Bus and keeps the per-task Lark interactive card in sync
(thinking → streaming → final | error). Card binding is per-task as
required by §4.5; throttled patches via an in-memory last-patched map;
final / error transitions bypass the throttle so the user always sees
the terminal state. The Renderer is plug-replaceable so the product
card template can evolve without touching transport.

The APIClient interface centralizes the Lark Open Platform surface
this package needs (send card, patch card, send binding prompt,
exchange OAuth code). The default stubAPIClient returns
ErrAPIClientNotConfigured for every transport call so a misconfigured
deployment fails loudly instead of dropping cards silently. Real
implementation lands in a follow-up; OAuth callback + frontend entries
land in the next commits on this branch.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): OAuth install start / callback (MUL-2671)

OAuthService builds a signed-state Lark authorization URL the frontend
can render as a QR (or open directly), then on callback verifies the
HMAC-protected state, exchanges the OAuth code for installation
credentials via APIClient.ExchangeOAuthCode, and persists the row via
InstallationService.Upsert (which keeps app_secret encryption inside a
single chokepoint).

State token format: workspaceID.agentID.initiatorID.expiresUnix.nonce.sig
— HMAC-SHA256 over the first five fields with a deployment-level
secret. TTL defaults to 10 minutes (covered by tests). Three failure
modes (invalid state / expired state / missing code) map to typed
errors so the HTTP handler can emit a single lark_error= query param
the frontend uses to pick copy.

Both endpoints degrade cleanly: the at-rest key gate (already in place)
returns 503 from /install/start when the InstallationService is nil,
and the OAuth gate (MULTICA_LARK_OAUTH_APP_ID / _SECRET / _REDIRECT_URI
/ _STATE_SECRET) returns configured:false from /install/start so the
frontend can render "configure manually instead" without an error
banner. /install/callback always finishes with a redirect to
/settings?tab=lark carrying either lark_installed=1 or lark_error=<code>.

Tests cover signed-URL shape, missing-config rejection, tampered state,
expired state, propagated exchange error, and the no-config redirect
path on the HTTP handler.

Co-authored-by: multica-agent <[email protected]>

* feat(views/lark): settings tab + agent bind button + /lark/bind redemption page (MUL-2671)

Adds the user-facing Lark surface across the shared packages:

- packages/core/types/lark.ts — wire shapes that mirror server/internal/
  handler/lark.go. Optional fields default to undefined so older desktop
  builds keep parsing if the server adds new keys (CLAUDE.md → API
  Response Compatibility).
- packages/core/lark/{queries,index}.ts — Tanstack Query options keyed
  by workspace id; realtime sync invalidates `installations(wsId)` on
  `lark_installation:*` events.
- packages/core/api/client.ts — listLarkInstallations,
  getLarkInstallURL, deleteLarkInstallation, redeemLarkBindingToken.
- packages/views/settings/components/lark-tab.tsx — Settings → Lark
  panel. Listing is member-visible (matches backend); disconnect is
  admin-only. Empty state points users at the per-Agent bind entry,
  matching the (workspace_id, agent_id) UNIQUE: there is no
  "pick an agent" UI here because the bind URL is per-agent.
- LarkAgentBindButton (same file) is the per-Agent CTA the Agent
  detail page imports. Opens the OAuth URL in a new tab; the callback
  bounces back to /settings?tab=lark with a query param the panel
  reads for inline confirmation copy.
- packages/views/lark/bind-page.tsx — the Bot's "you need to bind"
  destination. Requires session before redeeming, distinguishes the
  410/409/403 backend responses into distinct copy.
- apps/web/app/lark/bind/page.tsx — Next.js route wrapping the shared
  bind page in a Suspense boundary (Next 15 useSearchParams rule).

i18n: all user-facing strings land in en/zh-Hans, settings tab nav
includes a Sparkles-iconed Lark entry, bind-page copy lives under
common.lark_bind so it works pre-workspace-context too. typecheck +
lint clean.

Co-authored-by: multica-agent <[email protected]>

* chore(integrations/lark): wire outbound Patcher into server bootstrap (MUL-2671)

Constructs the Patcher next to the existing Installation/BindingToken
wiring in router.go and Register()s it on the event bus. With the stub
APIClient any actual transport call surfaces ErrAPIClientNotConfigured;
once the real Lark client lands, swap NewStubAPIClient for the real
implementation here without touching the Patcher's subscription logic.

doc.go updated to reflect everything the package now contains (Hub,
Patcher, OAuthService, APIClient interface). The Hub itself is NOT
booted here yet — it needs an EventConnector implementation for the
Lark long-connection wire protocol, which lands in a follow-up; the
orchestrator code and its unit tests are in place so that follow-up
can focus on the WS protocol rather than lifecycle plumbing.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): address Elon 二审 5 must-fix items (MUL-2671)

- Hub: renewer cancels run ctx on lease loss so the connector exits
  even if its wire I/O is blocked, keeping the §4.4 ownership
  invariant intact under lease theft.
- Hub: EventEmitter returns (DispatchResult, error) so the real
  connector can post the matching Lark-side card (needs_binding,
  agent_offline, agent_archived) and react to infra failures instead
  of silently logging at the seam.
- Dispatcher: top-level message_id dedup runs before group filter
  and identity check, so a reconnect storm cannot re-fire binding
  prompts or re-spam not_addressed_in_group audit rows; the in-
  AppendUserMessage dedup is removed since the table-level UNIQUE
  is the ultimate backstop.
- OAuth: HandleCallback auto-binds the installer via the new
  InstallerBinder seam (BindingTokenService implements it), so the
  §2.1 "scan to bind, you're done" promise holds end-to-end.
  validateExchangeResult now requires installer open_id; new error
  reason codes wired through the callback redirect.
- Frontend / handler: install_supported listing field + StartLark-
  Install short-circuit on stub APIClient hide install entry points
  (Settings tab + per-agent button) while no real Lark HTTP client
  is wired, so users do not land in an OAuth flow that fails at
  exchange.

Includes tests for each fix (lease-loss cancel, emit error
propagation, dedup ordering, OAuth installer-bind contract, stub-
client install gate) and i18n strings for the new preview state.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): two-phase dedup so infra failures do not swallow messages (MUL-2671)

The pre-fix top-level dedup wrote the lark_inbound_message_dedup row before
EnsureChatSession / AppendUserMessage. An infra error in either step left
the row in place and a WS-adapter retry was mis-classified as a duplicate,
so the user's Lark message was permanently lost without ever landing in
chat_session.

Make dedup two-phase:

  - ClaimLarkInboundDedup acquires an in-flight claim (processed_at NULL).
    Stale claims older than 60 s are re-takeable so a process crash does
    not strand the message_id.
  - MarkLarkInboundDedupProcessed flips processed_at on durable success
    (audit row OR chat_message + session touch).
  - ReleaseLarkInboundDedup deletes the in-flight row on infra failure
    before any durable side effect, so the retry can re-claim immediately.

Dispatcher.Handle now finalizes the claim exactly once based on whether
the inner pipeline reached a durable outcome — chat_message commit being
the transition point (errors past it Mark, errors before it Release).

Regression tests cover the two failure variants Elon flagged plus the
inverse invariants (durable-error Marks, drops Mark, in-flight replays
drop, stale claims re-claim).

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): owner-fence dedup claim to close the double-write windows (MUL-2671)

The two-phase Claim/Mark/Release fix from the previous commit closed the
"infra error swallows a replay" gap but left two windows that could still
write a chat_message twice for the same Lark message_id:

  1. Stale-reclaim race. Worker A claims at t=0, runs slowly past the
     60 s staleness TTL but is still alive. Worker B sees the row as
     stale and re-takes the claim. A reaches AppendUserMessage and
     commits a second chat_message.

  2. Mark window. Worker A commits chat_message but the post-pipeline
     MarkLarkInboundDedupProcessed fails (DB hiccup) or the process
     crashes before it runs. 60 s later a retry treats the in-flight
     row as stale, re-claims it, and writes a second chat_message.

Close both with owner fencing + same-tx Mark:

  - lark_inbound_message_dedup now carries a `claim_token` UUID;
    ClaimLarkInboundDedup mints a fresh one on insert and on stale
    re-take, so a reclaim ROTATES the token.

  - MarkLarkInboundDedupProcessed and ReleaseLarkInboundDedup are
    fenced on (message_id, claim_token, processed_at IS NULL) and
    return rowsAffected. Zero means our token is no longer live, and
    the caller treats it as a no-op (not an error).

  - AppendUserMessage invokes MarkLarkInboundDedupProcessed INSIDE its
    chat_message+session tx (qtx). If the token has been rotated by a
    concurrent reclaim, the Mark matches zero rows and the method
    returns ErrClaimLost; the deferred Rollback unwinds the
    chat_message insert, so the other holder is the sole writer. The
    durable write and the Mark therefore commit (or roll back)
    atomically — there is no "committed but not yet Marked" window
    for a crash or retry to exploit.

Dispatcher.processClaimed now returns a tri-state dedupFinalize directive
(none / mark / release): finalizeNone for the in-tx Mark path (and
ErrClaimLost), finalizeMark for audit-drop branches and the defensive
post-Append-success fallback, finalizeRelease for pre-durable infra
errors. ErrClaimLost is translated into OutcomeDropped + DropReason-
Duplicate at the Handle boundary, matching what the WS adapter expects
for a "another worker is the writer" outcome.

Regression tests:

  - TestDispatcher_StaleReclaimRaceDoesNotDoubleWrite injects worker
    B's reclaim via a beforeAppend hook so the claim_token rotates
    between Claim and AppendUserMessage. Asserts worker A's
    AppendUserMessage returns ErrClaimLost (no chat_message
    committed), the dispatcher surfaces a duplicate drop, the token
    rotated to a value distinct from A's original, and a follow-up
    replay still duplicate-drops.

  - TestDispatcher_InTxMarkPreventsPostCommitReclaim verifies the
    "Mark window" case is unreachable: a successful in-tx Mark
    produces exactly one Mark call (no post-finalize duplicate), the
    row is terminal, and a retry with dedupReclaim=true still
    duplicate-drops without re-rotating the token.

  - TestDispatcher_InTxMarkSucceedsAndSkipsPostFinalize pins the
    positive contract: DedupMarked=true must make applyFinalize a
    no-op (no extra Mark, no Release).

fakeQueries gains a fakeDedupRow model carrying (processed, token,
rotations) so the test seam matches production's UPDATE-with-WHERE
semantics; fakeChat gains a beforeAppend hook to inject race timing.

go test ./... and go vet ./... pass.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): real Lark HTTP APIClient for IM v1 send/patch (MUL-2671)

Lands the production Lark Open Platform HTTP APIClient that replaces
the stub for outbound transport. The patcher's "thinking → streaming
→ final | error" card lifecycle and the dispatcher's binding-prompt
card both now reach Lark for real once MULTICA_LARK_HTTP_ENABLED=true.

Scope of this stage:

  - tenant_access_token retrieval via /open-apis/auth/v3/
    tenant_access_token/internal, cached in-process per app_id with a
    60s safety margin against Lark's `expire` value. Sub-2-minute
    expires are clamped to 120s so we never cache an entry that's
    already past its safe window.
  - SendInteractiveCard: POST /open-apis/im/v1/messages?receive_id_type=chat_id
    returning the Lark message_id the Patcher persists in
    lark_outbound_card_message for later patches.
  - PatchInteractiveCard: PATCH /open-apis/im/v1/messages/:id with
    the full re-rendered card body (Lark's update endpoint replaces,
    not deep-merges).
  - SendBindingPromptCard: open_id-targeted interactive card with a
    primary "去绑定" CTA pointing at the redemption URL. Template is
    co-located with the transport so the dispatcher never has to know
    about Lark's card schema.
  - Token-error invalidation: Lark codes 99991663 (expired) /
    99991664 (invalid) drop the cached token so the next call
    refreshes from /tenant_access_token/internal instead of looping
    on a stale entry.

Out of scope (deferred to follow-up stages):

  - ExchangeOAuthCode stays unimplemented behind
    ErrAPIClientNotConfigured. The PersonalAgent install handshake's
    response shape (returning per-installation app credentials in a
    single call) is not yet verified against the production endpoint,
    and a silent mis-fill of OAuthExchangeResult would corrupt
    lark_installation rows past validateExchangeResult. Operators
    continue to use the manual-paste InstallationService path until
    the OAuth stage lands.
  - Inbound WS EventConnector — Hub's ConnectorFactory still needs a
    real wire-protocol implementation.

Wiring:

  - MULTICA_LARK_HTTP_ENABLED=true switches router.go from the stub
    to the real client. MULTICA_LARK_HTTP_BASE_URL overrides the
    default open.feishu.cn host (set to open.larksuite.com for the
    Lark international tenant, or to an httptest URL for integration
    tests).
  - The OAuth handler now also receives the real client (its
    ExchangeOAuthCode still surfaces ErrAPIClientNotConfigured, so
    callback behavior is unchanged until that stage lands).

Tests (19 new cases against an httptest.Server fake):

  - happy path send/patch/binding-prompt round trips, asserting URL
    query params, body shape, Authorization header
  - token cache: 3 sends share one /tenant_access_token/internal hit
  - token refresh after clock-driven expiry
  - sub-margin expire clamping (10s expire → cached for >= safety
    margin of wall-clock)
  - Lark error code surfacing (230001 send, 230002 patch, 10003 auth)
  - token-expired (99991663) invalidates the cache; caller's retry
    re-fetches and succeeds
  - non-2xx HTTP status surfaces "http 500: …"
  - input validation: missing chat_id short-circuits BEFORE auth
    round-trip, missing card json / open_id / bind url all fail
    pre-flight without hitting Lark
  - ExchangeOAuthCode still returns ErrAPIClientNotConfigured
  - binding-prompt template carries the BindURL and the localized
    "去绑定" CTA in valid JSON

go build ./..., go vet ./..., and go test ./internal/integrations/lark/...
pass. Pre-existing handler/router integration tests that require a
real Postgres connection are unaffected by this change.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): split outbound vs OAuth-install capability + card update_multi (MUL-2671)

Address Elon's two must-fix items from the HEAD a09993b1 review:

1. HTTP outbound and OAuth-install are now distinct APIClient
   capabilities. The new SupportsOAuthInstall() reports whether the
   install flow can succeed end-to-end (i.e. ExchangeOAuthCode is
   implemented); the real httpAPIClient still returns IsConfigured()
   = true (send / patch / binding prompt work) but
   SupportsOAuthInstall() = false until the PersonalAgent install-time
   response shape is pinned. Handler-side `install_supported` and
   StartLarkInstall now gate on SupportsOAuthInstall, so a half-wired
   client never reveals the scan-to-bind UI. larkOAuthErrorReason also
   maps ErrAPIClientNotConfigured to a dedicated
   `oauth_exchange_unimplemented` reason so a raw callback hit no
   longer masquerades as `internal_error`.

2. defaultRenderer now emits config.update_multi=true on every Kind.
   Lark refuses to apply PatchInteractiveCard to a card whose initial
   config doesn't declare it shared/updatable, so the absent flag
   would make every patch after the first send silently no-op on the
   wire while the local outbound status row still flipped to
   streaming/final.

Tests cover both halves of each fix:
- TestHTTPClient_SupportsOAuthInstall_FalseUntilExchangeLands +
  TestHTTPClient_StubReportsBothCapabilitiesFalse pin the new
  capability surface.
- TestStartLarkInstall_TransportOnlyClientReportsNotConfigured +
  TestListLarkInstallations_TransportOnlyClientReportsInstallNotSupported
  pin the handler gate at exactly the half-wired state.
- TestLarkOAuthErrorReason_APIClientNotConfigured pins the mapping
  for both the bare sentinel and the fmt.Errorf-wrapped form
  HandleCallback produces.
- TestDefaultRendererConfigCarriesUpdateMulti covers every CardKind.
- TestHTTPClient_(Send|Patch)InteractiveCard_DefaultRendererBodyHasUpdateMulti
  verify the wire body Lark actually receives carries update_multi
  through both send and patch transport paths.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): real OAuth code exchange + agent-detail bind entry (MUL-2671)

Stages the install side of the MVP critical path on top of the real
HTTP outbound work:

- httpAPIClient.ExchangeOAuthCode runs the production Lark v2 OAuth
  flow: POST /authen/v2/oauth/token to swap the authorization code
  for the installer's open_id, then GET /bot/v3/info under the parent
  app's tenant_access_token to fetch bot_open_id. Result feeds
  InstallationParams unchanged so OAuthService.HandleCallback's
  auto-bind step lights up automatically.

- HTTPClientConfig gains OAuthAppID/OAuthAppSecret, read from the same
  MULTICA_LARK_OAUTH_APP_ID/_APP_SECRET env vars the OAuthConfig
  consumes. SupportsOAuthInstall now mirrors that pair so the install
  capability gate is honest: outbound transport without OAuth creds
  reports configured-but-not-install-supported, exactly like before.

- Agent detail inspector wires the LarkAgentBindButton in a new
  Integrations section, viewer-hidden by canEdit. The button still
  self-hides when SupportsOAuthInstall is false, so a deployment
  without OAuth creds renders the section empty rather than CTA-broken.

- Capability wording cleaned across handler / router / lark-tab to say
  "OAuth-install capability" instead of "real APIClient wired", and
  the misleading TransportOnly... test was renamed/refocused on the
  early-return branch it actually exercises (Elon non-blocking note).

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): identity-only OAuth + atomic bind (MUL-2671)

Addresses Elon's round-4 must-fix items on PR #3277:

1. OAuth v2 token → user_info chain now matches Lark's official
   user-OAuth shape. `httpAPIClient.ExchangeOAuthCode` POSTs
   /open-apis/authen/v2/oauth/token (RFC 6749: top-level
   access_token, NO open_id), then GETs /open-apis/authen/v1/user_info
   with the user_access_token as Bearer to obtain the installer's
   open_id / union_id. The test fixture now reflects the real
   wire shape (separate user_info handler; no synthetic open_id in
   the token response).

2. `OAuthExchangeResult` is identity-only — drops the synthesized
   shared-parent AppID / AppSecret / BotOpenID return that broke
   the UNIQUE(app_id) constraint and the dispatcher's per-app_id
   routing. `OAuthService.HandleCallback` no longer Upserts an
   installation row: it looks up the lark_installation already
   provisioned via the manual-paste POST /lark/installations route
   and binds the installer onto it. Two new typed errors —
   ErrInstallationNotProvisioned and ErrInstallationRevoked — map
   to `installation_not_provisioned` / `installation_revoked`
   reasons at the HTTP boundary so the UI can guide the admin.
   The PersonalAgent install API (which would deliver
   per-installation bot credentials at scan time) remains a
   follow-up; until it lands the OAuth flow is identity-binding
   only and the agent-detail bind button stays hidden on
   deployments without OAuth env (capability gate unchanged).

3. The installation lookup + installer bind run inside a single
   DB transaction so a concurrent revoke / re-provision between
   the read and the binding insert cannot leak a half-applied
   state. `InstallerBinder.BindInstaller` is renamed to
   `BindInstallerTx` and accepts the OAuth-service-owned
   transaction's qtx; the binding_token redemption path is
   unchanged.

4. `validateExchangeResult` is simplified to require only the
   installer's open_id; the obsolete ErrExchangeMissingAppID /
   AppSecret / BotOpenID sentinels are removed (no caller can
   trip them now). The oauth_test suite is rewritten to use a
   stub failTxStarter so tests covering state-token verification
   and exchange-error propagation remain DB-free, while a new
   TestOAuthCallbackOpensTxAfterValidExchange pins the post-must-fix
   order (state ok + exchange ok ⇒ Begin runs before any lookup
   or bind, and a Begin failure aborts cleanly with no bind).

Verified locally:
  - go build ./... / go vet ./... clean
  - go test ./internal/integrations/lark/... ✓
  - go test ./internal/handler -run 'Lark|Binding|OAuth' ✓
  - go test ./internal/util/secretbox/... ./internal/service/... ✓

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): device-flow scan-to-install (MUL-2671)

Replaces the manual paste-credentials install path + identity-only
OAuth callback (rejected in product review: too many steps before a
user sees value) with a true single-step scan-to-install built on
Lark's RFC 8628 device-flow registration endpoint
(POST accounts.feishu.cn/oauth/v1/app/registration) — the same
protocol the official larksuite/oapi-sdk-go/scene/registration
package and zarazhangrui/feishu-claude-code-bridge use.

User journey: admin clicks "Bind to Lark" on the Agent detail page
→ QR dialog opens → admin scans in the Lark app on their phone →
authorizes the new PersonalAgent → dialog auto-closes with the new
installation visible. No app_id / app_secret to copy, no Lark
developer console visit, no Multica-side OAuth env to configure.

Backend (server/internal/integrations/lark):
- registration.go — inline ~280-line RFC 8628 client. Begin posts
  archetype=PersonalAgent / auth_method=client_secret /
  request_user_info=open_id; Poll follows the upstream SDK's
  state machine including the tenant-brand mid-stream domain swap
  to accounts.larksuite.com when a Lark-international account
  authorizes. SDK is NOT vendored — one endpoint isn't worth
  dragging the full oapi-sdk-go + transitive deps.
- registration_service.go — owns the in-process session store
  + background polling goroutine. On success calls APIClient.GetBotInfo
  (the new IM-side endpoint added below) and writes
  lark_installation + the installer's lark_user_binding inside
  one DB transaction so a half-applied install can never land.
  Stable error_reason codes (expired / access_denied /
  lark_protocol_error / bot_info_failed / installation_conflict /
  installer_bind_failed / internal_error) drive the UI copy
  without parsing prose.
- client.go / http_client.go — drops ExchangeOAuthCode and
  SupportsOAuthInstall (no longer applicable: device-flow returns
  identity alongside credentials in one response); adds GetBotInfo
  which mints a tenant_access_token from the freshly-minted
  client_id / client_secret and calls /open-apis/bot/v3/info for
  the bot_open_id. install_supported now gates on IsConfigured()
  (real HTTP client wired) instead of a separate OAuth capability.
- binding_token.go — absorbs InstallerBindParams / InstallerBinder
  (previously in oauth.go), retargets the doc-comment from the
  OAuth caller to the device-flow caller.
- Deletes oauth.go + oauth_test.go entirely.

Handler & router (server/internal/handler, server/cmd/server):
- POST /api/workspaces/{id}/lark/install/begin — opens a new
  registration session, returns {session_id, qr_code_url,
  expires_in_seconds, poll_interval_seconds}. Admin-only.
- GET /api/workspaces/{id}/lark/install/{sessionId}/status —
  polling endpoint, returns {status, installation_id?, error_reason?,
  error_message?}. Workspace-scoped lookup so a stolen session_id
  cannot be polled from another workspace. Admin-only.
- Removes POST /lark/installations (paste form),
  GET /lark/install/start (OAuth-redirect entry), and
  GET /api/lark/install/callback (OAuth redirect target).
- Removes MULTICA_LARK_OAUTH_APP_ID / _APP_SECRET / _REDIRECT_URI /
  _STATE_SECRET / _AUTHORIZE_URL / _SUCCESS_URL env vars. Self-host
  operators no longer need a parent Lark app at all.

Frontend (packages/core, packages/views):
- New types BeginLarkInstallResponse / LarkInstallStatusResponse
  + matching API methods (beginLarkInstall / getLarkInstallStatus);
  drops getLarkInstallURL.
- LarkAgentBindButton opens LarkInstallDialog instead of a
  window.open() to Lark's authorize page. The dialog uses
  react-qr-code (catalog) to render the verification_uri_complete
  inline as SVG (no external CDN image), polls status at the
  server-supplied cadence, auto-closes on success, offers
  "scan again" on terminal failure. Per CLAUDE.md "Enum drift
  downgrades, not crashes", error_reason switch has a default
  fallback so an older desktop build on a newer server still
  renders the generic failure copy.
- Adds the device-flow strings to en + zh-Hans settings.json;
  removes the obsolete OAuth-not-configured copy.

Verified locally:
  - go build ./... / go vet ./... clean
  - go test ./internal/integrations/lark/... — all green
    (existing tests + 15 new registration / GetBotInfo tests)
  - go test ./internal/handler -run 'Lark|Binding' — all green
  - pnpm typecheck — all 6 packages clean
  - pnpm lint — 0 errors (15 pre-existing warnings, none in changed files)
  - pnpm --filter @multica/views test — 859/859 pass

Pre-existing failures in server/internal/middleware (column
"profile_description" missing from local test DB) reproduce against
the parent commit and are unrelated to this change.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): gate bind CTA to workspace admins, terminate QR polling on 4xx (MUL-2671)

Two frontend must-fixes from the PR #3277 二审:

1. LarkAgentBindButton now self-hides for non-admin viewers in addition
   to the existing install_supported check. The agent-detail page mounts
   the button under `canEdit`, which canEditAgent lets agent owners
   through even when they are not workspace admins — but the backend
   gates POST /lark/install/begin and the status poll on owner/admin
   (router.go:478-487), so the previous behavior shipped a CTA that was
   guaranteed to 403. The new gate reads workspace role from the same
   member list the settings tab already uses.

2. The status polling loop now terminates on 404 (session gone — server
   restarted, multi-instance routing, or in-process GC swept it) and
   403/401 (permission revoked mid-session). Previously every error
   path scheduled another setTimeout, which trapped the user on a stale
   QR forever. ApiError gives us the HTTP status verbatim; terminal
   responses set status=error with stable error_reason codes
   (session_lost, forbidden) that flow through the existing dialog
   switch + retry/close affordances. 5xx + network blips still retry.

i18n: new install_error_session_lost / install_error_forbidden in en
and zh-Hans, with default fallback preserved per the enum-drift rule.

Coverage: 6 new vitest cases — admin/owner allow, member deny,
unsupported-install deny, and the two terminal-error polling paths
using fake timers to assert the loop stops scheduling.

Also clears a handful of stale OAuth/manual-install doc comments
flagged in the review (non-blocker cleanup): doc.go's §10 now points
at RegistrationService, installation.go's input-shape doc loses the
OAuth-callback half, and client.go's stubAPIClient comments no longer
reference OAuth callbacks.

Co-authored-by: multica-agent <[email protected]>

* docs(integrations/lark): describe gate as device-flow install in agent-detail integrations comment (MUL-2671)

The comment block above the agent-detail Integrations section still
described the capability gate as 'server-side OAuth-install'. The
OAuth path is gone — install is now device-flow per RFC 8628 — so the
comment now reads 'server-side device-flow install capability gate'.

Pure comment change; behavior is unchanged. Cleans up the nit Elon
called out in PR #3277 二审 (MUL-2671).

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): wire inbound pipeline + WS Hub at boot (MUL-2671)

Stage 3.a of MUL-2671. Hub class, Dispatcher, ChatSessionService and
AuditLogger have all been implemented and tested in prior PRs but
none of them was constructed at boot, so the in-process plumbing
was never exercised end-to-end. This change wires them together
behind the same `MULTICA_LARK_SECRET_KEY` gate that already gates
InstallationService / RegistrationService, and starts the Hub under
the existing `sweepCtx` so it winds down alongside the other
long-running workers after HTTP drain.

The real long-conn EventConnector is still pending; the factory
hands every supervisor a shared NoopConnector that holds the lease
and emits nothing. That lets staging exercise the lease /
supervisor / shutdown lifecycle against real DB rows without
committing to the Lark wire protocol implementation. Swapping in
the real connector is a single line change in the same router
block; the Dispatcher / ChatSessionService / Hub seams stay frozen.

## Why a noop placeholder, not a stub-or-skip

The Hub's value is mostly its lifecycle: §4.4 ownership lease,
LeaseRenewInterval / LeaseTTL, supervisor reap on revoke, clean
release on shutdown. None of that runs unless the Hub is actually
started. Holding off until the real connector lands means the next
PR has to debut both pieces simultaneously; wiring the supervisor
loop first lets the real connector PR be a focused, reviewable
swap.

## Changes

- `internal/integrations/lark/noop_connector.go` — `NoopConnector`
  implementing `EventConnector`: blocks on ctx until the Hub
  cancels (lease loss / shutdown / revoke), emits no events, logs
  on enter/exit so operators see exactly which installation the
  supervisor is holding the lease for.
- `internal/integrations/lark/noop_connector_test.go` — verifies
  the connector blocks until ctx cancel, returns nil on clean exit,
  never invokes the emit callback, and the factory shares a single
  connector instance across installations.
- `internal/handler/handler.go` — new `LarkHub *lark.Hub` field on
  `Handler`. Nil when the Lark integration is disabled.
- `cmd/server/router.go` — inside the existing Lark wiring block,
  construct `AuditLogger`, `ChatSessionService` (with `*pgxpool.Pool`
  for the in-tx dedup Mark), `Dispatcher` (wiring `h.IssueService`
  and `h.TaskService` so `/issue`-created issues share counter /
  duplicate guard / project boundary / broadcast / analytics with
  the rest of the product), and the `Hub` with the
  `NoopConnectorFactory`. `NewRouterWithOptions` now returns
  `(chi.Router, *handler.Handler)` so main.go can drive Hub
  lifecycle; `NewRouter` discards the handler.
- `cmd/server/main.go` — start the Hub under `sweepCtx` after the
  other background workers, and `Wait` on it after HTTP drain +
  sweep cancel so the lease renewer can issue a final release
  before exit. Skipped entirely when `h.LarkHub == nil`.

## Test plan

- [x] `go build ./...` clean
- [x] `go vet ./...` clean
- [x] `go test ./internal/integrations/lark/...` (new noop tests +
      existing hub / dispatcher / chat_service / registration /
      binding_token / outbound / issue_command suites) — all pass
- [x] `go test ./internal/handler -run 'TestLark|TestRedeemLarkBinding'`
      pass — handler-side Lark surfaces unchanged
- [x] `go test ./internal/service/... ./internal/util/secretbox/...`
      pass
- [x] `pnpm --filter @multica/views exec vitest run settings/components/lark-tab`
      pass (6/6) — frontend lark surfaces unchanged
- [ ] Local broad `go test ./internal/handler/...` still blocked by
      the pre-existing test DB schema drift Elon flagged in the
      previous round (`column "metadata" does not exist`,
      unrelated to this change); CI is the authoritative check.
- [ ] Manual end-to-end deferred until the real long-conn
      EventConnector lands (next stage).

MUL-2671

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): bound Hub lease release + shutdown wait (MUL-2671)

Lease release used context.Background(); a stalled DB pool could pin
shutdown indefinitely. Add LeaseReleaseTimeout (5s default) and
ShutdownTimeout (15s default) to HubConfig, route releaseLease through
a bounded context, and expose WaitWithTimeout for main.go so a wedged
supervisor degrades to LeaseTTL expiry on the next replica instead of
blocking process exit. Also correct the LarkHub field comment in
handler.go: the Hub is wired whenever the at-rest secret key is set,
independent of whether the outbound HTTP APIClient is configured.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): real WS long-conn connector + ctx-cancel-breaks-read (MUL-2671)

Replaces NoopConnectorFactory with a production EventConnector that
opens Lark's event-subscription WebSocket. Gated behind
MULTICA_LARK_WS_ENABLED so staging boots stay on the noop path until
operators opt in, and falls back to noop with a warning when the WS
flag is set without MULTICA_LARK_HTTP_ENABLED (the real connector
needs the cached tenant_access_token).

Why this connector exists separately from the Hub: gorilla/websocket
ReadMessage blocks on the underlying TCP socket and does not observe
context. The watchdog goroutine inside WSLongConnConnector.Run closes
the conn the moment ctx fires, so lease loss / shutdown breaks the
blocking read in bounded time — exactly the invariant Hub
renewLeaseUntil's runCancel depends on for the "at most one active WS
per installation across replicas" guarantee. Tests cover this
explicitly (TestWSConnectorRunReturnsOnCtxCancelEvenWhenReadIsBlocked).

The Lark wire surface is split into three swappable seams so the
transport layer stays tested in isolation:

  - EndpointFetcher (POST /event-subscription/v1/connection_token)
    resolves a one-shot wss URL per Run. No caching — replaying a
    one-shot token would look like a Lark outage.
  - FrameDecoder turns one raw JSON envelope into an InboundMessage
    or a "control / heartbeat / drop" verdict. Decoder errors log
    + drop the frame; they do NOT tear down the connection.
  - CredentialsProvider wraps InstallationService.DecryptAppSecret
    so plaintext app_secret lives in memory only during a Run.

Also fixes the handler.go LarkHub comment: it still said "joins on
Wait during graceful shutdown" but main.go has used WaitWithTimeout
(bounded wait) for several commits. Comment now matches.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): align WS to official binary Frame protocol + DispatchResult outbound replies (MUL-2671)

Two must-fix items from Elon's review of PR #3277:

1. WS protocol layer rewritten to match the official Lark Go SDK
   (`larksuite/oapi-sdk-go/v3/ws`):
   - Bootstrap is `POST /callback/ws/endpoint` with AppID/AppSecret
     in the body (no tenant_access_token bearer). Response carries
     wss URL + ClientConfig (PingInterval / ReconnectInterval /
     ReconnectNonce / ReconnectCount).
   - `service_id` is parsed from the wss URL query and used as
     Frame.Service on every outbound frame.
   - Wire envelope is the binary protobuf `pbbp2.Frame` (hand-rolled
     via protowire to avoid pulling the whole SDK in, byte-identical
     field tags). JSON payloads are nested inside Frame.Payload.
   - Inbound data frames are ACKed with a `Response{code:200,...}`
     JSON payload that reuses the inbound headers; infra failures
     produce code=500 so Lark retries.
   - Ping is the app-layer binary `NewPingFrame(serviceID)` at the
     server-supplied cadence; WebSocket protocol PING is removed
     (Lark ignores it). Server-initiated pings get a pong reply.
   - ctx-cancel-breaks-read invariant preserved via the watchdog
     goroutine that closes the conn on ctx.Done; the read loop and
     ping goroutine serialize their writes through a single mutex.

2. `DispatchResult` outbound replies wired via a new `OutcomeReplier`:
   - `OutcomeNeedsBinding` mints a one-shot binding token and sends
     the binding prompt card to the sender's open_id.
   - `OutcomeAgentOffline` / `OutcomeAgentArchived` push a notice
     card into the chat with the agent name + Chinese copy matching
     §4.6.
   - `OutcomeIngested` stays owned by the Patcher; `OutcomeDropped`
     is silent.
   - The replier is best-effort: outbound failures are logged and
     swallowed so a Lark outage cannot stall the inbound pipeline.
   - Hub installs the noop replier by default; router wires the
     production `LarkOutcomeReplier` when APIClient.IsConfigured().

PersonalAgent long-conn risk surfaced (open per Feishu docs:
`长连接模式仅支持企业自建应用`). The implementation works for any
app archetype; the open question is whether `/callback/ws/endpoint`
accepts PersonalAgent credentials in practice. Surfacing the Lark
code+msg verbatim from the bootstrap response so an operator running
the smoke test sees the exact failure rather than a generic timeout.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): byte-compat Frame marshal, chunk reassembly, ACK off reply critical path (MUL-2671)

Three protocol blockers from Elon's review of 9540008a:

1. Frame.Marshal is now byte-identical to oapi-sdk-go/v3/ws/pbbp2.Frame:
   - SeqID/LogID/Service/Method (proto2 req) emit unconditionally even at zero
   - PayloadEncoding/PayloadType/LogIDNew emit unconditionally per gogo
     generated MarshalToSizedBuffer (no zero-guard)
   - Payload uses the SDK's `!= nil` guard (nil omits, []byte{} emits 0-length)
   - ACK payload JSON matches SDK's NewResponseByCode + json.Marshal output
     ({"code":N,"headers":null,"data":null})

   Golden tests pin exact byte sequences for ping/pong/ACK/full/zero
   frames; verified against the real SDK pbbp2.pb.go MarshalToSizedBuffer
   producing identical bytes.

2. Multi-frame events (sum>1) are reassembled via the new chunkAssembler:
   - 5s sliding TTL (matches SDK combine() cache TTL)
   - Lazy GC on admit (no separate sweeper goroutine)
   - Out-of-order seq + duplicate seq idempotent
   - Partial chunks are NOT ACKed (SDK behaviour: only the final chunk's
     ACK confirms the whole event so Lark can retry on partial loss)
   - Connector wires assembler per-Run; state dies with the session

3. OutcomeReplier detached from ACK critical path:
   - HubConfig.ReplyTimeout default 2.5s, strictly under Lark's 3s ACK deadline
   - handleEvent dispatches synchronously (fast DB path), then spawns the
     replier under a fresh background ctx with WithTimeout(ReplyTimeout)
   - Hub.replyWg tracks in-flight replies; Hub.Wait / WaitWithTimeout
     drain them so shutdown is bounded
   - Noop replier short-circuits inline (no goroutine cost when outbound
     APIClient isn't configured)

   Proof tests:
   - TestHubScheduleReplyReturnsImmediately: scheduleReply with a 10s
     slow replier returns in <50ms
   - TestHubReplyTimeoutCancelsHungReplier: hung replier ctx fires at
     ReplyTimeout
   - TestHubWaitDrainsInFlightReplies: Wait blocks until replies finish
   - TestHubACKNotBlockedByOutboundReply: end-to-end through the
     connector — data-frame ACK lands within 500ms even when the
     replier hangs 5s

PersonalAgent real-env smoke remains Bohan's decision; this PR closes
the technical blockers Elon flagged.

Co-authored-by: multica-agent <[email protected]>

* docs(service/issue): narrow position concurrency claim to create-create (MUL-2671)

Elon's review of the merge resolution flagged that the comment on the
new NextTopPosition call promised more than the code guarantees:
concurrent manual reorder via UpdateIssue(position) does NOT take the
workspace row lock that IncrementIssueCounter holds, so a create
racing a reorder can still land on the same position. Rewrite the
comment to only claim create-create serialization, which is the
behaviour the lock actually delivers. No code change.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): keep device-flow polling on RFC 8628 HTTP 400 (MUL-2671)

Lark's device-flow polling endpoint returns HTTP 400 with the JSON
body `{"error":"authorization_pending"}` while the user hasn't scanned
the QR yet — this is the RFC 8628 spec, and the upstream oapi-sdk-go
implements the same handling. Our previous doForm treated ANY non-2xx
as a terminal protocol error, so every install session was killed by
the first poll (~5s after begin) and the install dialog appeared
silently empty: the frontend received status=error +
lark_protocol_error before the user could even read the description.

Fix: doForm now decodes the JSON body first; if it parses, the caller
(Begin / Poll) routes on the body's `error` field, where the existing
switch correctly maps authorization_pending / slow_down to "keep
polling" and access_denied / expired_token to terminal failure. Only
unparseable bodies (5xx HTML proxy pages, gateway timeouts) still
surface as a typed http_NNN RegistrationError.

Three regression tests pin the new behaviour:
- HTTP 400 + authorization_pending → res.Status="authorization_pending"
- HTTP 400 + access_denied → res.Err.Code="access_denied" (terminal)
- HTTP 502 + HTML body → http_502 RegistrationError

Verified against the live local env: install/begin -> 200, status
stays "pending" through the first poll cycle, no longer flips to
"error" within seconds.

Co-authored-by: multica-agent <[email protected]>

* fix(views/lark): reset closedRef on every mount so StrictMode double-mount renders QR (MUL-2671)

Empty QR dialog body in the dev env: Bohan opened the bind dialog and
got an empty white area where the QR should have been — no QR, no
"starting" placeholder, no error text. Backend was returning the QR
URL correctly; the bug was on the frontend.

Root cause: React 19 / Next.js dev StrictMode mounts every component
twice (mount → cleanup → mount). The component instance is REUSED
across the simulated remount, which means useRef objects are
preserved. The dialog's `closedRef` lifecycle:

  1. Mount #1: closedRef={current:false}, beginSession() kicked off
     (HTTP request still in flight)
  2. Cleanup runs: closedRef.current=true
  3. Mount #2: beginSession() kicked off again, BUT the ref still
     reads {current:true} from step 2
  4. Both promises resolve. Both hit the post-await guard
     `if (closedRef.current) return;` and bail out before setSession().
  5. Result: session stays null forever. Every conditional in the
     dialog body (beginning/session-pending/success/error) is false →
     empty body.

Fix: reset closedRef.current=false at the START of the effect, not
just at component construction. The cleanup-then-mount pair now
re-arms the guard so subsequent setSession calls actually land.

Regression test wraps the dialog in <StrictMode> and asserts the
QR appears within 2s with the correct value — fails closed if anyone
removes the reset.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): drop EventTaskCompleted subscription so the chat reply doesn't get overwritten by "Done." (MUL-2671)

Bohan reproduced on the live dev env: agent replies show only a card
saying "Done." in Lark, even though Multica's own chat panel has the
real "Hello! I'm cc…" reply. Tasks succeed end-to-end, but the user
loses the reply on the Lark side.

Root cause: TaskService.CompleteTask publishes two events for every
chat task IN ORDER:

  1. broadcastChatDone(...)       → ChatDonePayload{Content: "Hello!..."}
  2. broadcastTaskEvent(Completed) → map[string]any{task_id, agent_id,...}
                                     (no `content` key)

The Patcher subscribed to BOTH and routed each to finalize(). The
first patch correctly rendered the reply text, the second
patched the same card with an empty payload — chatDoneContent()
returned "" and the renderer fell back to "Done." (default empty-body
copy). The second patch wins because Lark stores whatever was last
applied.

Fix: stop subscribing to EventTaskCompleted in the Patcher and remove
the corresponding switch arm. EventChatDone is the canonical "agent
finished replying" signal for the Lark card path; EventTaskCompleted
is still emitted to the bus for other listeners (web UI, analytics,
task usage) where the lack of content doesn't matter.

Regression test TestPatcherIgnoresEventTaskCompletedForChatTasks
emits ChatDone followed by TaskCompleted on a streaming card and
asserts: exactly one patch, body contains the agent reply, body does
NOT contain "Done.". If anyone re-adds the EventTaskCompleted
subscription, this fails immediately.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): chat replies as plain text IM messages, not card chrome (MUL-2671)

Bohan reported on the live dev env that even with the agent's reply
shown correctly, every message is wrapped in an interactive card with
the agent name as the header — it feels like a system notification,
not a normal chat reply. He wants the reply to land as a regular Lark
text bubble.

Changes:

- Add APIClient.SendTextMessage backed by Lark's
  /open-apis/im/v1/messages with msg_type=text. JSON-encodes the
  {"text": ...} envelope Lark requires so callers pass raw strings.
- Patcher.Register no longer subscribes to EventTaskQueued /
  EventTaskRunning. There is no more thinking → running → final
  card lifecycle on the success path: it added card chrome without
  buying anything for free-form chat.
- On EventChatDone, the new sendChatReply path posts the assistant
  message content as plain text. Empty content is silently dropped
  rather than rendered as "Done." (the prior fallback that
  confused Bohan).
- Failure path keeps a one-shot error card on EventTaskFailed —
  the visual distinction from a normal reply is genuinely useful,
  and failures are rare enough that the chrome isn't noisy.
- Throttle / lastPatched map / MinPatchInterval / shouldPatch /
  markPatched / loadCardOrSkip are all removed; nothing in the new
  flow patches.

Tests:

- TestPatcherSendsPlainTextOnChatDone pins the new contract: exactly
  one SendTextMessage call, no card sends or patches, content
  matches the ChatDonePayload.
- TestPatcherDropsEmptyChatReply pins the "no more Done. fallback"
  decision — empty content drops, period.
- TestPatcherFailEventSendsErrorCard pins the failure path still
  uses a card (one-shot, no patching).
- TestPatcherIgnoresEventTaskCompletedForChatTasks rewritten for
  text path: ChatDone then TaskCompleted yields exactly one text
  send, no duplicate.
- TestPatcherSkipsWhenNoChatSessionBinding and
  TestPatcherSwallowsInstallationLoadErrors rewritten to drive
  EventChatDone (the new entry point) instead of TaskQueued.
- TestPatcherSendsThinkingCardOnTaskQueued deleted (no more
  thinking card).

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): pre-fill PersonalAgent bot name as "<agent> - Multica" (MUL-2823) (#3520)

The device-flow install left the bot at Lark's auto-generated
"{用户姓名}的智能助手". Lark's registration scene supports pre-filling the
name via a `name` query param on the verification/QR URL (mirrors the
upstream SDK's AppPreset.Name) — a user-editable default that rides on
the QR URL, not the begin POST body (which has no name field).

BeginInstall already loads the agent for its ownership check, so we keep
it and thread `<agent.Name> - Multica` through Begin → decorateQRCodeURL.
A blank name degrades to plain "Multica".

There is no post-install rename API (bot/v3 is read-only; no
bot/v3/update), so the install-time pre-fill is the only programmatic
lever; the user can still edit the name on the creation form.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): restore /issue confirmation + pin SendTextMessage wire (MUL-2671)

Two recovered/added contracts off Trump's review of HEAD fe381a07:

1) /issue confirmation in Lark was a casualty of the plain-text
   refactor. The pre-refactor `RenderInput.IssueNumber` field was
   declared but never actually rendered into the card body, so even
   in the original card-based flow the user never saw a "Created
   [MUL-42]" confirmation. Now the OutcomeReplier handles
   OutcomeIngested + IssueID.Valid by sending a plain text message:

     Created MUL-42 — fix login bug
     https://multica.example/issues/MUL-42

   Composed from a new DispatchResult.IssueIdentifier +
   IssueTitle, populated by the Dispatcher from
   workspace.IssuePrefix + issue.Number / issue.Title. Workspace
   lookup is best-effort: a Postgres blip on workspace gets a "#42"
   fallback rather than silently dropping the confirmation.

   The agent's own chat reply (if any) continues to land separately
   via the Patcher on EventChatDone — these are two semantically
   distinct messages and the user benefits from seeing both.

2) SendTextMessage is the wire layer Trump flagged for missing
   coverage. Three new wire tests pin:
   - happy path: POST /open-apis/im/v1/messages?receive_id_type=chat_id,
     msg_type=text, Bearer <tenant_access_token>, double-JSON
     content envelope
   - special-character round trip: newlines, double quotes,
     backslashes, tabs, Chinese + emoji, JSON-lookalike strings.
     The inner {"text": ...} is encoded once at JSON.Marshal time
     and once again when the outer body serializes; losing either
     pass corrupts the message and the bug is invisible without a
     contract pin.
   - Lark error path: non-zero `code` surfaces as a wrapped error
     with the code embedded.

Tests:
- TestDispatcher_IssueCreationFromCommand asserts IssueIdentifier
  ("MUL-42") and IssueTitle propagate through DispatchResult.
- TestDispatcher_IssueIdentifierFallsBackToNumberOnWorkspaceLookupErr
  pins the "#7" degrade-graceful fallback.
- TestLarkOutcomeReplierIssueCreatedSendsConfirmation pins the
  text body (identifier + title + deep link) and asserts no card
  send on this path.
- TestLarkOutcomeReplierOutcomeIngestedSilentWithoutIssue pins
  the silent-on-plain-chat default so we don't accidentally start
  emitting a confirmation for every message.
- TestHTTPClient_SendTextMessage_* covers the wire contract.

Frontend locale parity (en + zh-Hans, 53 tests) is currently green
on this HEAD; no changes needed.

Co-authored-by: multica-agent <[email protected]>

* fix(views/locales): add missing ko keys for Lark MVP (MUL-2671)

Trump flagged on PR #3277 review that the ko bundle was missing the
Lark-MVP-only keys that en + zh-Hans both carry. The parity test
caught it cleanly after main was merged in (Korean PR landed on main
between the prior review and this one):

  common.lark_bind.*                       (13 keys)
  settings.page.tabs.lark                  (1 key)
  settings.lark.*                          (45 keys)
  agents.inspector.section_integrations    (1 key)

Korean translations are professional/concise — "Lark" stays as the
brand name (matches how en keeps "Lark" + "(飞书)" parenthetically;
ko/users searching for the product expect "Lark"), and product copy
follows the zh-Hans tone where Multica nouns ("에이전트", "워크스페이스")
are romanized loan words consistent with the rest of the ko bundle.

Slot ordering preserved against EN:
  - page.tabs.lark sits between github and integrations
  - inspector.section_integrations sits right after section_skills

Verified: pnpm exec vitest run locales/parity → 105/105 pass.
Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): /issue origin_type CHECK + Hub restart on credentials rotation (MUL-2671)

Two live-env bugs Bohan reproduced:

1) /issue command crashed the WS connector. Dispatcher writes
   origin_type='lark_chat' on issues born from `/issue`, but the
   issue_origin_type_check CHECK constraint was last extended in
   migration 060 for quick_create — it doesn't list lark_chat, so
   every Lark /issue tripped SQLSTATE 23514 and bubbled up as an
   infra error. The infra error tore down the WS connector, Lark
   retried the same message, the new connector tripped the same
   constraint and crashed again. Repro in the live env: three
   crashes from the same /issue event over ~40s, each leaving the
   user with no confirmation in Lark.

   Migration 111 extends the CHECK list:
     CHECK (origin_type IN ('autopilot', 'quick_create', 'lark_chat'))

2) Re-scanning an already-bound agent silenced the bot. The device
   flow re-registers with Lark, which mints a brand-new bot (fresh
   app_id + app_secret); RegistrationService.finishSuccess upserts
   into lark_installation by agent_id, so the row's credentials
   rotate in place. But the running supervisor held the OLD inst
   struct by value and kept a WS open against the OLD bot's app_id —
   so all events to the NEW bot went nowhere. Bohan's "claude code
   现在不能在飞书里回复了" symptom maps exactly to this:

      log timeline:
      16:29:57  cc connector connected with app_id=cli_aa9398dd...  (OLD)
      16:34:07  lark registration: install complete                  (rotation)
      → row.app_id is now cli_aa93f36f...                            (NEW)
      → old WS still subscribed to OLD app_id; new app_id receives nothing

   Fix: Hub.sweep now compares each installation row's credentials
   fingerprint (app_id + bot_open_id + sha256(app_secret_encrypted))
   against the snapshot the running supervisor was started with. On
   diff, cancel the old supervisor and start a fresh one inline. A
   monotonic gen counter on the supervisor entry disambiguates the
   old goroutine's deferred cleanup from the new entry the rotation
   path already swapped in.

Tests:
- TestHubRestartsSupervisorOnCredentialsRotation pins the new path:
  starts hub on app_one, rotates the row to app_two, asserts the
  connector factory is called again with the fresh AppID.
- TestHubDoesNotRestartSupervisorOnUnchangedRow pins the negative
  case so an unchanged row doesn't degenerate into a per-sweep
  busy-loop.
- Existing hub tests (lease, supervise, shutdown, ACK timing,
  noop replier) all green.

Verification:
- go test ./internal/integrations/lark/... -race -count=1   ok
- go build ./... clean
- migration applied locally; \d+ issue confirms lark_chat in CHECK

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): per-supervisor lease token to fence rotation handoff (MUL-2671)

Elon flagged a race in HEAD be8d4cef's rotation path: both the old
and the new supervisors of the same Hub used the hub-wide nodeID as
their WS lease token, so an old supervisor's post-cancel
releaseLease(nodeID) would CAS-match the lease row the successor had
just acquired with the SAME token and DELETE it. Symptom would be a
silently empty lease row a few hundred ms after every device-flow
re-scan — no replica owning the install, no events delivered, the
"bot goes quiet" pattern Bohan hit the first time but now from the
fencing side rather than the credentials side.

Fix: leaseToken(nodeID, gen) composes "<nodeID>-g<gen>", where gen is
the monotonic counter already attached to each supervisorEntry. The
nodeID prefix keeps cross-replica observability (an operator
inspecting lark_installation.ws_lease_token can still map back to a
process) while the -g suffix makes the OLD supervisor's release
target the OLD row state. Once the rotation path swaps in the new
supervisor, the row's CurrentToken is the new -g(N+1) token, so the
old -gN release's WHERE clause no-ops instead of clobbering.

acquireLease / renewLeaseUntil / releaseLease now take an explicit
token argument; supervise threads its leaseToken through. The
plumbing isn't pretty, but having an explicit argument at every call
site is the only way the rotation invariant survives subsequent
refactors — without it, a future caller could quietly reintroduce
"just use h.nodeID" and the race is back.

Two regression tests:

- TestHubRotationStaleReleaseDoesNotClearSuccessorLease drives the
  fake lease state machine directly:
    1. old acquires(tokenA)
    2. rotation lands; new acquires(tokenB)
    3. old's stale release(tokenA) fires
  Asserts owner ends up still tokenB. Hub-wide-nodeID code would fail
  step 3 by clearing the entry.

- TestHubRotationEndToEndKeepsSuccessorLeased runs the same scenario
  through the live supervise loop: starts hub, rotates the row, waits
  for sup2 to take over with a distinct token, sleeps past sup1's
  unwind, asserts the row is still held by a non-sup1 token. Catches
  the bug even when the goroutine timing is non-deterministic.

Verification: go test ./internal/integrations/lark/... -race -count=1   ok
  go build ./...                                            clean
  go vet ./...                                              clean
Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): route group @-mentions via union_id, not open_id (MUL-2671)

In a Lark group with multiple Multica bots installed, the bot whose WS
received the event sometimes failed to recognize that it was the @-target
while the OTHER bot's supervisor falsely fired. Bohan's controlled three-
message test (only @A, only @B, @both) hit this: @A and @B alone went
unanswered, @both got picked up by A only.

Root cause: the `mentions[].id.open_id` field Lark puts on the WS event
is structurally INVERSE to `/bot/v3/info`'s `bot.open_id` across the two
WSes. From A's WS perspective, the wire-form open_id for "A was @-ed"
is NOT equal to A's API-side open_id, but IS equal to what B's WS sees
on its side, and vice versa. The decoder's `mention.open_id ==
inst.BotOpenID` match therefore fires on the wrong bot in multi-bot
groups. Only `union_id` (the Lark-tenant-scoped stable identifier) is
consistent across both WSes.

Changes:
- migration 112 adds nullable `lark_installation.bot_union_id`
- sqlc query exposes UpsertLarkInstallation/CreateLarkInstallation
  with bot_union_id, plus a focused SetLarkInstallationBotUnionID for
  the backfill path
- httpAPIClient.GetBotInfo now follows /bot/v3/info with /contact/v3/
  users/{open_id}?user_id_type=open_id and returns both identifiers
  on BotInfo. Soft-fails on contact-scope denial: install still
  succeeds with an empty UnionID, and the decoder falls back to the
  legacy open_id match for single-bot deployments.
- RegistrationService.finishSuccess persists union_id alongside
  open_id during the device-flow finalize.
- ws_frame_decoder.containsMention prefers union_id and only walks
  open_id when the installation row has not been backfilled yet.
- BackfillBotUnionIDs runs once at server boot for installations
  created before migration 112; bounded per-row 10s timeout and a
  pure soft-fail policy so a slow Lark round-trip cannot block
  startup.
- regression tests cover the three decoder paths: union_id match
  wins over open_id mismatch, union_id mismatch overrides open_id
  match, and open_id fallback when union_id is unknown.

Co-authored-by: multica-agent <[email protected]>

* chore: drop trailing blank lines at EOF on four files (MUL-2671)

git diff --check origin/main..origin/pr-3277 flagged these as new
blank lines at EOF; clearing so the diff stays clean for review.

Co-authored-by: multica-agent <[email protected]>

* fix(views/locales): add missing ja keys for Lark MVP + section_integrations (MUL-2671)

CI frontend job tripped on the ja locale parity check: ja is missing
the lark_bind block in common.json, the lark block + page.tabs.lark
in settings.json, and inspector.section_integrations in agents.json.
The ko fix earlier covered Korean; ja was added separately on main
and the merge surfaced these gaps. Translations mirror the en source
and follow the same voice as the existing ja bundle.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): rewrite @_user_N placeholders into clean body (MUL-2671)

When Lark dispatches a group `im.message.receive_v1`, the message
text contains opaque `@_user_1`, `@_user_2`, … placeholders and the
real identity is in `mentions[]`. We were forwarding the raw text to
the agent, so a Bohan-typed "@Bot ping test" arrived as "@_user_1
ping test" — neither human-readable nor useful as LLM context, and
the agent was paying tokens to figure out which `@_user_N` was even
itself.

The new resolveMentions pass:
  * strips the bot's own mention entirely (the dispatcher already
    routes the event on AddressedToBot; re-emitting @<self> in front
    of every message adds zero signal and pollutes context),
  * substitutes other participants with `@<displayName>` so a
    follow-up "@Alice" reads naturally,
  * collapses horizontal whitespace introduced by the strip while
    preserving original newlines.

Bot identity check uses the same union_id-preferred + open_id
fallback as containsMention, so the rewrite stays consistent with
the routing path. Tests cover the four shapes: bot self-mention,
mixed bot + other-user mention, multi-line body with stripped
mention, and a no-mention body that should be left untouched.

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): union_id-first self mention strip + token-aware scan + local whitespace cleanup (MUL-2671)

Three review blockers on the mention rewrite from PR review:

1. isBotMention now mirrors containsMention's union_id-first policy.
   When the installation row knows our union_id, we trust it
   exclusively (open_id is structurally inverted in multi-bot
   groups — matching on it would re-introduce the routing bug we
   fixed two commits ago). open_id fallback fires only when
   union_id is absent. New tests: @-ing both bots in one message
   correctly strips only self and renders the sibling as @<name>;
   open_id-matches-but-union_id-differs does NOT strip.

2. resolveMentions no longer collapses or trims whitespace globally.
   Indentation, tabs, code blocks, tables — all preserved verbatim.
   When the self mention is removed we eat exactly one adjacent
   horizontal space (the one after the placeholder, or, when the
   mention sits at end-of-input, a single space already emitted
   right before it). New test exercises a multi-line indented +
   tabbed body and asserts the whole shape survives.

3. Prefix-collision-safe replacement. A chat with 11+ participants
   exposes both `@_user_1` and `@_user_10`; naive ReplaceAll for
   `@_user_1` would mangle the substring of `@_user_10`. The
   resolver now does a single-pass token scan with the mention
   list sorted longest-key-first, so the longer placeholder always
   wins at any scan position. New test covers the @_user_1 /
   @_user_10 case explicitly.

Also drops the temporary INFO-level diag logging the previous
commit added — root cause was confirmed (union_id swap in the
manual backfill; not a decoder bug).

Co-authored-by: multica-agent <[email protected]>

* fix(integrations/lark): scope inbound dedup per (installation_id, message_id) (MUL-2671)

Root cause of the residual "@Cc gets dropped as not_addressed_in_group"
even after the union_id swap landed: lark_inbound_message_dedup was
keyed on `message_id` alone. In a Lark group chat where the workspace
has multiple Multica bots installed, Lark delivers the SAME message_id
to every bot's WS supervisor. Whichever WS claimed first then ran its
own AddressedToBot check; the bot that was actually @-ed lost the dedup
race, found the row already terminal (`processed_at IS NOT NULL`), and
was dropped as `duplicate` BEFORE it could evaluate its own mention.
Net: every @ silently disappeared if Lark happened to route the OTHER
bot's WS first.

The dedup gate's original purpose (idempotency against WS reconnect
replay) is per-installation by definition, so the right key is
composite (installation_id, message_id).

Changes:
- migration 113 drops + recreates lark_inbound_message_dedup with
  installation_id NOT NULL REFERENCES lark_installation(id) ON DELETE
  CASCADE and PRIMARY KEY (installation_id, message_id). The table is
  a 24h transient cache, so dropping existing rows is safe.
- sqlc queries: ClaimLarkInboundDedup / MarkLarkInboundDedupProcessed /
  ReleaseLarkInboundDedup all now take installation_id.
- AppendUserMessageParams carries InstallationID through to the
  in-tx Mark call so the chat_message+dedup atomicity stays intact.
- Dispatcher passes inst.ID to claim + applyFinalize + AppendUserMessage.
- Test fakes key dedup state on (installation_id, message_id) via a
  composite map key; all existing pre-seeded rows use a seedDedupKey
  helper bound to the default activeInstallation fixture so the prior
  staleness / token-rotation / in-tx mark tests still exercise the
  same regression they did before.
- New regression TestDispatcher_DedupIsScopedPerInstallation pins the
  multi-bot invariant: a row pre-seeded for installation A does NOT
  block installation B's first delivery of the same message_id; B
  runs through its own group-filter / identity / ingest pipeline.

Co-authored-by: multica-agent <[email protected]>

* feat(integrations/lark): render markdown chat replies via schema-2.0 card (MUL-2671)

The agent's chat replies were going out as msg_type=text, so every
`**bold**`, fenced code block, list, table, and link in the body
showed up as literal markdown characters in Lark — the user saw raw
asterisks, hashes, pipes instead of formatted text. Bohan reported
this and pointed at zarazhangrui/lark-coding-agent-bridge as the
shape to emulate.

The bridge repo uses Lark interactive cards with the schema-2.0
envelope and a `tag: "markdown"` body element; Lark's client
renders that to formatted text (GFM-ish: bold/italic, headings,
lists, links, fenced code blocks, tables, blockquotes). They expose
multiple reply modes (card / markdown-as-post / text) gated by user
config; we go a step simpler — auto-detect markdown syntax in the
agent's body and route accordingly:

- containsMarkdown(): cheap substring + regex pass for fenced code
  blocks, headings, list markers, bold/italic, tables, links,
  blockquotes, horizontal rules, inline code. Biases toward false-
  positive — wrapping prose in a card still renders fine, but
  missing a real markdown block leaves raw characters visible.

- APIClient gains SendMarkdownCard / SendMarkdownCardParams.
  Implementation marshals the schema-2.0 envelope verbatim:
  {schema:"2.0", body:{elements:[{tag:"markdown", content: md}]}}.
  Stub returns ErrAPIClientNotConfigured.

- Patcher.sendChatReply now branches on containsMarkdown:
  markdown → SendMarkdownCard, plain prose → SendTextMessage. A
  one-liner "sure, on it" stays as a normal IM bubble (no card
  chrome); anything with markdown gets the rendered card.

Tests: TestContainsMarkdown pins the heuristic across plain prose
and ten markdown shapes; TestPatcherRoutesMarkdownReplyToCard and
TestPatcherRoutesPlainReplyToText cover the router; new HTTP wire
test TestHTTPClient_SendMarkdownCard_HappyPath contract-pins the
card envelope (msg_type=interactive, schema 2.0, markdown tag,
verbatim body). Full lark suite passes.

Co-authored-by: multica-agent <[email protected]>

* fix(service/issue): route analytics.IssueCreated through obsmetrics.RecordEvent (MUL-2671)

CI's TestNoNakedAnalyticsCaptureInHandlersOrServices guard caught the
post-merge analytics call in IssueService.captureCreatedAnalytics
that still used s.Analytics.Capture(...) directly. Main added that
lint to prevent the Prometheus and PostHog sides from drifting — any
new analytics.* event must go through obsmetrics.RecordEvent so the
business-metrics collector and the PostHog client fire from the same
call site.

Fix mirrors how TaskService handles it: IssueService gains a
Metrics *obsmetrics.BusinessMetrics field (router wires it via
h.IssueService.Metrics = opts.BusinessMetrics next to the existing
TaskService line), and the in-service Capture call becomes
obsmetrics.RecordEvent(s.Analytics, s.Metrics, ...). nil-safe by
construction — RecordEvent treats a nil Metrics as PostHog-only.

Co-authored-by: multica-agent <[email protected]>

* feat(views/lark): swap Bind CTA for Connected+Manage link when agent already has an installation (MUL-2671)

Bohan reported the agent-detail Bind button keeps inviting the user to
re-scan the QR even when the agent already has an active Lark
PersonalAgent connected — and re-scanning silently upserts the
installation row, leaving the previously-created Lark bot dangling
as a zombie. Frustrating UX and an actual product footgun.

Anti-zombie guard at the only entry point: LarkAgentBindButton now
checks the cached installations listing for an active row pinned to
this agent_id. When one exists, the install CTA is gone — replaced
by a small Connected pill + an "Manage in Lark" link that opens the
Bot's app page in Lark's developer console (open.feishu.cn/app/<app_id>)
in a new tab. That's where scopes, display name, and additional
permission requests actually live; re-scanning never was the right
answer for managing an existing bot.

Scoping is per-agent: an active installation on a DIFFERENT agent
in the same workspace doesn't affect this agent's button, and a
revoked installation falls back to the bind CTA so the user can
re-create. Tests cover all four states (own-active / own-revoked /
other-agent-active / no-installation) and pin the Manage link's
href + target=_blank + noopener.

i18n: three new keys in settings.json (en / zh-Hans / ja / ko):
agent_bot_connected_label, agent_bot_manage_link,
agent_bot_manage_tooltip. Locale parity test still 157/157.

The dev console host is hardcoded to open.feishu.cn — operators
on the Lark international tenant currently get the wrong host;
future-proof fix wants the backend to surface a per-installation
dev_console_url on the listings response, called out in a code
comment.

Co-authored-by: multica-agent <[email protected]>

* feat(views/settings): collapse Lark into Integrations + render agent identity (MUL-2671)

Lark was its own top-level workspace settings tab while Integrations sat
empty next to it. As more integrations land, the sidebar would balloon
with one tab per provider. Move the Lark surface into Integrations as
the first hosted integration; the old ?tab=lark URL redirects through
LEGACY_WORKSPACE_TAB_REDIRECTS so bookmarks still resolve.

The Connected bots list was leaking the raw Lark app_id (cli_…) as the
row title with bot_open_id (ou_…) underneath — meaningless to product
users. Since the binding is 1:1 with a Multica Agent, join on agent_id
and render the agent's avatar + name via the workspace-standard
ActorAvatar + useActorName.getAgentName. Deleted agents fall back to
"Unknown Agent" so the row is still actionable for cleanup.

Tests: stub useActorName + ActorAvatar in lark-tab.test.tsx and add
LarkTab connected-bot tests covering the agent identity render and the
deleted-agent fallback. Drop the now-dead integrations.* + page.tabs.lark
+ lark.bot_open_id_label keys across all four locales — parity still
157/157, views suite 1141/1141.

Co-authored-by: multica-agent <[email protected]>

* feat(views/settings): wrap Lark in a named section inside Integrations (MUL-2671)

Integrations is meant to host multiple providers (Slack, Linear etc. as
they land), so the Lark content should sit under a Lark heading rather
than fill the tab directly — otherwise the first additional integration
would feel like it broke the IA. Add a "Lark" / "飞书" section heading
above LarkTab using the same h2 chrome the other settings tabs use, and
pin lark.section_title across all four locales (parity 169/169).

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-06-03 19:12:14 +08:00
341ce7bfa5 feat: support local working directory for projects (MUL-2618 v1) (#3283)
* feat(project): add local_directory project_resource type (MUL-2662)

Adds a second project_resource type alongside github_repo so a project
can be pinned to an existing directory on a specific daemon (the v1 of
the local-working-directory flow tracked in MUL-2618). The ref schema is
{ local_path, daemon_id, label? }; local_path must be absolute and
daemon_id is required. The same (daemon_id, local_path) pair is allowed
on multiple projects by design — no UNIQUE constraint is added.

Implementation reuses the existing project_resource API surface: the new
type is wired through the validator switch with no migration, no new
events, and no daemon-handler changes (daemon already passes through
arbitrary resource types via ProjectResources). The CLI gains
--local-path / --daemon-id / --ref-label shortcuts so
`multica project resource add --type local_directory` mirrors the
existing `--type github_repo --url ...` ergonomics; the generic --ref
flag still works for both types.

Tests cover the full CRUD lifecycle, the same-path-across-projects
allowance, the same-path-same-project conflict, the validator rejections
(missing/blank/relative path, missing daemon_id, wrong payload type),
and the cross-platform isAbsoluteLocalPath helper.

Co-authored-by: multica-agent <[email protected]>

* feat(project): add update endpoint + label-shadow guard for project_resource (MUL-2662)

Addresses the Elon review on PR #3263:

- Add PUT /api/projects/{id}/resources/{resourceId} with sqlc query,
  matching handler, CLI `project resource update`, and a new
  EventProjectResourceUpdated WS event. resource_type stays immutable;
  ref/label/position are all individually optional.
- Catch same-project (daemon_id, local_path) collisions where only the
  embedded label differs — the row-level UNIQUE only matches the full
  ref JSON, so a label typo would otherwise let the same working
  directory bind twice.
- Tests cover the update lifecycle (label-only / ref / clear / 404 /
  invalid path) and the label-shadow conflict on both create and
  update; the in-place rename still succeeds because the conflict
  scan ignores the row being edited.

Incidental: regenerating sqlc picked up a missing skills_local scan in
UpdateAgentCustomEnv that drifted in from #3200.

Co-authored-by: multica-agent <[email protected]>

* fix(project): close bundled-create label-shadow gap + merge resource_ref on CLI update (MUL-2662)

Two follow-ups from MUL-2662 review round 2:

- CreateProject inline resources path now dedupes local_directory entries on
  (daemon_id, local_path) before opening the transaction. The DB-level
  UNIQUE(project_id, resource_type, resource_ref) constraint only fires on a
  full JSON match, so two rows with the same target but different `label`
  would otherwise slip past. Standalone POST/PUT already cover this via
  findLocalDirectoryConflict; bundled create was the missing surface.
- `multica project resource update` now seeds resource_ref from the existing
  row before applying per-type shortcut flags, so `--default-branch-hint x`
  on its own no longer constructs a payload missing `url` (which the server
  400s on). Local_directory partial edits get the same merge behavior.

Co-authored-by: multica-agent <[email protected]>

* feat(desktop): local_directory project_resource UI (MUL-2665) (#3273)

* feat(desktop): local_directory project_resource UI (MUL-2665)

First UI surface for the local-working-directory flow tracked in MUL-2618.
Lets users on the desktop pin a project to an existing folder on this
machine; web stays read-only since the per-daemon check can't be done in
the browser.

What's new for the renderer:

- ProjectResourcesSection grows a desktop-only "Add local directory"
  button next to the existing GitHub-repo popover. Clicking it opens
  Electron's native folder picker, validates the path through a new
  IPC pair (existence + r/w), and submits a project_resource of
  resource_type=local_directory with daemon_id pulled live from
  daemonAPI.getStatus.
- LocalDirectoryRow renders the rename pencil + path tooltip, and
  greys out when ref.daemon_id != this machine's daemon_id (with a
  "only available on the machine that registered this directory"
  tooltip). Delete stays enabled so users can drop stale registrations
  from any device.
- LocalDirectoryHint sits above the issue-detail comment composer and
  shows "Agent will work in-place at {label} ({path})" when the issue's
  project has a local_directory matching this daemon. Hidden on web.
- TaskStatusPill picks up a new "waiting_for_directory_release" stage
  that the daemon will publish when it dequeues a task but can't
  acquire the path lock. The render is in place now so the daemon
  sibling subtask can wire the status string without an additional UI
  PR.

Plumbing:

- @multica/core/types gains LocalDirectoryResourceRef +
  UpdateProjectResourceRequest, and the api client gets the matching
  PUT method backed by the server endpoint that landed in
  2ac3faebb (MUL-2662). A useUpdateProjectResource hook drives the
  in-place label edit.
- New Electron handlers under apps/desktop/src/main/local-directory.ts:
    local-directory:pick     -> dialog.showOpenDialog (openDirectory)
    local-directory:validate -> stat + access(R_OK + W_OK)
  exposed through the preload as desktopAPI.pickDirectory /
  validateLocalDirectory. View code talks to them via a thin
  packages/views/platform helper that returns reason=unsupported on
  web instead of crashing.
- useLocalDaemonStatus exposes the local daemon's id, device name, and
  running flag from daemonAPI.onStatusChange so the renderer can do the
  cross-device match without coupling to the desktop preload typings.

Tests:

- pickStageKeys gets a unit test covering the new stage and proving
  the directory-release status outranks availability hints.
- LocalDirectoryHint tests cover the four render branches (no project,
  no daemon, foreign daemon, matching daemon).
- i18n parity stays green; new keys added under projects.resources.*
  and chat.status_pill.stages.waiting_for_directory_release in both
  locales.

Out of scope (will land separately):
- The daemon-side waiting/lock signal that flips the pill into the
  new state.
- Adding local_directory to the create-project modal's bulk
  attach flow.
- Docs page refresh for project-resources.mdx — left for the
  MUL-2618 umbrella sweep.

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): hide rename for foreign daemon local_directory rows (MUL-2618)

Address review nit on #3273: the rename pencil was gated only by
`canEdit`, so a foreign / unknown-daemon row still showed it even
though the spec says cross-device rows are disabled. Gate rename on
`!mismatch` so it disappears on those rows; delete stays available
so a stale registration can still be dropped from any device.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663) (#3274)

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663)

Wires up the daemon side of the local_directory project_resource introduced
in MUL-2662. When a task is dispatched against a project whose resources
include a local_directory pinned to this daemon's UUID, the daemon now:

  - Validates the path (absolute, exists, daemon process can read+write,
    not in the system-root / $HOME blacklist) and fails the task fast on
    any precondition violation, with a user-readable reason.
  - Serialises concurrent tasks on the same on-disk path via a
    daemon-local LocalPathLocker keyed by symlink-resolved realpath. The
    lock is held for the entire task lifetime (claim → context write →
    agent → result report).
  - When the lock is contended, the daemon flips the row to a new
    waiting_local_directory status on the server (carrying a wait_reason
    like "<path> (held by task <short id>)") so the UI can render
    "等待本地目录释放" instead of leaving the row silently in dispatched
    past the sweeper timeout. The status accepts being woken into running
    once the lock is acquired.
  - Sets execenv.WorkDir to the user's path (no copy, no mount). envRoot
    still lives under workspacesRoot/<wsID>/ and hosts output/, logs/, and
    .gc_meta.json — the daemon's logbook for the run.
  - Stamps GCMeta.LocalDirectory=true so the GC loop never RemoveAlls
    envRoot for these tasks (gcActionClean → gcActionCleanArtifacts,
    gcActionOrphan → gcActionSkip). The user's directory was never under
    envRoot to begin with, so this is defense in depth.
  - Skips execenv.Reuse for local_directory tasks because the prior
    WorkDir is the user's path and reusing it through that code path
    loses the envRoot association the GC loop needs. Prepare is cheap
    here (no clone, no copy), so always running it is fine.

Server-side protocol changes:

  - New CHECK value 'waiting_local_directory' on agent_task_queue.status
    plus a wait_reason TEXT column (migration 109).
  - All cancel / active / counted-as-running / orphan-recovery queries
    expanded to include the new status; FailStaleTasks intentionally
    excludes it (the daemon owns the wait).
  - New SQL MarkAgentTaskWaitingLocalDirectory(id, reason) and a relaxed
    StartAgentTask that accepts both dispatched and
    waiting_local_directory as preconditions (and clears wait_reason on
    the way through).
  - New POST /api/daemon/tasks/{taskId}/wait-local-directory endpoint,
    TaskService.MarkTaskWaitingLocalDirectory broadcaster, and matching
    daemon Client.MarkTaskWaitingLocalDirectory.

Tests cover: path blacklist + R/W enforcement, mutex serialisation +
ctx-cancelled wait, lock handover between two tasks, GC never returns
gcActionClean / gcActionOrphan for local_directory rows (with negative
control for the standard path), and Prepare/Cleanup correctly substitute
+ protect the user's WorkDir.

The desktop UI side (UI for adding a local_directory resource, surfacing
the "等待本地目录" badge) is MUL-2665; the agent-task lifecycle changes
(no branch switch, dirty-tree tolerant, auto-commit) are MUL-2664.

This PR targets the shared MUL-2618 v1 feature branch agent/j/912b8cb1,
not main; the whole v1 will be merged to main together when complete.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): tighten local_directory status, symlink, cancel handling (MUL-2618)

Address the 3 must-fix items from Elon's review of PR #3274.

1. Status string unified. The server / daemon publish
   `waiting_local_directory`; align views, locales, and the
   pickStageKeys test (PR #3273 had used `waiting_for_directory_release`
   on a placeholder string). Without this, the daemon's wait state
   never reached the pill once the two siblings merged.

2. validateLocalPath now also runs the blacklist against the
   symlink-resolved realpath, with macOS's `/etc` -> `/private/etc`
   redirect handled via `isBlacklistedRealPath` which compares
   canonical forms. Without this, a symlink such as
   `/Users/me/proj/home -> /Users/me` slipped the literal $HOME check
   while every daemon write still landed in the user's home. Tests
   cover symlink-to-home, symlink-to-system-root, and the negative
   case (symlink to a regular subdirectory).

3. acquireLocalDirectoryLockIfNeeded now spins up a cancellation
   watcher inside `onWait` (lazy — the fast path stays free) so the
   gap between dispatch and StartTask responds to server-side cancel
   or row deletion. If the watcher fires while the daemon is parked
   on the path mutex, the lock-wait context is cancelled, Acquire
   returns promptly, and the helper exits silently the same way the
   run-phase poller does. New TestAcquireLocalDirectoryLock_CancelDuringWait
   exercises the path end-to-end with a fake server.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): unconditional canonical blacklist + Windows drive-root generalisation (MUL-2618)

- validateLocalPath now always runs isBlacklistedRealPath on the
  symlink-resolved path, not only when it differs from absPath. The old
  guard let users type the canonical form of an OS-symlinked banned root
  (e.g. /private/tmp, /private/etc, /private/var on macOS) straight
  through, since EvalSymlinks is a no-op on already-canonical input.
- Windows drive-root rejection moved off the static C/D/E/F enumeration
  onto filepath.VolumeName via a new isDriveRoot helper, so removable /
  network drives mounted at G:..Z: and UNC \\server\share roots are also
  blocked. systemRootBlacklist keeps the well-known C:\ trees only.
- Tests: macOS-only case exercises direct /private/{tmp,etc,var}; a
  new TestIsDriveRoot covers the Windows generalisation (skipped on
  POSIX runners by runtime guard).

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>

* feat(views): wire waiting_local_directory end-to-end in issue UI + presence (MUL-2618)

Connect the daemon-emitted `task:waiting_local_directory` and `task:running`
events through to issue execution log, sticky agent banner, activity indicator,
and agent presence so a parked task is no longer invisible on the issue page.

- Add `waiting_local_directory` to `AgentTask.status` and the typed
  `task:running` / `task:waiting_local_directory` WS event payloads.
- Chat realtime sync writes both new statuses into the pending-task cache so
  the chat StatusPill flips out of a stale `dispatched` frame.
- ExecutionLogSection: count `waiting_local_directory` as active, add tone +
  status label, treat parked tasks the same as dispatched for time anchor /
  transcript visibility / terminate-confirm note.
- AgentLiveCard: subscribe to both new events, rank the parked state between
  dispatched and queued, and surface a "is waiting for the local directory"
  banner with the muted "Clock" treatment used for queued.
- IssueAgentActivityIndicator: route parked tasks into the queued bucket so
  the hover stack and chip stay visible.
- derive-presence: parked tasks count toward `queuedCount` so the agent
  workload chip stays out of `idle` while the daemon waits on the path lock.
- Locales: add `agent_live.is_waiting_local_directory` and
  `execution_log.status_waiting_local_directory` (en + zh-Hans).

Co-authored-by: multica-agent <[email protected]>

* feat(project): enforce one local_directory per (project, daemon) (MUL-2618)

The daemon-side resolver picks the first matching local_directory by
daemon_id, so allowing two rows on the same daemon — even at different
paths — let the agent silently write into whichever sorted first. Tighten
the invariant top to bottom:

- server: `findLocalDirectoryConflict` rejects any second row sharing a
  daemon_id, regardless of `local_path` or label. Bundled-create surface in
  `CreateProject` runs the same daemon-scoped dedupe up front.
- daemon: `findLocalDirectoryAssignment` fails fast when it finds more than
  one row pinned to the current daemon (older API client / direct DB
  writes can still produce that state — refuse to guess).
- desktop UI: hide the "Add local directory" action once the current
  daemon owns a row on this project, with a hint and a defensive toast on
  the call path; foreign-daemon rows stay visible read-only as before.
- Tests:
  * daemon: new `two local_directory rows on this daemon fail fast` /
    `local_directory rows on different daemons coexist` cases.
  * handler: rewrite the legacy `LabelShadow` cases as
    `DaemonScopedConflict` / `BundledLocalDirectoryDaemonConflict` —
    asserts 409 on same-daemon different-path, 201 on per-daemon bundles.
- Locales: en + zh-Hans copy for the new hint + toast.

Co-authored-by: multica-agent <[email protected]>

* chore(sqlc): drop stale skills_local in UpdateAgentCustomEnv (MUL-2618)

Follow-up to the main-merge in 0f8e8ca7: the auto-merge preserved most
of main's skills_local revert but kept the column reference inside the
UpdateAgentCustomEnv scanner because that block hadn't been touched by
either side. Re-running `sqlc generate` regenerates the file without
skills_local in this query, matching the rest of the file and the
post-revert schema.

Co-authored-by: multica-agent <[email protected]>

* feat(create-project): binary source picker — repos OR local directory

Turn the create-project dialog's "Repos" pill into a binary Source
picker. A project's source is mutually exclusive: either a set of
GitHub repos (worktree mode, default) or a single local working
directory (local mode, desktop-only). Mirrors the constraint the
backend will enforce next.

Behavior:
- Pill shows the active mode's selection (GitHub icon + repo count, or
  folder icon + local label/path).
- Popover has a 2-tab segmented control at the top; the Local tab is
  hidden entirely on web (local_directory needs a daemon_id).
- Local tab requires the daemon online — amber notice + disabled picker
  when offline, re-renders automatically via useLocalDaemonStatus.
- Switching tabs preserves the other side's stash, but handleSubmit
  only emits the resource matching the active sourceMode, so abandoned
  picks never leak into the created project.

Backend mutual-exclusion validation + the resources-section
conditional-add-button still to come — this PR just unblocks the
dialog so it can be demoed.

* fix(mobile): cover waiting_local_directory in run row status maps (MUL-2618)

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Multica J <[email protected]>
2026-05-27 13:44:31 +08:00
Naiyuan QingandClaude Opus 4.7 fedd0f1694 feat(issues): live agent activity chip + per-issue indicator + filter (#3058)
* feat(server): broadcast task:running event

The dispatched → running transition was silent: only task:queued,
task:dispatch, task:cancelled, task:completed and task:failed
broadcast over WS. Any UI that distinguishes "queued" from "running"
(e.g. the new issue-card agent activity indicator) would lag by up to
the 30s agentTaskSnapshot staleTime on the most user-visible
transition. StartTask now broadcasts task:running so the workspace
snapshot invalidates immediately, keeping the agent activity UI live.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(issues): live agent activity chip + per-issue indicator + filter

Surfaces "which agents are working on what, right now" in the Issues
and My Issues views, with a one-click filter to narrow the list to
issues that have a running agent task.

Two visual surfaces:

- **Workspace chip** in the header (left of Filter). Shows the
  brand-tinted avatar stack of agents currently running on visible
  issues. Click toggles a page-scoped filter; idle state renders a
  static "0 working" button with a hover-card placeholder. When the
  filter is active the chip pins to brand fill across hover and popover
  states (the Button outline variant otherwise repaints back to
  neutral). A muted "Viewing only working agents" hint sits to the
  left of the chip whenever the filter is on, so users notice the
  active state without having to hover.

- **Per-issue indicator** on every board card and list row (top-right
  of the identifier line). Renders the avatar stack of agents in
  running or queued state on that issue, full-opacity ring at brand/70
  when ≥1 is running, half-opacity stack when only queued. Returns
  null when nothing is in flight.

Both surfaces open the same hover-card body that lists each active
task with the agent avatar, status dot (composed via the existing
availability + workload tokens), and a live-ticking duration.

Adds a new "All" scope to /my-issues that unions assignee, creator,
and involves_user_id via three parallel fetches deduped on the
client — no backend changes for this part. The chip's count and the
quick-filter both use the page's currently visible issue ids so they
stay in sync with the active scope.

State is per-user (Zustand + localStorage) and the agentRunningFilter
is intentionally omitted from partialize — running state changes
second-to-second and a stored toggle would land users in an
unexplained empty list. WS task:running, already added in the
preceding commit, drives real-time updates without polling.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* refactor(issues): swap indicator ring pulse for shimmer text label

Earlier iterations layered a brand ring with various opacity-pulse
cadences around the per-issue avatar stack. Every tuning attempt was
either invisible (transparent ring + faded pulse) or oppressive (a
visible ring that flashed on a dense board). Moves the "alive" signal
onto a small text label and reuses chat's existing
`animate-chat-text-shimmer` utility — a soft light sweep across the
glyphs that already powers the ChatGPT-style "thinking" cue in
task-status-pill.

Indicator now reads as a 12 px avatar stack + 10 px label:

- Running → full-opacity avatars + shimmering localized "Working"
- Queued  → half-opacity avatars + muted static "Queued"
- Idle    → render nothing (unchanged)

Avatars and the surrounding card stay completely still; only the few
glyphs animate. The label is i18n-driven via the existing
`status_running` / `status_queued` keys, so no locale changes are
required.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-22 14:20:42 +08:00
Bohan Jiangandmultica-agent 0c767c0052 feat(issues): per-issue metadata KV (MUL-2017) (#2845)
* feat(issues): per-issue metadata KV (MUL-2017)

Adds a small JSONB KV map to every issue for agent pipeline state (attempts,
PR number, pipeline status, ...). Keys match a narrow regex, values are
primitives (string / number / bool), capped at 50 keys per issue and 8KB
per blob. Defense-in-depth via two CHECK constraints (object shape + size).

All mutations are single-key atomic (jsonb_set / `- key`). `UpdateIssue`
intentionally does NOT touch metadata: a whole-blob overwrite would race
with concurrent agent writes.

  GET    /api/issues/:id/metadata
  PUT    /api/issues/:id/metadata/:key   body: { "value": <primitive> }
  DELETE /api/issues/:id/metadata/:key

Containment filter on list: GET /api/issues?metadata=<json-object> uses
PG `@>` against a `jsonb_path_ops` GIN index. Mirrored across ListIssues,
CountIssues, ListOpenIssues, and the hand-rolled ListGroupedIssues SQL so
CLI/API and UI grouped views stay consistent.

CLI: multica issue metadata {list,get,set,delete}
  multica issue list --metadata key=value (repeatable, AND)
  set has --type to override the default value-sniffing
Co-authored-by: multica-agent <[email protected]>

* fix(issues): metadata test bugs + wire realtime + read-only display (MUL-2017)

- Fix two failing handler tests blocking backend CI:
  - reset decode target after delete so map merge does not mask removal
  - url.PathEscape the key segment so spaces no longer panic NewRequest
- Wire issue_metadata:changed end to end so the detail / list / my-issues
  caches stay in sync with set/delete events (other tabs, CLI writes).
- Add a read-only Metadata strip to the issue detail sidebar; hidden when
  the issue has no keys so it stays quiet in the common case.

Co-authored-by: multica-agent <[email protected]>

* feat(runtime): teach agents to read/write issue metadata (MUL-2017)

Add an `## Issue Metadata` section to the runtime brief plus a
`metadata list` step on entry and a `metadata set`/`delete` step on
exit. Section only emits when the task carries an issue id (comment- or
assignment-triggered); chat / quick-create / run-only autopilot stay
clean so they don't fire failing CLI calls.

Co-authored-by: multica-agent <[email protected]>

* fix(issues): bump metadata migration to 105 and drop attempts as example (MUL-2017)

main is now at 104_drop_runtime_timezone; the migrator picks
LatestVersion() by sorted filename, so a slot before the tail would
let DBs that have already run 099–104 think they're up-to-date while
the issue.metadata column is missing — runtime would then fail with
column does not exist. Renumbering to 105 puts the migration at the
tail and forces it to run.

Also drop attempts as a positive example across docs/code comments and
test fixtures — the runtime instruction prompt already lists it under
"What NOT to pin" (runtime bookkeeping). Replace with pr_number, which
is in the recommended-keys set, so docs/tests speak the same language
as the prompt.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-05-21 16:35:45 +08:00
AdamQQQandClaude Opus 4.6 fab0671332 feat(skills): support multi-select bulk import in Copy from runtime (#2686)
- Multi-select UI for batch importing skills from a local runtime
- Server batch-dispatches up to 10 import requests per heartbeat cycle
- WS heartbeat now reads supports_batch_import from daemon payload
  instead of hardcoding true, so old daemons correctly fall back to
  one-at-a-time dispatch
- Raised server pending timeout to 3min and client poll timeout to 4min
  to accommodate daemons that pop only one import per 15s heartbeat

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-05-18 16:56:27 +08:00
29082f7cfe feat: implement Squad feature MVP (#2505)
* feat: implement Squad feature MVP

- Add migration 084_squad: squad, squad_member, squad_activity_log tables
- Extend issue.assignee_type to support 'squad'
- Add sqlc queries for squad CRUD, member management, activity logs
- Add Go handler with full Squad API (CRUD, members, activity log)
- Register routes: /api/squads/*, /api/issues/{id}/squad-activity, /api/squad-activity
- Add Squad trigger logic:
  - Assign Squad immediately triggers leader
  - Every external comment on squad-assigned issue triggers leader
  - Anti-loop: squad members' comments don't trigger leader
  - Dedup: skip if leader already has pending task
- Add squad activity log API (方案 B) for leader no-op recording
- Add frontend TypeScript types (Squad, SquadMember, SquadActivityLog)
- Add protocol events: squad:created, squad:updated, squad:deleted

Co-authored-by: multica-agent <[email protected]>

* fix: address PR review blocking issues

1. validateAssigneePair now accepts 'squad' assignee_type
2. All squad endpoints validate workspace ownership via GetSquadInWorkspace
3. CreateSquadActivityLog restricted to squad leader agent only
4. AddSquadMember validates member exists in workspace
5. UpdateSquad auto-adds new leader to squad members
6. DeleteSquad transfers assigned issues to leader before deletion
7. IssueAssigneeType includes 'squad' in frontend types

Co-authored-by: multica-agent <[email protected]>

* feat: soft-delete squads via archive instead of hard delete

- Add migration 085: archived_at + archived_by columns on squad table
- ListSquads now excludes archived squads (ListAllSquads for admin)
- DeleteSquad → ArchiveSquad (sets archived_at, preserves all records)
- Transfer squad-assigned issues to leader before archiving
- SquadResponse includes archived_at/archived_by fields
- Frontend Squad type updated with nullable archived fields

Co-authored-by: multica-agent <[email protected]>

* feat: re-add Squads frontend entry (sidebar nav + pages)

Re-applies the frontend squad entry that was lost during a merge:
- Sidebar nav: Squads item with Users icon
- Paths: squads() and squadDetail() in workspace paths
- Routes: /squads and /squads/[id] pages
- Views: SquadsPage (list) and SquadDetailPage
- i18n: en 'Squads' / zh '小队'
- Reserved slug: 'squads'

Co-authored-by: multica-agent <[email protected]>

* fix: fix SquadsPage rendering - use PageHeader children pattern

PageHeader takes children, not title/actions props. The incorrect
usage caused a React rendering error. Now matches the pattern used
by autopilots and agents pages.

Co-authored-by: multica-agent <[email protected]>

* fix(squads): add API client methods and package export for squads pages

* feat: complete Squad frontend - create dialog, member management, API methods

- Add CreateSquadModal with name/description/leader selection
- Register 'create-squad' in modal registry
- Wire 'New Squad' button to open the modal
- Add full API client methods: createSquad, updateSquad, deleteSquad,
  addSquadMember, removeSquadMember
- Rewrite SquadDetailPage with:
  - Member list showing resolved names
  - Add/remove member UI
  - Archive squad button
  - Back navigation to squads list

Co-authored-by: multica-agent <[email protected]>

* feat: improve Squad UI - match create agent dialog style

- CreateSquadModal: proper Dialog with Header/Description/Footer,
  agent picker with avatars, textarea for description
- SquadDetailPage: centered max-w-2xl layout, ActorAvatar for members,
  Crown badge for leader, textarea for member description,
  improved spacing and visual hierarchy
- Renamed 'role' field label to 'Description' in add member form
  (describes the member's responsibilities in the squad)

Co-authored-by: multica-agent <[email protected]>

* feat(squad): add avatar, instructions; drop unique-name constraint

- 086: add squad.avatar_url
- 087: drop unique constraint on squad.name (squads with the same
  name are legitimate across teams; uniqueness was an accidental
  product constraint)
- 088: add squad.instructions (text, default '')
- UpdateSquad now COALESCEs avatar_url + instructions
- handler exposes Instructions in SquadResponse and accepts it in
  UpdateSquad

Co-authored-by: Copilot <[email protected]>

* feat(squad): assignable + mention target; trigger leader on assign

- assignee picker and @mention suggestion list squads alongside
  agents and members; renders squad avatar/icon
- creating or updating an issue with assignee_type=squad enqueues
  a task for the squad's current leader (mirrors agent-assignee
  parking-lot rule: skip backlog only)
- workspace queries/hooks expose squads where needed for the
  pickers
- locales updated for new picker copy

Co-authored-by: Copilot <[email protected]>

* feat(squad): agent-style detail page with members + instructions tabs

- restructure squad detail page to mirror the agent detail page:
  320px inspector (creator, leader, created/updated) + tabbed
  pane (Members | Instructions) with dirty-guard AlertDialog
- inline name + avatar editing on the inspector
- inline description editor (modal textarea)
- members tab: leader + member picker with role descriptions,
  swap leader, edit member roles, remove
- instructions tab: ContentEditor + Save (mirrors agent pattern)
- squads list shows the squad avatar/icon
- core types + api.updateSquad accept avatar_url + instructions

Co-authored-by: Copilot <[email protected]>

* feat(squad): inject leader briefing on claim (protocol + roster + instructions)

When a squad's leader agent claims a task on a squad-assigned issue,
append a system-level briefing to the agent's Instructions composed of:

1. Squad Operating Protocol — hard-coded rules: leader is a
   coordinator, dispatch via @mention, stop after dispatching,
   resume on re-trigger, do not work outside the roster.
2. Squad Roster — leader self-row plus one row per non-archived
   member with a literal mention markdown string ([@Name](mention://
   agent|member/<UUID>)) the leader can paste verbatim. Round-trips
   through util.ParseMentions, enforced by a contract test.
3. Squad Instructions — the user-defined squad.instructions block,
   omitted entirely when empty so we do not leave a dangling heading.

Non-leader members claiming the same issue receive no briefing.

Tests cover: full squad with mixed agent/human members, lone leader,
archived agents skipped, empty user instructions, mention round-trip,
and the leader/non-leader claim-handler gate.

Co-authored-by: Copilot <[email protected]>

* fix(squad): tell leader not to restate issue context in dispatch comment

After observing leaders padding their delegation comments with full
re-summaries of the issue body and prior discussion, make the
Operating Protocol explicit:

- assignees on Multica already have the full issue (title,
  description, all comments, attachments) and workspace context;
- delegation comments should add only what cannot be inferred
  (who is picked, why, extra constraints), aim for two or three
  sentences;
- restating context is now an explicit hard rule violation.

Co-authored-by: Copilot <[email protected]>

* feat(squad): unify leader evaluation into activity_log, add CLI command

- Squad member comments now trigger leader (only leader self-excluded)
- Replace squad_activity_log with activity_log (action: squad_leader_evaluated)
- Add CLI: multica squad activity <issue-id> <outcome> --reason
- Add API: POST /api/issues/{id}/squad-evaluated
- Update squad operating protocol to require evaluation recording
- Remove squad_activity_log table from schema and generated code

* feat(cli): add squad list, get, member list commands

* fix(squad): address review findings (P1+P2)

P1 fixes:
- Add 'squads' to reserved_slugs.json (source of truth)
- Add 'create-squad' to ModalType union
- Remove unused leaderOpen/selectedLeader in create-squad modal
- Replace literal JSX strings with i18n selectors (en + zh-Hans)

P2 fixes:
- Add 'squad' to mention regex (MentionRe)
- Fix human member lookup in squad briefing (use GetUser directly)
- Add squads routes to desktop app
- Add squad:created/updated/deleted to WSEventType + invalidation
- Reject archived squads as issue assignees

* fix(squad): restore zh-Hans key, publish activity event, invalidate issues on archive

- Restore create_project.title in zh-Hans modals.json (dropped by prior edit)
- Publish activity:created WS event after squad leader evaluation
- Invalidate issue queries on squad:deleted (archive transfers assignees)
- Add creator info to squad list cards

* fix(squad): realtime sync, rerun support, leader validation

- Use workspaceKeys.squads prefix for detail/member queries (realtime invalidation)
- Publish squad:updated after add/remove/role-change member mutations
- Support rerun for squad-assigned issues (targets leader agent)
- Reject assignment to squads whose leader is archived

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Copilot <[email protected]>
2026-05-13 18:46:20 +08:00
Bohan Jiangandmultica-agent 51aa924124 feat(chat): support renaming chat sessions inline (#2522)
Adds a pencil icon next to the trash icon on each session row in the chat
dropdown. Clicking it turns the title into an inline editable input:
Enter / blur saves, Escape cancels.

Server: new PATCH /api/chat/sessions/{id} handler that updates the title
via the existing `UpdateChatSessionTitle` sqlc query, broadcasts a new
`chat:session_updated` WS event so other tabs / devices stay in sync, and
rejects blank titles. Frontend mutation is optimistic with rollback,
matching the existing delete-session pattern.

MUL-2110

Co-authored-by: multica-agent <[email protected]>
2026-05-13 16:57:34 +08:00
e8c2855746 fix(chat): collapse chat-done flicker via inline cache write (#2509)
* fix(chat): collapse chat-done flicker via inline cache write

The chat panel flickered at end-of-turn: live TimelineView unmounted →
short blank + scroll jump → persistent AssistantMessage finally appeared.

Root cause: chat:done's WS handler called setQueryData(pendingTask, {})
synchronously while invalidateQueries(messages) was an async refetch.
The render guard pendingAlreadyPersisted (chat-message-list.tsx:62-68)
expected the persisted message to already be in the messages cache
before pending cleared, but the sync/async ordering broke that guard.

Fix follows TkDodo's "combine setQueryData (active query) + invalidate
(others)" pattern. ChatDonePayload now carries the freshly-persisted
ChatMessage (id, content, elapsed_ms, created_at); the WS handler
writes it into chatKeys.messages BEFORE clearing pending. Same render
tick → AssistantMessage mounts before TimelineView unmounts → no
flicker. invalidate(messages) stays as a fallback for clients that
took the older code path or for content drift (redaction, etc.).

Also slim task:completed's chat branch — chat:done already wrote the
message and cleared pending; task:completed only refreshes the
cross-session pending aggregate that drives the FAB.

Field additions are all `omitempty` / TS `?:` so older clients ignore
them and older servers (no fields populated) fall back to invalidate-
only, preserving prior behavior.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(chat): cover chat done cache handoff

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Eve <[email protected]>
2026-05-13 15:27:44 +08:00
Bohan Jiangandmultica-agent caeb146bac feat(github): GitHub App integration for PR ↔ issue linking (#1817)
* feat(github): GitHub App backend for PR ↔ issue linking

- New tables: github_installation (workspace ↔ App install), github_pull_request (mirrored PR state), issue_pull_request (M:N link).
- Webhook handler verifies HMAC-SHA256, upserts PR rows, parses issue identifiers from PR title/body/branch and auto-links them. Merging a linked PR moves the issue to done.
- Connect/setup endpoints power the zero-config "Connect GitHub" install flow; state token is HMAC-signed so the setup callback can recover the workspace.
- Workspace-scoped admin routes for listing/disconnecting installations, plus a per-issue `pull-requests` list endpoint.

Co-authored-by: multica-agent <[email protected]>

* feat(github): UI for connecting GitHub and viewing linked PRs

- Settings → Integrations: new tab with Connect GitHub / installations list / disconnect, gated on the deployment having the App configured.
- Issue detail sidebar: Pull requests section showing linked PR title, repo, state (open/draft/merged/closed), and author, with deep link to GitHub.
- Real-time refresh: github_installation:* and pull_request:* events invalidate the matching TanStack Query caches.

Co-authored-by: multica-agent <[email protected]>

* fix(github): address review — null actor, role gating, configured guard, scoped uninstall broadcast

- listeners: use optionalUUID(e.ActorID) so the system actor on the github-driven issue:updated event no longer panics activity / notification listeners; merged-PR → issue done now produces a status_changed activity and inbox entry.
- IntegrationsTab: gate the admin-only installations query on canManage so members no longer hit /github/installations 403; the configured/not-configured copy is also scoped to admins.
- backend: introduce isGitHubConfigured() requiring both GITHUB_APP_SLUG and GITHUB_WEBHOOK_SECRET, and surface that single flag from list-installations + connect endpoints so the frontend Connect button stays disabled until both are set.
- DeleteGitHubInstallationByInstallationID now RETURNs workspace_id; webhook handler publishes github_installation:deleted scoped to the right workspace so already-open Settings tabs invalidate in real time. ErrNoRows on a re-fired delete short-circuits cleanly.
- tests: focused webhook integration coverage (auto-link + merge → done, cancelled preservation, uninstall returns workspace).

Co-authored-by: multica-agent <[email protected]>

* fix(github): i18n the new GitHub UI strings to satisfy lint

CI flagged every literal string in the Integrations tab, the Pull requests
sidebar section, and the per-PR row label. Move them through useT() and
add the matching `integrations.*` block to settings.json (en / zh-Hans)
plus `detail.section_pull_requests` / `detail.pull_request_state_*` /
loading + empty copy under `issues.json`.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-05-12 13:49:03 +08:00