* feat(daemon): surface the real task initiator to the agent runtime (MUL-2645)
In a multi-person workspace the agent runtime only ever saw the runtime
OWNER identity: the brief's `## Requesting User` is sourced from
runtime.OwnerID and the task-scoped token is owner-bound, so every
requester (whoever commented, @mentioned, or chatted) appeared to the
agent as the owner. Agents that route by initiator for permission,
privacy, or audit all misjudged.
Resolve the real task initiator at claim time and surface it distinctly
from the owner:
- comment / mention trigger -> triggering comment's author (member or agent)
- chat task -> chat session creator (sessions are creator-only)
- on-assign / autopilot / quick-create -> no attributable initiator (omitted)
Adds initiator_{type,id,name,email} to the claim response, the daemon
Task, and TaskContextForEnv, rendered into the brief as a new
`## Task Initiator` section. The section documents the privacy boundary:
the agent's credentials stay owner-scoped, so this is an attested
identity for the agent's own routing/privacy logic, not act-as. No DB
migration — both paths are derivable from existing rows.
Tests: brief rendering (member/agent/omit/sanitize) + email guard unit
tests, and claim-handler tests for the comment and chat paths.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): store real sender as task initiator, not chat_session creator (MUL-2645)
Review fix (Niko, PR #3899). v1 resolved the chat task initiator from
chat_session.creator_id at claim time. That is correct for web chat and
Lark p2p (creator == sender), but WRONG for Lark group chats: the group
session creator is deliberately the installer (stable identity across
member churn), not the message sender. So in a Lark group, every member
who triggered the agent showed up in the brief as the installer/owner —
the exact bug this issue is about, still live at that entry point.
Capture the real sender at enqueue time instead of deriving it from the
session creator at claim time:
- migration 117: agent_task_queue.initiator_user_id (FK user, ON DELETE
SET NULL); NULL for non-chat and pre-migration rows.
- EnqueueChatTask now takes an explicit initiatorUserID. Web chat passes
the authenticated request user; the Lark dispatcher threads the inbound
sender (binding.MulticaUserID) through scheduleRun -> flushChatRun. The
debouncer keeps the latest scheduled flush per session, so in a multi-
sender silence window the LATEST sender wins (documented + tested).
- claim handler resolves the initiator from task.initiator_user_id and
drops the creator_id fallback entirely.
The Lark group session creator stays the installer (unchanged) — only the
task initiator is corrected, keeping the two concepts cleanly separate.
Tests: dispatcher group regression (initiator = sender, not installer),
latest-sender-wins, p2p initiator assertion; the chat claim handler test
now sets creator != initiator and asserts the stored sender wins.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
When a message is successfully ingested, send a Typing reaction to
the user's message. When the agent replies (EventChatDone) or fails
(EventTaskFailed), clear the reaction before the reply is visible.
- Add AddMessageReaction / DeleteMessageReaction to APIClient
- Implement reaction HTTP calls in httpAPIClient
- Introduce TypingIndicatorManager for per-session state tracking
- Wire into Hub (add on ingest) and Patcher (clear before reply)
- Skip typing for messages older than 2 minutes (WS replay guard)
Co-authored-by: miaolong001 <miaolong@xd.com>
Fix OpenClaw config discovery when `openclaw config file` prints Doctor warning UI before the actual config path. The daemon now uses the last non-empty stdout line as the path while preserving the existing tilde expansion, absolute-path validation, stat checks, and fail-closed behavior.
Tests: go test ./internal/daemon/execenv
* feat(cli): refine per-status error copy with actionable hints (PR2)
Builds on PR1's translation layer. Each HTTP-status message now carries an
actionable next step, in both English and Chinese:
- 401: run `multica login`; plus a self-hosted / non-OAuth fallback telling
the user to ask their administrator for valid credentials
- 403: check the workspace / ask an admin to grant access
- 404: check the ID or run the matching `list` command
- 409: re-fetch the latest state and retry
- 422: check values / run with --help
- 429: wait and retry; reduce call frequency if it persists
- 5xx: retry, contact support, and re-run with --debug for the raw response
Also adds ErrorKind.String() (stable snake_case identifiers) and uses it in
--debug output instead of the raw int, and clears the pre-existing gofmt dirt
Eve flagged in cmd_config.go, cmd_version.go, and help.go.
Tests: TestErrorKindString (all kinds + uniqueness + out-of-range fallback)
and TestFormatErrorActionableHints (locks the per-status hints in EN and ZH).
Refs MUL-3104. PR2 of 3.
Co-authored-by: multica-agent <github@multica.ai>
* test(cli): cover validation (400/422) actionable hint
TestFormatErrorActionableHints omitted KindValidation, so deleting the 400/422
hint would have gone unnoticed. Add 400 and 422 cases (no server message, so
the generic validation copy is used) asserting EN contains --help / expected
format and ZH contains --help / 格式 / 参数.
Refs MUL-3104, PR #3897.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(cli): add central error translation layer (PR1)
Introduce server/internal/cli/errors.go, a single user-facing error
translation layer that collapses raw transport errors, HTTP status
errors, and internal verb-wrapped chains into clear, localized messages.
- ErrorKind classification (network timeout/DNS/refused/TLS/offline,
401/403/404/409/400+422/429/5xx, unknown)
- NetworkError wraps transport errors and strips the raw URL from the
user-facing message; classifyNetworkError categorizes via errors.As/Is
with string fallbacks
- HTTPError.Kind() maps status codes onto ErrorKind
- FormatError: bilingual output (English default, auto-switch to Chinese
on a zh LC_ALL/LC_MESSAGES/LANG locale), validation errors surface the
server message; --debug / MULTICA_DEBUG appends the full raw chain
- ExitCodeFor: tiered exit codes (network=2, auth=3, 404=4, validation=5,
other=1)
- client.go: default HTTP timeout 15s -> 30s, overridable via
MULTICA_HTTP_TIMEOUT; wrap every transport Do() error as *NetworkError
- main.go: route errors through FormatError + ExitCodeFor, add persistent
--debug flag
Unit tests cover every ErrorKind, classification, language detection,
exit codes, server-message extraction, and timeout parsing.
Refs MUL-3104. PR1 of 3; PR2/PR3 (status-code copy refinement and
per-command customization) follow separately.
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): address review — unify command timeouts and classify all helper errors
Must-fix 1: command-level contexts no longer truncate MULTICA_HTTP_TIMEOUT.
Added cli.APITimeout/AtLeastAPITimeout/APIContext (budget = transport timeout
+ small grace, honoring MULTICA_HTTP_TIMEOUT) and replaced the hardcoded 15s
context.WithTimeout in every API command (14 files, 92 sites) with
cli.APIContext. The issue-create/comment path now uses APITimeout() with a
60s floor for attachment uploads.
Must-fix 2: all API helpers now return *HTTPError on status >= 400. Added a
shared newHTTPError(method, path, resp) and routed GetJSON, GetJSONWithHeaders,
PostJSON, PutJSON, PatchJSON, DeleteJSON, DeleteJSONWithBody, UploadFile,
UploadFileWithURL, DownloadFile (and HealthCheck) through it, so issue
update/status/metadata (PUT), comment list (GetJSONWithHeaders), project/label/
comment delete (DELETE) and agent/workspace/autopilot update (PUT/PATCH) all
get HTTPError.Kind() classification, friendly copy, and the tiered exit code
instead of the raw string + exit 1.
Tests: new errors_integration_test.go drives the real helpers against a fake
server and asserts FormatError copy + ExitCodeFor for 401/403/404/422/500
across all 10 helpers, plus a slow-server test proving the command context
does not cancel before the transport timeout. Updated the UploadFileWithURL
assertion to check for *HTTPError.
Refs MUL-3104, PR #3892.
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): make remaining fixed-timeout API commands honor MULTICA_HTTP_TIMEOUT
Closes out the timeout work: the last API command paths still used a
hardcoded context deadline that capped MULTICA_HTTP_TIMEOUT. Converted them
to cli.AtLeastAPITimeout(<original floor>) so the env override scales them up
while preserving each original lower bound:
- cmd_autopilot.go autopilot trigger 30s -> AtLeastAPITimeout(30s)
- cmd_attachment.go attachment download 60s -> AtLeastAPITimeout(60s)
- cmd_agent.go avatar upload 60s -> AtLeastAPITimeout(60s)
- cmd_skill.go skill import / search 60s -> AtLeastAPITimeout(60s)
- cmd_runtime.go runtime update 150s -> AtLeastAPITimeout(150s)
- cmd_login.go workspace-creation poll 10s -> AtLeastAPITimeout(10s)
The login poll keeps a short 10s floor to stay responsive within its 5-minute
loop, but it is NOT a silent exception: AtLeastAPITimeout means it still scales
with MULTICA_HTTP_TIMEOUT. Documented in code and covered by a new subtest in
TestAPITimeoutRespectsEnv.
Refs MUL-3104, PR #3892.
Co-authored-by: multica-agent <github@multica.ai>
* style(cli): gofmt cmd_attachment.go to unblock backend CI
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): wire agy --model and model discovery for Antigravity
agy 1.0.6 added a --model flag and an `agy models` catalog command, which
were the #1 blocker in the earlier agy-backend review (MUL-3125). The
antigravity backend already shipped but deliberately dropped opts.Model
because agy 1.0.1 had no way to select a model.
- buildAntigravityArgs now passes --model <display name> when opts.Model is
set; the value is the exact `agy models` display string (spaces + parens),
passed as a single exec arg so no shell quoting is needed.
- Block --model in custom_args so it can't override the managed value.
- ListModels("antigravity") enumerates via `agy models` (no static fallback:
agy silently no-ops on unrecognised models, so a stale guess would turn a
typo into a successful empty run).
- ModelSelectionSupported now returns true for every built-in provider; the
hook stays for any future model-less runtime.
- Daemon probe reads MULTICA_ANTIGRAVITY_MODEL for the daemon-wide default.
Co-authored-by: multica-agent <github@multica.ai>
* docs(providers): mark Antigravity model selection as supported
Antigravity gained --model in agy 1.0.6 (MUL-3125). Update the provider
matrix + prose (en/zh/ja/ko) from "managed internally / no --model" to
dynamic discovery via `agy models`, and refresh the now-stale picker
comments. Flag the display-string (not slug) shape and agy's silent no-op
on unrecognised values.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): reject unknown Antigravity model at spawn (MUL-3125)
agy exits 0 with empty output on an unrecognised --model, so a stale/typo'd
value would surface as a 'completed' but empty task. Validate opts.Model
against the `agy models` catalog in Execute before spawning: a non-empty
model the CLI does not advertise fails fast with an actionable error listing
the real choices. opts.Model is the single funnel for agent.model and the
MULTICA_ANTIGRAVITY_MODEL default, so this one check covers every source
(UI free-text, API, persisted value, env) — addressing Elon's review that a
UI-only guard is bypassable.
Validation is fail-OPEN: if the catalog can't be discovered we pass the
value through and let agy resolve it, so a discovery hiccup never blocks a
run. Pure antigravityModelError() is unit-tested (valid / unknown / near-miss
/ empty-model / empty-catalog); verified live against real agy 1.0.6.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): 清理陈旧 agent 分支
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): 串行化 bare repo gc
Co-authored-by: multica-agent <github@multica.ai>
* test(daemon): adapt health repo cache mock
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): gate gc maintenance on stale-branch deletion
Address review feedback on the bare-repo GC change:
- Only run `reflog expire` + `git gc --prune=30.days` when we actually
deleted a stale agent branch this cycle. Previously the heavy step
ran every GC tick on every cached repo even when there was nothing
to reclaim, turning a stale-ref cleanup into a periodic full-repo
maintenance job under the per-repo lock.
- Split git command timeouts: `gc --prune=30.days` now gets a
10-minute budget instead of sharing the 30s ceiling that was scoped
for the original `worktree prune` call. Light commands stay at 30s.
- Drop the redundant `gc --auto` — `gc --prune=30.days` already
performs the maintenance `gc --auto` would have triggered.
- Narrow the agent-namespace ref query from `refs/heads/agent` to
`refs/heads/agent/` so the pattern can't surface a literal
`agent` branch outside the daemon namespace.
Tests:
- New TestPruneWorktree_IgnoresLiteralAgentBranch pins the trailing-
slash narrowing.
- New TestPruneWorktree_SkipsMaintenanceWhenNothingDeleted uses an
unreachable, backdated loose object as a sentinel to verify that
`gc --prune` runs only when a stale agent branch was reaped.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: 0xNini Code Dev <agent@multica.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: 0xNini <0xnini@iMac-Pro.local>
Co-authored-by: J <j@multica.ai>
* feat(issues): move agent live signal into the issue-detail header
Replace the in-body sticky "agent is working" card (AgentLiveCard) with a
compact chip in the issue-detail header, so the live signal sits in one
fixed place and never competes with sticky banners in the content column.
- New IssueAgentHeaderChip: avatar(s) + live-ticking blue elapsed time;
click opens a popover listing every active task.
- Popover reuses ExecutionLogSection's ActiveTaskRow (now exported) so the
popover and the right panel are literally the same row — no duplication.
- PopoverContent gains an optional keepMounted so the row's confirm dialog
survives the popover closing on Stop.
- Running rows in ExecutionLogSection drop the blue spinner for a
live-ticking blue elapsed timer (panel + popover share this).
- Source the chip from the workspace agent-task snapshot filtered by issue
(same source as board/list indicators, zero extra network); delete the
old AgentLiveCard + its test and its heavy per-issue WS machinery.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(issues): live event count on the agent chip + execution-log rows
Show a live "N events (elapsed)" on running agents, consistent across the
header chip, its popover rows, and the right-panel execution log.
- Read the shared per-task message cache (taskMessagesOptions, kept live by
useRealtimeSync's global task:message handler) instead of a bespoke
subscription — one source of truth, deduped across chip / popover / panel /
transcript, no extra WS wiring.
- Extract <RunningStat> (event count in info-blue + elapsed in muted parens)
so all surfaces render the running stat identically.
- ExecutionLogSection running rows now show the same "N events (elapsed)";
the transcript opened from them streams live from the shared cache.
- Chip: single running shows events (elapsed); multiple shows "N working".
- i18n: add agent_live.event_count (4 locales).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(comments): skip agent triggering on /note-prefixed comments
A comment whose first token is the reserved /note prefix (case-insensitive)
is stored like any other comment but never wakes an agent. The guard sits at
the top of triggerTasksForComment, the single chokepoint, so it covers all
three trigger paths — assignee, squad leader, and @mentioned agents. Gating
only shouldEnqueueOnComment (as originally proposed) would still let
"/note @agent ..." through the mention path.
Lets members leave human-only tips/notes on agent-assigned issues without
burning an agent run. MUL-3115, closes#3649.
Co-authored-by: multica-agent <github@multica.ai>
* feat(editor): add /note built-in slash command to comment composer
Enable the `/` menu in the issue comment and reply composers in a new
"command" mode that lists fixed built-in commands instead of the chat
skill picker. Currently one command, /note, which marks a comment as a
human-only note that won't trigger the assigned agent.
Selecting it inserts the plain-text "/note " prefix (not a rich node), so
a menu pick and a hand-typed command are byte-identical and the backend
detects either with a simple prefix match. The command menu renders nothing
on a non-matching `/` (hideOnEmpty) so typing a date like 6/8 isn't noisy.
The chat skill picker is unchanged. MUL-3115.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(editor): match /note by label prefix and localize its description
Address PR review feedback:
- buildBuiltinCommandItems now matches the command label as a prefix only,
dropping the description substring match copied from the skill picker. With
one command this keeps the menu predictable (/no surfaces note; /deploy or a
description word like /agent shows nothing) and avoids Enter selecting note
unexpectedly.
- The command description is now a localized UI string: added
slash_command.commands.note to all four editor locales (en/ja/ko/zh-Hans)
and the menu renders it via the typed translator. The /label itself stays
literal since it's the typed token the backend matches.
MUL-3115.
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): shorten /note command description to avoid truncation
The slash menu item is single-line (truncate, w-72), so the longer copy was
cut off. Shorten to "won't trigger any agents" across all four locales — also
more accurate, since /note skips assignee, squad leader, and @mentioned agents,
not just the assigned one.
MUL-3115.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The in-app inbox (sidebar badge, real-time WS updates, settings, inbox
page) was already shared and worked on web. The only Desktop-only piece
was the native OS banner: handleInboxNew called desktopAPI.showNotification,
which is undefined on web, so no banner fired for new inbox items while the
app was unfocused.
Add the browser equivalent, keeping handleInboxNew as the single decision
point (focus + source-workspace mute gating stays shared with desktop):
- packages/core/platform/system-notification.ts: browser Notification engine
(showWebNotification) + permission helpers + a click-handler registry. Lives
in core (the caller does) but injects the click-routing decision so core
stays headless.
- handleInboxNew: branch desktopAPI (unchanged) → else showWebNotification.
- apps/web WebNotificationBridge: registers click routing to the source
workspace's inbox (?issue=…), mirroring desktop's DesktopInboxBridge.
- Settings → Notifications: web-only opt-in to grant browser permission
(hidden on desktop / where the API is unavailable); en/zh-Hans/ja/ko.
Permission is an explicit settings opt-in (no auto-prompt on load, per
browser best practice). Tests cover the engine and the web path in
handleInboxNew.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
DeleteAgentRuntime paused autopilots for the runtime's archived agents
just outside the teardown transaction, so a pause that succeeded before a
later delete failed (and rolled back) left autopilots paused while the
runtime survived. Move ListArchivedAgentIDsByRuntime +
PauseAutopilotsByAgentAssignees inside the tx via qtx and treat a pause
error as a hard failure, matching ArchiveAgentsAndDeleteRuntime.
Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
shouldInterruptAgent now treats every terminal task status (completed/failed/cancelled, via isAgentTaskTerminal) plus a 404 task-not-found as an interruption signal, so the daemon stops a local agent once the backend has finalized the task — e.g. the runtime offline sweeper flipping running -> failed during a disconnect/reconnect. Previously only `cancelled`/404 interrupted, so the agent ran to completion and its CompleteTask call failed against a non-running row, wasting compute and adding log noise.
Closes#3877
The sticky header in the Projects compact list was missing backdrop-blur,
causing underlying content to bleed through the semi-transparent bg-muted/30
background when scrolling. Matches the DataTable header pattern used in the
Agents module.
Co-authored-by: multica-agent <github@multica.ai>
* test(migrate): add concurrent migration race test using real Postgres (MUL-2956)
Follow-up to MUL-2923 / #3658, which added a Postgres advisory lock to
serialize the migration loop across concurrent runners (multi-replica
backend startup, scale-up, manual `migrate up` overlap). That PR shipped
without a test because cmd/migrate/ had no harness; this commit adds it.
Refactor: extract runMigrations(ctx, pool, runOptions) from main(), with
the lock key, the bookkeeping table, and the file list now injectable.
main() behavior is unchanged. Identifier interpolation goes through
pgx.Identifier{}.Sanitize so callers can pass "schema.schema_migrations"
safely.
Tests (cmd/migrate/migrate_concurrent_test.go) — every case isolates
itself in a unique throwaway schema and a unique lock key, so they
never touch the real schema_migrations table or block real production
runners that share the database. Skip cleanly when DATABASE_URL is
unreachable, matching the pattern already used in
internal/handler/handler_test.go and internal/metrics/business_sampler_pgsleep_test.go.
- TestRunMigrationsConcurrentPending: 16 goroutines apply 5
deliberately non-idempotent migrations (bare CREATE TABLE +
ALTER TABLE ADD COLUMN). Without the lock, concurrent CREATE TABLE
races trip "duplicate key value violates unique constraint
pg_type_typname_nsp_index" — proving the lock is doing its job.
- TestRunMigrationsConcurrentAlreadyApplied: 16 goroutines hit the
EXISTS no-op path against a pre-populated bookkeeping table; the
state must be unchanged.
- TestRunMigrationsAdvisoryLockSerializes: an external connection
holds the same advisory lock; we assert that zero of the 16
runners complete during a 1 s observation window, then release
the side lock and let them all finish. Catches the original
MUL-2923 bug where the lock got attached to a random pooled
connection.
- TestRunMigrationsConcurrentMixedPoolStress: same pending case but
with a deliberately small pool (runners/2), forcing pgxpool.Acquire
contention to overlap with pg_advisory_lock contention.
Verified locally: `go test -race -count=10 ./cmd/migrate/` passes in
~15 s. Mutation test (lock acquire/release replaced with `SELECT 1`)
confirms the pending and lock-serializes tests both fail loudly,
catching the regression they were written to detect.
go.mod tidy promotes golang.org/x/sync to a direct dependency
(now imported by the test for errgroup) and incidentally fixes a
stale `// indirect` annotation on prometheus/client_model, which is
already imported directly by internal/metrics/testutil.go.
Co-authored-by: multica-agent <github@multica.ai>
* test(migrate): gofmt + address review nits (MUL-2956)
- gofmt -w cmd/migrate/migrate_concurrent_test.go: fixture struct field
alignment.
- quoteQualifiedIdentifier: actually reject identifiers with more than
one dot (the previous version split on the first dot only and would
silently sanitize "a.b.c" into "a"."b.c", contradicting the comment).
Inline the splitter via strings.Split now that we explicitly check the
component count.
- Soften the test's lock-key comment from "never collide" to the
accurate probabilistic statement (~1 in 2^62 collision odds with the
production constant).
go test -race -count=10 ./cmd/migrate/ still passes (~15 s).
Co-authored-by: multica-agent <github@multica.ai>
* test(migrate): direction whitelist + tidy go.mod (MUL-2956)
Address two follow-ups from review:
- runMigrations now whitelist-checks opts.Direction up-front and
returns an error for anything that is not "up" or "down". The
previous shape relied on `opts.Direction == "up"` and an else branch,
so a typo or empty string would silently fall through to the
rollback path. Add TestRunMigrationsRejectsInvalidDirection covering
the empty string, "UP"/"DOWN" case mismatches, "rollback", and a
whitespace-padded value; the check fires before any pool work, so
the test runs without Postgres.
- go mod tidy: promotes google.golang.org/protobuf to a direct
dependency (it is imported directly elsewhere in the module and was
stale-marked indirect).
go test -race -count=10 ./cmd/migrate/ green (~15.7 s, 50/50).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: wei-heshang <wei-heshang@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Built-in SKILL.md description values contained unquoted ': ' sequences, which strict YAML parsers (e.g. Codex) reject — silently dropping the skill at load.
- Quote all eight built-in skill descriptions.
- ensureSkillFrontmatter() re-synthesizes frontmatter that has a name but fails YAML validation, so malformed imports are repaired instead of dropped.
- Unify frontmatter delimiter parsing into a single frontmatterParts helper.
- Add strict-YAML regression tests over the built-in skills, plus unit tests for the recovery branch and delimiter variants.
Closes#3851.
* fix(runtime): delete squads referencing archived agents before runtime teardown
The DeleteAgentRuntime handler was failing with 500 'failed to clean up
archived agents' because squad.leader_id has an ON DELETE RESTRICT FK on
agent(id). When an archived agent was still referenced as a squad leader
(even on an archived squad), the DELETE FROM agent query was blocked.
Fix: add DeleteSquadsByArchivedAgentsOnRuntime query that removes squads
whose leader_id points to an archived agent on the target runtime, and
call it before DeleteArchivedAgentsByRuntime in the handler.
Closes TMI-85
* test(runtime): cover squad cleanup before archived-agent deletion
Adds four tests around the DeleteSquadsByArchivedAgentsOnRuntime fix:
* TestDeleteSquadsByArchivedAgentsOnRuntime_Query — query-level: deletes
squads whose leader is an archived agent on the target runtime, leaves
squads with active leaders or archived leaders on a different runtime
alone, and is safe to call when nothing matches. Covers the archived-
squad case that originally hid the FK blocker from `multica squad list`.
* TestDeleteAgentRuntime_RemovesSquadsLedByArchivedAgents — handler
end-to-end regression for TMI-85. Reverting the handler change makes
this fail with the exact 500 'failed to clean up archived agents' the
user reported.
* TestDeleteAgentRuntime_NoSquadsRegression — happy path for runtimes
whose archived agents were never squad leaders, ensuring the new step
is a no-op there.
* TestDeleteAgentRuntime_StillBlockedByActiveAgents — preserves the 409
CountActiveAgentsByRuntime guard so the active-agent contract isn't
silently regressed by the new cleanup ordering.
Refs TMI-85
* chore: remove internal issue tracker references from test comments
* fix(runtime): keep active squads during runtime teardown
* fix(runtime): block runtime delete on active archived-leader squads
* fix(runtime): make runtime delete 409 path a no-op
---------
Co-authored-by: Kiro <kiro@multica.ai>
The logo (resolved avatar_url) branch was missing the border the fallback
tile and web's <img> carry, and didn't thread the className prop. NativeWind
has no cssInterop for expo-image, so className/border on <ExpoImage> is
silently dropped — wrap the logo in an overflow-hidden View that carries
border border-border + className (the same pattern lib/markdown/markdown-image.tsx
uses to border/round an expo-image). Both branches now match web parity.
Follow-up to #3839. MUL-3096
Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The per-agent "CLI" column rendered the shared multica daemon `cli_version`
from each runtime's metadata. That value is the version of the multica
daemon binary and is identical for every agent registered by one daemon, so
Claude / Codex / Gemini / Opencode all displayed the same number (e.g.
v0.3.17) even though each tool has its own version (#3838).
Each runtime already reports its own underlying CLI tool version in
`metadata.version` (e.g. "2.1.5 (Claude Code)", "codex-cli 0.118.0"). The
column now shows that. The multica daemon CLI version and its update prompt
stay where they belong — the machine meta strip and the detail page's
UpdateSection — so the per-row multica update arrow (which compared against
the latest multica release) and its now-unused i18n strings are removed.
MUL-3097
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The workspace switcher showed a generic `sf:building.2` glyph for every
workspace and never used `workspace.avatar_url`, and the switch sheet,
confirm dialog, and More-tab entry row shipped hardcoded Chinese strings
(mobile is English-only — no i18n infra yet).
- Add `components/workspace/workspace-avatar.tsx`, mirroring web's
`packages/views/workspace/workspace-avatar.tsx`: a resolved `avatar_url`
renders as a rounded-square logo, otherwise the workspace's initial
letter sits in a muted tile. URL resolution reuses the existing
`resolveAttachmentUrl` helper (the mobile mirror of core's
`resolvePublicFileUrl`).
- Use `WorkspaceAvatar` in the switcher list and the More-tab entry row.
- Replace the hardcoded Chinese strings with English.
Co-authored-by: Matt Voska <voska@users.noreply.github.com>
Mainland Feishu binding works; only the newly-added Lark (international,
open.larksuite.com) install path is unreliable — some Lark installs
complete on Lark's side but never persist a lark_installation row (no WS,
no inbound, no task). Hide just the "Bind to Lark" CTA behind a single
LARK_INTL_CONNECT_ENABLED flag and leave the "Bind to Feishu" entry, the
settings panel, and all existing-installation management untouched.
Flip LARK_INTL_CONNECT_ENABLED back to true to restore the Lark CTA;
nothing else changes. Temporary measure while the Lark install-landing
bug is investigated.
- LarkAgentBindButton: the Lark button is gated by the flag; the Feishu
button and the Connected badge / Manage / Disconnect are unchanged.
- Tests: the CTA tests assert Feishu shown + Lark hidden; the Feishu
click-to-begin (region=feishu) test stays; the Lark click test was
removed (no button) and noted for restore; the dialog polling-error
tests open via the Feishu CTA.
MUL-3083
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): resolve real speaker names in group context (MUL-3084)
The recent-context block (and quoted/forwarded blocks) labeled senders
positionally as "User 1 / User 2", and the agent had no idea who had
@-mentioned it. Add APIClient.BatchGetUsers (contact/v3/users/batch) and,
on the group prefetch path, resolve the surrounding speakers AND the
trigger sender to display names in one batch call. Speakers now render as
"[Alice]: ..." and the user's own message as "[Charlie]: ..." so the
agent knows who addressed it. Unresolved senders (restricted contact
scope, deactivated user) fall back to positional "User N"; resolution is
best-effort and never blocks ingestion. Closes the standing speaker-name
TODO in the enricher.
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): resolve names for quoted/forwarded senders too (review)
Address the #3828 review: BatchGetUsers only included the recent-window
and trigger senders, so a quoted parent / merge_forward child whose
sender was NOT in the recent window still rendered as "User N".
Restructure Enrich into fetch (Phase 1) -> resolve names (Phase 2) ->
render (Phase 3): quote/forward items are now fetched up front and their
senders folded into the single Contact batch, so every block (recent +
quoted + forwarded) shows real names in group chats. p2p keeps positional
labels. Replaces the fetch+render renderQuoted/renderForwarded with a
render-only renderQuotedBlock plus an inline forward fetch.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083 follow-up)
The single "Bind to Lark" button began the device flow against
accounts.feishu.cn and relied on a mid-poll tenant_brand="lark" to
auto-switch international users over to accounts.larksuite.com. Lark
users had to scan a QR served from a Feishu domain first, which
surfaced as confusing in real use.
Replace with two explicit CTAs side by side — "Bind to Feishu" and
"Bind to Lark" — and route the device-flow begin straight to the
matching accounts host based on the user's choice. The mid-poll
auto-switch is preserved as a safety net for users who pick the wrong
entry.
Backend
- RegistrationClient.Begin(ctx, namePreset, region): POSTs to
c.cfg.LarkDomain when region=lark, c.cfg.Domain otherwise. Empty /
unknown region falls back to Feishu (matches RegionOrDefault).
- BeginInstallParams.Region threads through to the registration session
and onto runPolling's initial region local. SwitchedDomain still
flips it on tenant_brand=lark.
- POST /api/workspaces/{id}/lark/install/begin accepts ?region=feishu|lark
with empty defaulting to feishu for back-compat.
Frontend
- api.beginLarkInstall(wsId, agentId, region) — region now required
so every call site is forced to pick a cloud explicitly.
- LarkAgentBindButton renders two buttons; dialog state collapsed into
a single dialogRegion useState so an "open but with no region picked"
intermediate state can't exist.
- LarkInstallDialog takes region as a required prop and renders
region-aware copy (title, description, scan hint, link fallback,
success toast).
i18n
- Add bind_button_{feishu,lark}, install_dialog_{title,description}_*,
install_scan_hint_*, install_open_link_fallback_*, and
install_success_toast_* keys across en, zh-Hans, ja, ko. Legacy
single-region keys are kept for now; nothing in the tree references
them anymore but a follow-up cleanup can remove them once the dust
settles.
Tests
- Two new lark.RegistrationClient tests pin region routing in both
directions (region=lark hits LarkDomain; region=feishu hits Domain).
- Two new lark-tab.test.tsx cases pin that clicking each CTA calls
beginLarkInstall with the matching region argument. Existing CTA
tests updated to expect both buttons in place of one.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): bidirectional tenant_brand swap + region-aware badge + link context menu
Addresses Elon's review on PR #3832 plus a separate report that the
"Or tap here to open in Lark" link in the install dialog had no
standard right-click affordances on the desktop app.
Backend (must-fix from review)
The PR's stated 'safety net for users who pick the wrong CTA' only
worked one direction: a Feishu-first begin already swapped to Lark on
tenant_brand=lark, but the new Lark-first begin (added by this same PR)
had no reverse path — a user who picked 'Bind to Lark' but actually
authorized with a Feishu account would carry RegionLark all the way
through finishSuccess and either fail at GetBotInfo or commit a
wrong-region row.
- PollResult now carries SwitchedDomain AND SwitchedRegion in
lockstep, so the caller never has to re-derive region from the
domain string.
- Poll() detects tenant_brand=feishu while polling against a non-Feishu
host symmetrically with the existing tenant_brand=lark check, gated
on the current host so we don't loop on a brand we already match.
- runPolling reads region from res.SwitchedRegion instead of the
hardcoded RegionLark — the SwitchedDomain branch now flips both
feishu→lark and lark→feishu cleanly.
- Tests: updated the existing TestRegistrationClient_Poll_DomainSwitchOnLarkTenant
to assert SwitchedRegion, added TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant
for the reverse, and TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost
(table-driven, both directions) to pin that the gate doesn't loop.
Backend (nit from review)
Handler comment on /lark/install/begin claimed unknown region defaults
to Feishu downstream, but the handler already returns 400 on unknown
values. Updated the comment to match the actual behavior and document
why we 400 rather than silently normalize (so a frontend typo can't
land users on the wrong cloud without telling them).
Frontend (nit from review)
The Agent inspector's Connected badge was hardcoded 'Connected to
Lark' / 'Manage in Lark' (en) and 'Connected to Feishu' / 'Manage in
Feishu' (zh-Hans) — both wrong half the time now that the install
flow can land on either cloud per agent. Made the badge text and
Manage tooltip read from installation.region:
- agent_bot_connected_label_{feishu,lark}
- agent_bot_manage_link_{feishu,lark}
- agent_bot_manage_tooltip_{feishu,lark}
across en / zh-Hans / ja / ko. Legacy single-region keys retained for
safety. Existing badge tests updated: fixtures without 'region' now
expect the Feishu copy; the region: 'lark' test was promoted to also
assert the Lark badge text and link target. 21/21 lark-tab tests pass.
Desktop (separate report)
Right-clicking an <a> in the renderer surfaced only Copy / Cut /
Paste / Select All — no 'Open Link in Browser' or 'Copy Link Address'.
The renderer's <a target="_blank"> click path already routes through
setWindowOpenHandler → openExternalSafely, but discoverability via the
context menu was missing.
context-menu.ts now appends two link-specific items when params.linkURL
is an http(s) URL. Open Link routes through openExternalSafely (reuses
the existing scheme allowlist); Copy Link Address writes to Electron's
clipboard. Labels are localized to the OS preferred language for the
four locales the renderer ships (en / zh-Hans / ja / ko); zh-* variants
all route to zh-Hans, anything else falls back to English. New
context-menu.test.ts pins five cases: link items show for http(s),
not for javascript:/mailto:/etc., not when no link is under the cursor,
zh-CN gets Chinese, fr-FR falls back to English. 198/198 desktop tests
pass.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
The agent Lark binding surfaced the same connect/disconnect affordance in
two places on one page — the left inspector's INTEGRATIONS section and the
right pane's Integrations tab both rendered the full LarkAgentBindButton,
so the destructive Disconnect lived in two spots.
Split by role:
- Inspector (left): a compact, read-only status row (green dot + region
chip + "Connected to Lark") that deep-links into the Integrations tab.
New LarkAgentBotStatusRow, opted into via LarkAgentBindButton's
onShowConnectedDetails prop.
- Integrations tab (right): keeps the full badge, now the single home for
Manage / Disconnect. The badge itself is reworked to a two-row layout —
status (left) + soft `destructive`-variant Disconnect (right) on row 1,
"Manage in Lark" demoted to a muted secondary link on row 2.
Cross-sibling navigation goes through a one-shot navIntent channel on
AgentOverviewPane that routes via requestTabChange, so the unsaved-changes
guard still fires when jumping from the inspector.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(issues): remove comment composer expand control
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): auto-grow composers and highlight reply submit when ready
- Drop max-height cap on comment + reply composers so they grow with content
- Reply send button turns primary when there is submittable text
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface a Disconnect/Unbind action in LarkAgentBotConnectedBadge so
owners and admins can remove a Lark Bot binding directly from the
agent inspector — no detour to Settings. The button sits next to the
existing 'Manage in Lark' link and is intentionally rendered as a
quieter muted-foreground control with a hover-destructive accent so
it doesn't compete visually but stays discoverable.
Confirmation is mandatory: a small AlertDialog reuses the existing
disconnect_confirm_* i18n strings. The action calls
api.deleteLarkInstallation, invalidates larkKeys.installations(wsId)
on success so the parent re-renders the Bind CTA, and toasts
success/failure. Cancel is disabled while the request is in-flight
to prevent racing the close.
Tests cover button visibility, confirm gating, success path (delete
called with correct args, cache invalidated, toast), error path (no
invalidate, toast.error), and Cancel-disabled behaviour.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): recognize official cloud by frontend host in daemon setup config
The 'Add a computer' dialog builds its command from /api/config's
daemon_server_url/daemon_app_url, falling back to 'multica setup' when
both are empty. The official cloud is meant to omit them, but the
omission only fired when MULTICA_PUBLIC_URL=https://api.multica.ai. When
that env is unset the server URL defaults to the frontend origin and the
old guard (which required serverURL host == api.multica.ai) didn't match,
so the dialog emitted 'multica setup self-host --server-url
https://multica.ai' — pointing the daemon backend at the frontend (no
/health, no WebSocket proxy).
Identify the official cloud by its frontend host alone (multica.ai /
app.multica.ai) so a missing or misconfigured MULTICA_PUBLIC_URL can no
longer leak the broken self-host command. Regression from #3474.
* fix(cli): probe before persisting self-host config to preserve auth on failure
setup self-host wrote a fresh CLIConfig{ServerURL, AppURL} (a full
overwrite that drops the saved token) and only then probed the server,
returning early on failure. A failed probe therefore logged the user out
and left them unconnected, with no recovery in the same command.
Probe first via persistSelfHostConfigIfReachable: an unreachable server
leaves the existing config — and its token — untouched (failed setup =
no-op). The prober is injected so both branches are unit-tested.
* fix(daemon): serve health before preflight so daemon start readiness is accurate
The CLI's 'daemon start' polls the health endpoint for 15s expecting
status=running, but the daemon only began serving health after
preflightAuth, whose initial workspace sync detects every configured
agent's version by exec'ing it (~20s cold with 8 agents). Health served
too late, so a perfectly healthy daemon printed 'may not have started
successfully'.
Start the health server right after resolveAuth (which still fails fast
on a missing token) and before the slow preflight, so readiness reflects
the daemon core being up rather than agent-version detection finishing.
* fix(daemon): gate /health readiness so daemon start can't report a false start
Serving health before preflightAuth fixed the false-negative (a healthy
daemon printed "may not have started"), but health still returned
status:"running" unconditionally — before preflight (PAT renew + workspace
sync + runtime registration) had completed. `daemon start` and the desktop
treat "running" as ready, so a slow or *failing* preflight could be
misreported as a started daemon: setup prints "connected", then the process
exits or hangs in agent-version detection with no runtime registered. That
is harder to diagnose than the original false-negative.
Split liveness from readiness: bind/serve the health port early (so callers
see a live "starting" daemon instead of connection-refused), but report
status:"starting" until d.ready is set after preflight, then "running".
- daemon.go: add d.ready (atomic.Bool); set it true after the background
loops launch, before pollLoop.
- health.go: healthHandler reports "starting" until ready, else "running".
- cmd_daemon.go: `daemon start` waits for "running" with a deadline raised
to 45s (covers cold-start agent detection) and a clearer "still starting"
message; new daemonAlive() helper treats both "running" and "starting" as
a live daemon, so the already-running guard, restart, and stop act on a
starting daemon and don't double-spawn or race its listener; `daemon
status` shows "starting" distinctly.
Older CLIs/desktop that only know "running" safely treat "starting" as
not-ready (status != "running"), so no boundary break.
Tests: health reports starting-then-running; daemonAlive truth table.
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): handle daemon "starting" health status in lifecycle
The daemon now reports /health status:"starting" until preflight completes
(liveness/readiness split). That made "starting" a new external contract of
/health, but the Desktop daemon-manager only knew "running", so the readiness
fix would have moved the CLI's false-negative into a Desktop start regression:
- `daemon start` now blocks up to 45s waiting for readiness, but the Desktop
spawned it via execFile({ timeout: 20_000 }). On a cold start (the ~20s agent
detection this PR targets) Electron killed the CLI supervisor at 20s and
reported a start failure, even though the detached daemon child kept booting —
the UI flashed "stopped" then "running". Raise the timeout to 60s (must exceed
the CLI's 45s startupTimeout).
- The Desktop treated only raw status === "running" as a live daemon, so a
daemon that was still "starting" (booting on its own or started via the CLI)
showed as "stopped", and startDaemon() would spawn a second one — which the new
CLI rejects as "already running", surfacing as a start error.
Add daemonStatusAlive() (shared, pure, unit-tested) mirroring the Go daemonAlive()
and use it for liveness: fetchHealth() surfaces a daemon-reported "starting" as
state "starting" regardless of our own currentState; startDaemon()'s
already-running guard and the restart-on-user-switch guard treat "starting" as an
existing daemon. version-decision stays gated on "running" (readiness, not
liveness) — unchanged.
Verified: desktop typecheck, eslint, full vitest suite (193 tests) all pass.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): prefetch surrounding group context on @-mention (MUL-3084)
In Feishu group chats the Bot only saw the single message that @-mentioned
it — never the surrounding conversation — because the inbound enricher only
inlined context the user explicitly attached (a quoted reply or a
merge_forward), and the API client had no way to list a chat's history.
Add APIClient.ListChatMessages (GET /open-apis/im/v1/messages,
container_id_type=chat, ByCreateTimeDesc, page_size clamped to Lark's 50
cap) and, for a group message addressed to the Bot, prefetch a bounded
window of recent messages and inline them as a <recent_context> block
ahead of the user's own message. The trigger and any quoted parent are
excluded so nothing is duplicated; speakers are labeled positionally
(User 1/2 / Bot); failures degrade to a visible placeholder and never
block ingestion. Window size is configurable via
InboundEnricherConfig.RecentContextSize (<=0 disables); production wires
DefaultRecentContextSize (20). One list call per addressed turn keeps the
fetch within the inbound ACK / EnrichTimeout budget.
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): anchor group context window to trigger time, default 10
Address review feedback on MUL-3084:
- Anchor the recent-context prefetch to the trigger message's time:
thread the message create_time through InboundMessage and pass it as
the list end_time (millis -> seconds), so the window is the
conversation up to the @-mention rather than whatever is newest when
the slightly-later prefetch HTTP call runs. end_time is omitted when
the time is missing/unparseable (falls back to newest N).
- Lower DefaultRecentContextSize from 20 to 10.
Co-authored-by: multica-agent <github@multica.ai>
* docs(lark): clarify recent-context persistence stance and fetch-window semantics
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): region-aware doJSON for ListChatMessages after rebase
origin/main merged #3815 (Lark dual-region support), which changed
doJSON to take a per-call baseURL resolved via resolveBaseURL(creds).
Adapt the new ListChatMessages call to that signature so the backend
build passes against latest main, and refresh the now-stale
ListMessagesParams comment (EndTime is exposed).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
@tiptap/markdown parses via marked, whose tokenizer is O(n²) in document
length. Opening a large markdown doc (issue description, agent
instructions, …) froze the UI for tens of seconds: a 533KB plain-text doc
took 61.8s to parse while the subsequent ProseMirror setContent was only
40ms. Upgrading marked doesn't help — already on 17.0.5, whose fix only
covers `_`/`*` delimiter runs, not general prose.
Parse large markdown in chunks instead of in one shot: split on blank
lines outside fenced code blocks, parse each chunk independently, then
concatenate the resulting docs. This drops marked's cost to O(n²/k) while
producing a byte-identical document. Applied transparently at
ContentEditor's two parse entry points (mount + WS-driven re-parse), gated
at 50KB so normal small docs stay on the single-parse fast path.
533KB: parse 61.8s -> 0.95s (65x), open 100s -> 3.2s (31x).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #3797. The inbox:new handler keys the notification-preference
query on item.workspace_id, but the request itself still resolved its
workspace from the active-workspace X-Workspace-Slug header. On a cold
cache, a user viewing workspace B who received a workspace-A notification
read B's mute setting and cached it under A's key — so A's banners could
fire while muted (and vice-versa), polluting A's cache.
Add an optional workspaceSlug override to getNotificationPreferences and
notificationPreferenceOptions, and pass the resolved source slug from the
inbox:new handler. When the source slug can't be resolved, read only an
already-warm cache instead of fetching with the wrong workspace. Tests
cover the cold-cache source-slug fetch, source mute suppression, and the
no-fallback guard.
MUL-3062
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>