* docs(changelog): add 0.4.1 release notes across en/zh/ja/ko
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): cancel scheduled update checks when auto-update is disabled
The startup and periodic update timers were left running when a user turned
automatic updates off; the timer callbacks only consulted the preference
asynchronously, so a tick that raced the preference flip could still fire a
check. Cancel the timers on disable (and re-arm them on re-enable) so disabling
truly stops future background checks, removing a CI-flaky race in updater.test.ts.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): drop issue-view virtualization improvement from 0.4.1
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
The "skips startup and periodic checks when automatic updates are
disabled" case advanced fake timers without awaiting the async
preference load. On slow CI the in-flight readFile resolved after
afterEach() removed the temp dir, defaulted enabled back to true, and
fired a deferred background check into the next test's freshly-cleared
shared mock — making "persists the automatic update preference and stops
future background checks" flake with checkForUpdates called once.
Await updater:get-preferences (which awaits preferencesReady) before
advancing timers so the read settles against the existing file and no
background work outlives the test. Test-only change; production behavior
is unaffected.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(inbox): virtualize notification list (MUL-4474)
The inbox notification list rendered every item at once. Each row mounts an
avatar + hover card, so a long inbox inflates the tab-switch commit — the
same render-amplifier class this issue targets.
Extract an InboxList component that virtualizes the rows via react-virtuoso
(customScrollParent over the existing overflow-y-auto element, same pattern
as the issue-detail timeline). Only the visible window plus a small overscan
is mounted; everything else — selection, hover, archive, scroll semantics,
the row component and callbacks — is unchanged. Virtualization changes
exactly one thing: whether an off-screen row is in the DOM.
Slice 2a of MUL-4474 (inbox is the no-DnD surface, done first to prove the
Virtuoso + scroll + keyboard harness before the drag surfaces). Draft: must
pass the manual zero-functional-change pass on a real Desktop build before
merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(board): virtualize board columns (MUL-4474)
Each board column rendered every card at once; cards carry pickers, avatars,
and a per-issue activity indicator, so a tall column inflates the tab-switch
commit. Virtualize the cards within each column via react-virtuoso, using the
column's own scroll container as customScrollParent.
The dnd-kit droppable stays on the always-mounted column scroll container
(merged callback ref feeds both dnd-kit and Virtuoso), and SortableContext
still wraps the full id list. So cross-column drops (status/assignee change)
and reorder among on-screen cards are unchanged; reordering to an off-screen
target relies on drag auto-scroll to mount it — the documented virtualization
tradeoff, to be confirmed in the manual pass. The infinite-scroll sentinel
rides Virtuoso's Footer slot so loadMore still fires at the bottom, and a
per-item pt-2 reproduces the previous space-y-2 gap with padding inside the
measured item box.
issues-page.test.tsx: mock react-virtuoso to render items inline (jsdom has no
layout), and make the useDroppable mock's setNodeRef referentially stable to
match real dnd-kit — the board's merged customScrollParent ref would otherwise
loop on a fresh ref each render.
Slice 2b of MUL-4474 on the shared inbox/list/board/swimlane branch. Draft:
requires the manual zero-functional-change pass on a real Desktop build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(list): virtualize issue list rows (MUL-4474)
The status-grouped list rendered every row in every expanded section at once;
each row carries a sortable, context menu, tooltip, and activity indicator, so
a long list inflates the tab-switch commit. Virtualize each expanded section's
rows with react-virtuoso, all instances sharing the page's single scroll
container as customScrollParent.
Everything structural is preserved by construction: the Base UI accordion,
sticky status headers, collapse, the per-section useDroppable, the per-section
SortableContext, and the load-more sentinel (now Virtuoso's Footer). The
Virtuoso only mounts for an expanded section (a collapsed/hidden panel has no
viewport to measure). Virtualization changes exactly one thing: whether an
off-screen row is in the DOM.
issue-surface.test.tsx: mock react-virtuoso inline (jsdom has no layout) so the
surface-level loading-semantics assertions still observe the list's rows.
Slice 2c of MUL-4474 on the shared branch. Draft: requires the manual
zero-functional-change pass on a real Desktop build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(swimlane): virtualize lanes (MUL-4474)
The swimlane rendered every lane (each a full row of status cells) at once.
Virtualize the vertical lane axis with react-virtuoso over the board's outer
scroll box (customScrollParent), so only on-screen lanes stay mounted.
Behavior is preserved: pinned lanes keep their leading position, the
SortableContext still wraps the lane set for grip-drag reorder (its items are
only the non-pinned lane ids), per-cell droppables and per-cell card
SortableContexts are unchanged (cells live on mounted lanes), the sticky status
header stays above the list, and the per-status load-more sentinels ride
Virtuoso's Footer. pt-4 per lane reproduces the previous gap-4.
swimlane-view.test.tsx: mock react-virtuoso inline so the ~47 lane/cell/DnD
assertions still see the lanes the virtualized list renders.
Slice 2d of MUL-4474 on the shared branch — this completes the four surfaces
(inbox/board/list/swimlane). Draft: requires the full manual
zero-functional-change pass on a real Desktop build before merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): don't pass undefined to Virtuoso `components` (MUL-4474)
react-virtuoso seeds its `components` prop with an internal `{}` default;
passing `components={undefined}` (which the list and board did when there was
no Footer — hasMore false / no column footer) overwrites that default with
undefined, so Virtuoso's startup destructure of `EmptyPlaceholder`/`Footer`
throws and the surface crashes. jsdom tests mock react-virtuoso so this only
surfaced on a real Desktop build (found in manual perf testing).
Return a stable module-level empty object instead of undefined. Inbox (omits
the prop entirely) and swimlane (always supplies a Footer) never hit this and
are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): show runtime alias + provider consistently (#5260)
The daemon bakes the provider into runtime.name ('Codex (host)') while a
custom alias is stored separately in custom_name. runtimeDisplayName()
returned the bare alias and dropped the provider, and the agent list and
profile card rendered raw name, ignoring the alias entirely.
Add runtimeDisplayLabel(): with an alias it renders 'alias (Provider)',
otherwise it returns the daemon name unchanged (no duplicated provider).
Route the agent personal page, list Runtime column, and profile card
through it.
Fixes#5260
* fix(agents): use provider display-name map for aliased runtime label
Address review on #5340: a title-cased slug mislabels providers whose
display name differs from the slug (traecli -> 'Traecli' instead of
'Trae') and flattens mixed-case families (CodeBuddy / OpenCode /
OpenClaw). Add a provider display-name map mirroring the ProviderLogo
switch, with a title-case fallback for unknown slugs.
* fix(agents): align provider display map with daemon contract
Follow-up on #5340 review: the previous map canonicalized codebuddy /
opencode / openclaw as CodeBuddy / OpenCode / OpenClaw, but the daemon's
runtimeDisplayNameOverrides only special-cases traecli and first-letter-
capitalizes the rest. That recreated alias/no-alias drift for those
providers ('Openclaw (host)' vs 'box (OpenClaw)').
Shrink the frontend map to mirror the daemon exactly (traecli -> Trae,
first-letter fallback otherwise) and point the comment at the daemon map
as the source of truth. Tests updated to lock the alignment.
* fix(execenv): overlay per-task HERMES_HOME so Hermes discovers bound skills
Hermes has no workspace-relative skill discovery — it scans <HERMES_HOME>/skills
first, then skills.external_dirs from config.yaml (verified against the bundled
agent/skill_utils.py). The daemon wrote assigned skills to the generic
.agent_context/skills/ fallback, which Hermes never reads, so they silently never
took effect (#5242).
When (and only when) an agent has skills bound, redirect HERMES_HOME to a minimal
per-task compatibility overlay; a skill-less Hermes task keeps its real home and
original behavior:
- mirror every top-level entry of the shared home via symlink except the
overlay-owned ones (denylist), reconciling entries deleted from the shared home;
- derive a task-local config.yaml whose skills.external_dirs references the shared
skills dir plus the user's existing external_dirs, expanded against the
sanitized effective child env (unknown vars preserved, blocklisted keys resolved
to the process value) and normalized to absolute paths;
- write only the bound skills into the task-local skills/ dir (home skills scanned
first, so they win); global skills are referenced, not copied;
- keep memories/ overlay-owned (fresh per-task dir) AND disable the external
memory.provider, so neither on-disk memory nor a shared backend crosses tasks;
- keep active_profile/profiles out of the overlay so Hermes can't follow a sticky
profile and redirect past it at startup.
Profile handling mirrors hermes_cli.profiles: the daemon reads -p/--profile with
agent.HermesProfileFromArgs and seeds the overlay from that profile's home via
ResolveHermesSourceHome (default/invalid -> base, valid name -> <base>/profiles/
<name>, validated; a missing named profile fails closed). The profile flags are
stripped from the acp argv ONLY when the overlay is active (hermesLaunchArgs), so
a skill-less task's profile passes through unchanged. HERMES_HOME is no longer
custom_env-blocklisted: no skills -> user value passes through; skills -> overlay
overrides after layering. Fail closed — Prepare errors, Reuse returns nil.
Task home 0700, derived config 0600 via atomic replace. Platform-native default
home (%LOCALAPPDATA%\hermes, incl. the LOCALAPPDATA-missing fallback, on Windows).
Tests span execenv/daemon/agent: no-skill no-op, child-env layering + env
sanitization, profile parse/unquote + conditional strip + final args/env per
scenario, custom/profile/default/invalid/missing/Windows source home, sticky-
profile not mirrored, memory dir isolation + external provider disable, mirror
reconciliation, external_dirs rebasing + sanitized/unknown-var expansion,
local-precedence slug, perms, fail-closed, resume teardown. Docs (en + ja/ko/zh).
Fixes#5242
* fix(hermes): make profile selection one resolver contract matching Hermes
Round 5 review: the profile chain approximated Hermes' semantics in three
separate places (argv parsing, source-home selection, arg filtering), so it
diverged from native Hermes in several merge-blocking cases. Collapse it into
one authoritative resolution:
- agent.ParseHermesProfileArgs replaces HermesProfileFromArgs/
FilterHermesProfileArgs. It reproduces _apply_profile_override step 1/1b
(first occurrence, value-flag skipping, `--` and `mcp add --args` boundaries,
space-form profile-id guard) and returns the exact argv occurrence to consume;
StripHermesProfileArgs removes only that occurrence.
- execenv.ResolveHermesProfile replaces ResolveHermesSourceHome. It derives the
Hermes root exactly like get_default_hermes_root (an already-profile-scoped
HERMES_HOME roots at its grandparent), selects an explicit profile first,
otherwise trusts a profile-scoped home (step 1.5) and only then the sticky
<root>/active_profile (step 2), and validates via normalize/validate_profile_name
(reserved hermes/test/tmp/root/sudo and empty inline `--profile=` are hard
errors). Profiles always resolve under the root, so `-p default` re-roots and
`-p <sibling>` is a sibling, never nested.
- The daemon runs one parse + resolve, fails the task closed on a reserved/
invalid selection (matching Hermes' sys.exit(1)), and exports the selected
source home as the effective env's HERMES_HOME so ${HERMES_HOME} in a profile's
skills.external_dirs expands against the selected profile home (as native
Hermes does before loading config.yaml), not the root or the overlay.
Regressions added: root + sticky named profile selection; already-profile-scoped
home with no flag; that home with -p default and -p <sibling>; reserved and empty
inline profile values; and a selected profile whose external_dirs contains
${HERMES_HOME}.
* fix(hermes): overlay-owned derived .env + symlink-resolved root
Round 6 review, two remaining overlay-bypass paths:
1. A source `.env` could redirect HERMES_HOME after profile resolution. Hermes
runs `_apply_profile_override()` then `load_hermes_dotenv()`, which loads
`<HERMES_HOME>/.env` with override=True — so a mirrored source `.env` carrying
an out-of-band `HERMES_HOME=` overwrote the overlay's home, repointing skill
discovery and memory back at the source. `.env` is now overlay-owned and
DERIVED (writeDerivedHermesEnv): it preserves the source's credentials/settings
but strips any `HERMES_HOME` assignment and pins `HERMES_HOME` to the overlay
last (single-quoted, literal), written 0600 via atomic replace. It is written
even when the source has none, so Hermes' project-`.env` fallback (override=True
only when no user `.env` loaded) can't relocate the home either.
2. Root derivation was lexical-only, diverging from `get_default_hermes_root`,
which compares `env_path.resolve()` with `native_home.resolve()`. A HERMES_HOME
symlinked into `<native>/profiles/<x>` was treated as its own root, so
`-p default`/`-p <sibling>` resolved wrong. `hermesRootFromHomeFor` now resolves
symlinks (Path.resolve(strict=False)-style best effort) for the containment
decision while keeping the returned root unresolved, matching Hermes.
Regressions: source `.env` with HERMES_HOME replayed through the override=True
dotenv order (bound skill + task memory stay on the overlay; creds preserved);
minimal overlay `.env` created when the source has none; and a symlinked profile
home resolving `-p default`/`-p <sibling>` to the native root.
Each issue row's IssueAgentActivityIndicator subscribed to the whole
workspace agent-task snapshot via useQuery. Any task change swaps the
snapshot array reference, so every observing row re-rendered — on a busy
workspace the snapshot changes constantly, turning one task update into a
full-list re-render and inflating the tab-switch commit.
Narrow each row's subscription to this issue's tasks with a `select`
(selectIssueTasks). React Query's structural sharing keeps the selected
value referentially stable when the issue's own tasks are unchanged, so a
snapshot invalidation now only re-renders the rows whose tasks actually
moved.
This is slice 1 of MUL-4474 (render de-amplification). Virtualization of
list/board/swimlane/inbox and the non-position useSortable mount change
are tracked separately — they need interactive drag + DevTools Performance
verification that the headless runtime can't provide.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The daemon pins each agent CLI's symlink-resolved absolute path at startup to
block PATH-redirect of a task launch. A version manager (Homebrew Cask, nvm/fnm)
upgrading in place deletes the pinned versioned directory and repoints the stable
name, leaving the daemon on a path that no longer exists — every codex task, model
list, and version detection then hard-fails with "executable not found" until the
daemon restarts.
resolveAgentEntry now self-heals a vanished pin by re-resolving the recorded
command once, version-detecting and min-version-gating the candidate before
adopting it, and publishing {path, version} atomically so callers key policy off
the binary that actually runs. Coalesced with singleflight; a live heal wins over
a reappearing stale path; custom runtimes and custom-only hosts are untouched.
Applied at task launch, model listing, and registration.
MUL-4486
The Agents list gated its first paint only on the main agent-list query
(prefetched, so usually warm) while the sort/filter columns arrive in
separate activity / run-count / presence queries. On entry the default
lastActive sort ran on placeholder values (lastActiveDays null→Infinity,
runCount 0), painting a degenerate name-ordered list that visibly
re-ordered once each auxiliary query resolved (1–2 jumps).
Add a need-based `listReady` render gate: wait for exactly the auxiliary
queries the active sort field / filter depends on — nothing for
name/created, run-counts for runs, activity + run-counts for the default
lastActive, plus presence when an availability filter is active. Queries
still run in parallel, so this only defers the first paint by at most one
round-trip (shown as skeleton); an empty workspace skips the gate so the
empty state is never blocked. Scope is the Agents page only.
Adds agents-page.test.tsx covering the five gate scenarios.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
* 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 <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The `@`-mention and `/`-command suggestion popups defaulted to
`placement: "bottom-start"`, so they stayed below the caret whenever any
space existed below it — even when far more room was above. In
bottom-anchored composers (chat input, issue comment/reply) that meant the
list opened down over the send controls and, near the viewport bottom, was
squashed or clipped off-screen.
Two compounding causes:
- The preferred side was the cramped one. Composers' roomy side is above
the caret, so default to `top-start`; `flip` still sends it down when the
caret is near the viewport top.
- The floating-ui `size` middleware wrote `maxHeight` on the outer wrapper,
which does not clip — the inner list is the scroll container and carried
its own fixed `max-h-[300px]/[420px]`. That viewport-unaware cap was the
real height authority and could overflow. Publish the size middleware's
`availableHeight` as a CSS var and have the list clamp to
`min(designMax, availableHeight)`, so there is a single, viewport-aware
height authority. Drop the old `Math.max(120, ...)` floor that forced
overflow in tight bands.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): correct unread — archived sessions + mount-race auto-read (MUL-4360)
Reland of #5315, which was reverted by #5332 as collateral in an unrelated
release-wide revert (to unwind 162/163 migration BLOCK from other PRs), not for
any defect in this code — Howard/Preflight assessed these changes WARN /
non-blocking. Restores all three fixes verbatim off current main:
- backend: ListAllChatSessionsByCreator derives unread_count=0 / has_unread=false
for status='archived' rows via a CASE gate. last_read_at is untouched, so
unarchiving restores the true unread state. Single source of truth for every
unread surface (quick-chat FAB, sidebar Chat tab, chat-window header, mobile);
installed desktop clients benefit with no app update.
- frontend: the archive mutation onMutate optimistically zeroes the row's unread
so no badge counts a just-archived session in the frame before the refetch
lands. Unarchive does not fabricate a count — the true state returns from the
server refetch.
- frontend: auto-mark-read is deferred a tick and cancelled on cleanup, so a
session that is only momentarily active on mount (persisted activeSessionId
restored for one frame, then cleared by the URL->store effect) is not marked
read; only a session that stays active past the tick is.
Verification: sqlc regenerate produces no drift; go test ./internal/handler
-run 'TestListChatSessions_ArchivedSessionReportsZeroUnread|TestSetChatSessionArchived_ClearsChannelBinding'
passes against a real Postgres; vitest mutations.test.tsx (3) and
use-chat-controller.test.tsx (8) pass; core + views typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): converge archived unread on chat:session_updated realtime event (MUL-4360)
Howard's #5333 review found a real cross-tab gap: the chat:session_updated
handler patched status but never unread, and chatSessionsOptions is
staleTime: Infinity, so a session archived in one tab kept its unread badge lit
in another tab/device forever — the same stuck-badge bug, one surface over.
Extract the inline handler into applyChatSessionUpdatedToCache and force
unread_count=0 / has_unread=false when payload.status === "archived", mirroring
the archive mutation's optimistic patch and the backend deriving unread=0 for
archived rows. Unarchive does NOT fabricate a count — the true unread returns
from the server refetch (last_read_at untouched). No sessions-list
invalidation; minimal field patch as reviewed.
Adds use-realtime-sync.test.ts coverage: an archived event zeroes a cached
unread row; an active event does not resurrect unread; a rename-only event
leaves unread untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Gate the Chat unread badge and chat auto mark-read on a shared useAppForeground() signal (document visible AND window focused). A reply arriving while the app is backgrounded now stays unread and badges, and clears when the user returns. Adds foreground-gating regression tests for the sidebar count and useChatController.
Fixes#5300. The daemon hardcoded optionId="approve_for_session" when auto-approving Hermes' session/request_permission, but Hermes' ACP edit-approval offers only ["allow_once","deny"] and rejects anything else, silently blocking every file write. handleAgentRequest now selects an option the agent actually offered — a safe session/single-use grant, else an offered reject_once to deny just that action, else a JSON-RPC error — never a permanent allow_always or a whole-turn cancelled. Includes regression + branch-coverage tests.
* fix(labels): sweep resource-label junctions on bulk deletes (MUL-4479)
Runtime, runtime-profile, and workspace deletion hard-delete their agents
and skills without clearing agent_to_label / skill_to_label. Migration 173
dropped the junction foreign keys, so these rows are no longer cascade-cleaned;
once resource labels are enabled, every labelled agent/skill removed through one
of these bulk paths leaves a permanent, invisible orphan junction row.
Clear the junctions in the same transaction as the owner delete, before the
owning rows disappear:
- DeleteAgentRuntime / ArchiveAgentsAndDeleteRuntime / DeleteRuntimeProfile:
DeleteAgentLabelAssignmentsByRuntime, ahead of the archived-agent hard-delete.
- DeleteWorkspace: DeleteAgentLabelAssignmentsByWorkspace +
DeleteSkillLabelAssignmentsByWorkspace, ahead of the workspace cascade.
Adds regression tests for all four delete paths.
Follow-up to #5345 (Elon review). Resource labels must stay disabled until this
lands.
Co-authored-by: multica-agent <github@multica.ai>
* fix(labels): make workspace cleanup atomic
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
The 162_resource_labels down migration recreated issue_label_workspace_name_lower_idx
with a plain CREATE UNIQUE INDEX, which takes a blocking lock on the existing
issue_label table during a rollback to v0.3.43 and violates the online-migration
rule that every CREATE INDEX must be CONCURRENTLY.
CREATE INDEX CONCURRENTLY cannot run in a transaction or a multi-command migration,
so the rebuild is split out of 162.down:
- 171.down now rebuilds the legacy index as a single CREATE UNIQUE INDEX CONCURRENTLY
(the natural inverse of 171.up, which drops it).
- 174 (new, no-op up) deletes the agent/skill label rows in its down so they are gone
before 171.down rebuilds the workspace-wide unique index. Down migrations apply
high->low, so 174.down -> 171.down -> 162.down runs in the required order.
- 162.down keeps only the transaction-safe structural teardown.
Validated on an isolated Postgres: full up->down->up chain with a colliding
issue/agent label pair; the concurrent rebuild succeeds after the rows are deleted,
restores the valid pre-162 unique index, and a negative control (rebuild before the
delete) fails on the exact collision.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Drop the "使用模板" card and its onTemplate wiring from ModeChooser so the
agent creation studio only offers blank and AI modes. Rebalance the mode
grid to two columns. TemplateChooser and the template creation path are
left in place but no longer reachable from the UI (known follow-up debt).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(projects): add start_date / due_date pickers to project create modal and sidebar
#5313 landed the backend start_date/due_date fields + types but deliberately
shipped no UI. Wire up the two editor surfaces users expect:
- ProjectStartDatePicker / ProjectDueDatePicker mirror the issue pickers (same
calendar-day contract, clear idiom, shared @multica/core/issues/date helpers)
but are typed to UpdateProjectRequest and scoped to the "projects" i18n
namespace. One component serves both surfaces via a custom trigger.
- Create-project modal: two date pills; values flow into the create payload and
the persisted draft (draft-store gains startDate/dueDate).
- Project sidebar (project-detail): two PropRows after Lead, wired to the update
mutation, with clear support (send null).
- i18n: prop_start_date / prop_due_date / clear_date across en/zh-Hans/ja/ko,
reusing the existing issue date wording.
Tests: picker display + clear behavior (real popover), and the create modal
renders both pills. typecheck + lint + i18n parity pass.
Part of #5227
Co-authored-by: multica-agent <github@multica.ai>
* refactor(projects): align create-project footer with create-issue
Restructure the create-project modal footer to match the create-issue pattern
(per design feedback): the primary action moves out of the cramped single
justify-between row into its own border-t action strip, and the property pills
sit in a dedicated wrapping toolbar above it. Low-frequency fields (start/due
date) collapse into a ⋯ overflow via progressive disclosure — a pill only
renders inline once its date is set or the user opens it from the menu — so the
default toolbar stays a clean single row (Status · Priority · Lead · Repos · ⋯).
- Use the shared PillButton (../common/pill-button) instead of the modal-local
copy, gaining the data-popup-open styling create-issue uses.
- ProjectStartDatePicker / ProjectDueDatePicker gain controlled open props so
the overflow menu can reveal + open them (mirrors the issue pickers).
- i18n: create_project.set_start_date / set_due_date / more_options_aria across
en / zh-Hans / ja / ko, reusing the create-issue wording.
Test updated to assert the dates are revealed from the overflow rather than
shown inline by default. typecheck / lint / i18n parity pass.
Part of #5227
Co-authored-by: multica-agent <github@multica.ai>
* refactor(views): extract shared DateOnlyPicker base for date pills (Elon nit2)
The issue and project start/due-date pickers were near-complete copies of the
same Popover + Calendar + clear wiring, so they could drift in behaviour or
formatting. Extract that into one entity-agnostic DateOnlyPicker
(packages/views/common/date-only-picker.tsx); each of the four pills is now a
thin wrapper supplying only its field name (via onChange), icon, overdue flag,
and localized copy. -264 lines of duplication, single source of truth.
Behaviour is unchanged: the issue pickers keep their full API (trigger /
triggerRender / open / onOpenChange / align / defaultOpen — all still used by
board-card, issue-detail, create-issue) and the calendar-day contract stays in
@multica/core/issues/date. The en-US display format now lives in one place
rather than being duplicated per entity.
Full views test suite (1857 tests) + typecheck + lint pass.
Part of #5227
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): bound app-server startup RPCs
Co-authored-by: multica-agent <github@multica.ai>
* test(codex): de-flake bounded-handshake test
The single 500ms handshake bound was shared by the successful preamble
RPCs, so a slow fork/exec of the /bin/sh fake app-server could make
initialize spuriously time out under parallel load. Raise the test bound
to 3s (still below the 5s semantic timeout and 10s harness ceiling) and
loosen the elapsed assertion to match.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
Moving an issue to `cancelled` used to auto-cancel every in-flight agent task
on that issue. Users have no expectation that clicking "cancel" stops running
agent runs, so this implicit coupling is removed from UpdateIssue and
BatchUpdateIssues. Deleting an issue still cancels its tasks (the owning row
disappears); a plain status change never does. Reassignment already didn't
cancel tasks (#4963 / MUL-4113), so this makes status-cancel consistent.
- Status-table-driven regression tests cover every active state the cancel
query sweeps (queued / dispatched / running / waiting_local_directory /
deferred) on both the single and batch paths.
- Updated the multica-working-on-issues skill (SKILL.md + source map) and
corrected stale comments in task.go and agent.sql that described the removed
coupling as current.
MUL-4465
* feat(shortcuts): make browser-reserved accelerators recordable on desktop
Cmd/Ctrl+P (and L/T/N/D/U) were rejected by the shortcut recorder on every
platform because a browser tab cannot reliably own them. The Electron
renderer receives these as plain keydowns — neither Electron's default menu
nor the desktop shell binds any of them — so the reservation now only
applies to the web runtime.
Adds a ShortcutRuntime dimension (configured by CoreProvider from the
client identity, with a preload-global fallback that is already correct at
module-eval time so store hydration sanitizes with the right runtime).
App-owned accelerators (W/R/Q, editing keys, zoom row) stay reserved
everywhere.
Closes MUL-4457
Co-authored-by: multica-agent <github@multica.ai>
* fix(shortcuts): limit desktop unlock to bare primary browser accelerators
Review follow-up (MUL-4457): the desktop skip matched primary+key with any
extra modifiers, which would also unreserve OS-owned combos such as
Option+Cmd+D (macOS Dock toggle) and Ctrl+Alt+T (Linux terminal). Only the
bare primary chord is now recordable on desktop; every extra-modifier
variant keeps the historical reservation on both runtimes. Adds regression
tests for the OS combos.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): search runtime local skills
* feat(skills): highlight matched substrings in runtime local skill search
Reuse the shared HighlightText (the same component the global search
command uses) to highlight matched substrings in a result's name,
provider, description, and path, so styling stays consistent across the
app. Narrow the search to the fields the row actually renders and drop
`key`, so every match maps to something visible. While a query is active,
lift the description's 2-line clamp so a match past the first two lines
stays on screen instead of being clipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): zero unread for archived chat sessions across all badges (MUL-4360)
Archiving a chat session flips status but deliberately does not advance
last_read_at, and ListAllChatSessionsByCreator counted unread
unconditionally. So an archived session that had unread replies kept
reporting has_unread=true / unread_count>0 — a stuck badge the user can
never clear (archived sessions are read-only and hidden from history, so
there is no mark-as-read entry). MUL-4372 fixed only the quick-chat FAB
surface; the sidebar Chat tab badge and the chat-window "other unread"
header still counted it.
Fix at the source: derive unread_count = 0 for status='archived' rows in
ListAllChatSessionsByCreator. Because has_unread is server-derived as
unread_count > 0, and all surfaces (FAB, sidebar via
countUnreadChatMessages, chat-window header, and mobile) read this one
payload, every badge drops archived sessions with no per-surface filter.
last_read_at is left untouched so unarchiving restores the true unread
state. Installed desktop clients benefit without an app update.
Also zero unread optimistically in the archive mutation so no badge
counts a just-archived session in the frame before the refetch lands
(FAB already filtered archived; this keeps sidebar/header consistent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): stop auto-mark-read from clearing a transiently-active session on mount (MUL-4360)
The chat page persists `activeSessionId`, so on a bare `/chat` navigation it
restores the last-open session as active for one frame before its URL→store
effect (which runs AFTER useChatController's effects, since the hook is called
first) clears it back to null. The auto-mark-read effect fired in that gap and
marked the restored-but-never-opened session read — its unread badge vanished
though the right pane still showed "select a chat" and the user never opened it.
This is why the sidebar count dropped (e.g. 2 → 1) just by entering the tab.
Defer the read by a tick and cancel it on cleanup: a session that is only
momentarily active (restored on mount, then cleared) has its pending read
cancelled when activeSessionId changes; only a session that stays active past
the tick — a real select, deep link, or refresh — is marked read. A live-store
re-check in the timer is a belt-and-suspenders guard.
Adds the previously-missing auto-mark-read coverage: a stable-active session is
read after the tick; a momentarily-active one is not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: coderbaozi <YHbaozi1988@163.com>
Co-authored-by: abun <103836393+coderbaozi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The settings nav and agent overview aside tinted themselves with
bg-app-shell/70, pulling the window-chrome tone inside the content
card. Since the desktop's active tab merges into the card top
(MUL-4439), a tinted panel under the first tabs visibly broke the
tab/content seam. Zone separation stays with the hairline divider,
row hover/selected states, and the narrow column — matching the
inbox panel's existing pattern.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
#5307 renamed 161_attachment_task_id -> 164 (and 162_..._index -> 165) to
resolve a prefix collision. schema_migrations keys on the full stem, so any DB
that applied the migration under its old 161 number does not have "164" in the
ledger and the runner re-applies the renamed file. The bare `ADD COLUMN task_id`
then aborts with 42701 ("column already exists"), blocking every later
migration — this is exactly what crashed the dev deploy of main (bf288349f) at
container startup, before it ever reached 166_project_dates.
Add `IF NOT EXISTS` so the re-run is a harmless no-op on already-migrated DBs
(dev/staging/prod) with no manual schema_migrations surgery, while staying
identical on a fresh DB. Sibling 165 already uses CREATE INDEX ... IF NOT EXISTS
for the same reason; this brings 164 in line. The down file already uses
DROP COLUMN IF EXISTS.
Verified on a throwaway DB reproducing the dev drift (forget 164/165 in the
ledger, keep the column+index): old file reproduces the 42701 crash, the fixed
file self-heals and completes. Migration lint + concurrent migrate tests pass.
Refs #5307
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The active tab now shares the content card's fill and keyline: rounded
top corners, concave bottom flares (radial-gradient corner pieces whose
1px arc hands the tab border over to the card's top ring), and a
borderless base that runs into the card so the two read as one surface.
Inactive tabs sit flat on the shell with an inset hover pill and
hairline separators that hide around the active tab, Chrome-style.
Closes MUL-4439
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Projects become schedulable planning objects alongside their issues: add
optional start_date / due_date, mirroring issue.start_date / issue.due_date.
This is only the first slice of #5227 — labels, metadata, and the editable
metadata UI are still out of scope.
- migration 166: two nullable DATE columns on `project` (calendar days, no
FK/index — matches the issue end-state after migration 112)
- sqlc CreateProject / UpdateProject carry the dates; UpdateProject uses
narg so an explicit null clears
- handler: parse YYYY-MM-DD (400 on bad format), rawFields-presence clear on
update, and the hand-scanned SearchProjects query returns the columns
- CLI: `project create/update --start-date/--due-date` (empty clears on update)
- frontend + mobile types/zod schemas: the two new schema fields are
nullable().default(null) so a project from an older backend (frontend
deploys before backend) parses to null instead of degrading the batch to
the empty fallback; added a search schema drift test
- projects skill / CLI docs
Part of #5227
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): search runtime local skills
* feat(skills): highlight matched substrings in runtime local skill search
Reuse the shared HighlightText (the same component the global search
command uses) to highlight matched substrings in a result's name,
provider, description, and path, so styling stays consistent across the
app. Narrow the search to the fields the row actually renders and drop
`key`, so every match maps to something visible. While a query is active,
lift the description's 2-line clamp so a match past the first two lines
stays on screen instead of being clipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>