* fix(daemon): stop routing CodeBuddy skills/memory through Claude's .claude paths
CodeBuddy Code is a Claude Code fork but ships its own native config
directory (~/.codebuddy, .codebuddy/) with its own memory filename
(CODEBUDDY.md). It only reads .claude/skills or CLAUDE.md if a user
manually symlinks/copies them during migration
(https://www.codebuddy.ai/docs/cli/troubleshooting#migrating-from-claude-code).
Multica's daemon/execenv code treated "codebuddy" as an alias for
"claude" in three places, so skills synced by Multica landed in
.claude/skills/ and CLAUDE.md — paths the default CodeBuddy install
never reads — instead of ~/.codebuddy/skills, .codebuddy/skills, and
CODEBUDDY.md as documented at
https://www.codebuddy.ai/docs/cli/codebuddy-dir and
https://www.codebuddy.ai/docs/cli/skills.
Split the "claude", "codebuddy" switch cases in:
- daemon/local_skills.go (user-level local skill discovery/import)
- daemon/execenv/context.go (per-task skill materialization)
- daemon/execenv/runtime_config.go (runtime brief target file)
Added regression tests locking in the new paths and updated the
install-agent-runtime / providers docs (all 4 locales) that had
documented the old .claude/skills behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(daemon): cover CodeBuddy sidecar hygiene and lifecycle
Address PR #5224 review feedback: exclude CODEBUDDY.md/.codebuddy from
repo-cache worktrees, extend sidecar lifecycle matrices to codebuddy, and
make the local-skills CodeBuddy test exercise a true same-key collision.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Eve <eve@multica-ai.local>
* Reapply "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)
This reverts commit c10bfa8f56.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): seed virtualized lists so route-return doesn't flash blank (MUL-4750)
The relanded MUL-4474 virtualization flashed an empty card area (group /
column headers present, rows blank) when returning to /issues or crossing
inbox<->issues. Two stacked blank windows caused it:
1. The scroll element reaches Virtuoso via a callback ref that lands in
state, so the first render after a remount has customScrollParent === null
and the code rendered nothing.
2. Even once mounted, Virtuoso renders 0 rows until its post-paint
ResizeObserver measures the viewport.
Fix both, four surfaces (list / board / swimlane / inbox):
- New shared <VirtuosoSeed> renders a bounded slice of the real rows while the
scroll parent is still null, reusing each caller's own itemContent /
computeItemKey so a seeded row is identical to its virtualized counterpart.
- Pass initialItemCount={Math.min(len, SEED)} so the measurement frame keeps
those rows instead of collapsing to empty.
SEED is capped at 30 and floored by Math.min, so small workspaces
(hasMore=false) and short columns never over-mount — the path that crashed on
real Desktop before. restoreStateFrom (tab-switch Activity restore) is
intentionally out of scope for this round.
Verified: @multica/views tsc --noEmit, eslint, and full vitest (1936 tests)
pass. Real-Desktop route-return / DnD / keyboard / scroll-position regression
pass still owed on device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(issues): defer per-card popup mounting, share one context menu per surface
Tab-switching to a board froze the main thread for seconds: every card
eagerly mounted ~6 popup roots (context menu, pickers, hover cards) plus
per-card query subscriptions, multiplied by seed x columns x remount.
- DeferredPopup: pickers render a pixel-identical static trigger and mount
the real popover on first pointerenter/keydown (Base UI opens on click,
so the warm mount always wins the race)
- AssigneePicker/PriorityPicker/DateOnlyPicker defer when uncontrolled;
AssigneePicker's members/agents/squads/frequency subscriptions now only
start on interaction
- IssueActionsContextMenu: one controlled ContextMenu per surface anchored
at the cursor via a virtual anchor; items delegate (issue, position) up.
Known debt: iOS Safari long-press no longer opens it
- ActorAvatar hover cards warm-mount on pointerenter with a manual
first-dwell timer matching Base UI's OPEN_DELAY
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): stabilize board column scrollbar across mounts
Column scrollbars redrew visibly on every surface mount (route switches,
first open): the seed frame's scroll height covered only the seeded cards,
then Virtuoso spaced out the full count.
- VirtuosoSeed: optional estimatedItemHeight renders a trailing spacer so
the seed frame's scroll height already approximates the full list
- Board columns: seed capped at 10 (one column viewport of ~110px cards,
not the 36px-row-sized generic 30) and the same estimate feeds Virtuoso's
defaultItemHeight so both phases agree until real measurements land
- Columns at <=30 cards skip virtualization entirely and render plainly
(same itemContent), making their scroll height browser-measured truth in
every scenario -- the per-column split Linear ships
(data-virtual-cluster=false for small clusters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): reduce issue detail mount cost
Parse long Markdown in smaller chunks without the duplicate initial sync, defer title and empty composer editors until intent, and keep the description editor eager to avoid layout shifts.
* chore(desktop): add navigation boundary lint rule (MUL-4741 Phase 2 prereq)
The tab Coordinator protocol requires that application code never
navigates directly (invariant 1: a Router location change without a
Coordinator token is a protocol error). Enforce it statically:
- renderer app code may not import useNavigate/Navigate from
react-router-dom nor call router.navigate; src/platform is exempt
- the five known legacy sites (the RFC §8.1 migration checklist,
cross-validated: the rule fires on exactly those and nothing else)
carry inline eslint-disable directives tagged MUL-4741 — the Phase 2
migration removes them one by one, and this rule holding with zero
disables is the machine check that the migration is complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(views): open deferred pickers on click, align triggerRender types
Pointerenter warm-mounting swapped the trigger element mid-gesture: real
browsers re-hit-test so the click lands on the new trigger, but synthetic
pointer sequences (tests, assistive tech) keep dispatching on the detached
node and the first click dies. Upgrade now happens on click/Enter/Space
only — the same timing as Base UI's own trigger — with the in-flight click
stopped so the popup's just-mounted outside-press dismissal doesn't close
it in the same breath.
Also widen triggerRender to ReactElement<Record<string, unknown>> (React
19 defaults ReactElement props to unknown) and mount the
IssueContextMenuProvider in the swimlane test harness like IssueSurface
does in production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(desktop): single-router tab sessions with Coordinator protocol (MUL-4741 Phase 2)
Replace the per-tab-router + <Activity> keep-alive model with the approved
single-router session architecture:
- TabSession: tabs are pure serializable state (url, resourceKey, virtual
history stack, scroll memento). Persist v4 does the one-time legacy
view-state import from v3; mountGeneration is deliberately unpersisted.
- Coordinator (platform/tab-coordinator.ts) is the only router writer: it
reconciles THE app router to the active session URL with navigation
tokens; a location change without a token is a protocol error handled by
bounded recovery (invariant 1). The router history is never used — every
reconcile is a replace; back/forward are session-stack operations.
- ActiveTabHost mounts exactly one tab, keyed on tabId:mountGeneration.
reload() = generation bump + active-scope query invalidation (never
router.revalidate, never a global cache invalidation). Warm switches
restore scroll pre-paint; cold restores pre-size containers from the
memento's saved scrollHeight and settle when data lands.
- resourceKey dedup (pathname only) replaces exact-path dedup: opening
/slug/issues?filter=b focuses the existing issues tab (RFC §8.2,
deliberate semantic change).
- All five §8.1 legacy navigation sites migrated (index <Navigate>, error
page recovery, workspace-layout login bounce, overlay parking, shell
back/forward); their MUL-4741 ratchet eslint-disables are removed, so the
navigation boundary rule now holds with zero exemptions outside
src/platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(issues): register board columns and list scroller for scroll mementos
Per-container scroll registration for the MUL-4741 tab session memento:
board columns key by group id (each column's offset restores
independently, the per-column split Linear ships), the list view keys as
"list". Chat and issue-detail already carry the marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tabs): pull-based scroll restoration fed into virtualized lists (MUL-4741)
Rebuild the restore side of the memento protocol on first principles (the
model Linear ships): a saved offset is an INPUT to the mounting view, not a
post-hoc DOM mutation from outside.
- ScrollRestorationProvider (views/platform): views pull their saved offset
while mounting. Virtualized lists feed it into Virtuoso's initialScrollTop
so the first render already materializes the rows around it — this
replaces the pushed spacer+scrollTop hook, whose foreign spacer deadlocked
against Virtuoso's own height model (restore landed the viewport in
phantom space, Virtuoso rendered nothing, and the spacer's removal
condition could never be met → blank issue detail). Plain containers
assign the offset at ref-attach, pre-paint. Web has no provider and
behaves as before.
- Memento keys gain a route dimension (`${pathname}::${containerKey}`) and
capture now also fires before in-tab navigation, so back/forward restores
each route's own offsets and same-named containers on different routes
no longer collide.
- commitScrollMemento uses REPLACE-per-route semantics: a container
scrolled back to 0 clears its stale offset instead of resurrecting the
old position on the next visit.
- List view gets the same estimate alignment as the board (36px rows into
the seed spacer and defaultItemHeight), which keeps the shared scroller's
height truthful from the first frame so the restored offset sticks.
Known gap: chat's bottom-anchored list captures offsets but has no restore
consumer — intentional, it re-anchors to bottom on mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): make unlabelled code fences plaintext instead of auto-detected
Follow-up to the issue-detail mount work: lowlight's highlightAuto runs
every registered grammar over the full block for code fences without a
language, which dominated mount cost on code-heavy comments. Extract a
shared syntax-highlight module whose auto fallback deterministically
renders plaintext; explicitly labelled languages highlight as before.
Also ignore .gstack/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): lazy-mount column-header popups, right-size swimlane seed
Trace analysis of tab/view switching (30s session): a swimlane mount spent
its largest slice on eagerly-mounted header machinery — ~170 tooltip roots
(one per lane x status cell add-button), 13 column dropdown-menus, and up
to 30 fully-materialized lanes from the generic seed count.
- DeferredTooltip (views/common): renders only the trigger until first
hover, then mounts a controlled Tooltip anchored to the SAME element
(no trigger swap, so mid-gesture events never land on a detached node);
ui TooltipContent grows an `anchor` passthrough for it.
- Board/list/swimlane header add-buttons and hide-column dropdowns now
defer via DeferredTooltip / DeferredPopup (which gains an ariaHasPopup
option for menu triggers).
- Swimlane lane seed drops 30 -> 6 (a lane row is ~300px+; a viewport fits
~3) on both the pre-scroll seed and Virtuoso's initialItemCount.
- openTab gains an `activate` option so "open and focus" paths (pinned-tab
redirect, explicit open-in-new-tab) are ONE store write instead of
openTab + setActiveTab back-to-back — one full subscriber pass per user
action instead of two.
- Dev-only breadcrumb logs IssueSurfaceContent's remount key: the trace
showed the surface mounting twice inside one task, and the my-issues
relation toggle is one confirmed key-flip source; the log ties the next
trace's mounts to exact key transitions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): stop double full-tree render passes on surface interactions
Trace forensics on view switching showed every interaction paying TWO full
surface render passes (React's own Cascading Update marker sits between
them): entering swimlane flips controller-level loading state (loadProjects
enables the projects query), and any such flag flip re-rendered the entire
unmemoized view tree (~600-1000ms dev per pass).
- Memoize BoardView / ListView / SwimLaneView: controller/data outputs are
already useMemo/useCallback-stable, so a controller flag flip now
re-renders the header, not the whole board. The one unstable prop —
BoardView's inline assigneeGroups.flatMap — moves into a useMemo.
- Selection reset on mount swapped the initial empty Set for a NEW empty
Set, buying a guaranteed extra full pass per surface mount; functional
bail keeps the reference when nothing was selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(ui): drive sidebar resize by direct DOM writes, drop motion/react
Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): wire swimlane's outer scroller into tab scroll restoration
Review blocker on #5403: board/list/issue-detail register their scroll
containers with the tab session memento protocol, but the swimlane outer
scroller did not — under the single-router architecture an inactive tab
unmounts, so a deep-scrolled swimlane returned at top after a tab switch
or reload.
Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for
capture, useRestoredScrollRef in the scroller's attach callback for the
pre-paint assignment, and the saved offset into the lane Virtuoso's
initialScrollTop. Regression test asserts both the capture marker and the
restored offset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The four landing "Dashboard" CTAs used href="/" and relied on the proxy
bouncing logged-in visitors from the root path to their workspace. Once #5363
kept "/" public on the official marketing host so logged-in users could browse
the site (#5326), that bounce stopped — and the CTAs began resolving to the
page the visitor was already on, so clicking them did nothing at all.
Resolve the destination in the CTA instead of leaning on a redirect side
effect elsewhere: useDashboardCtaHref() runs the same
resolvePostAuthDestination() that RedirectIfAuthenticated already uses, so
"where the dashboard lives" has one source of truth. This also collapses the
same `user ? "/" : "/login"` line that had been copy-pasted across four files.
proxy.ts and redirect-if-authenticated.tsx are untouched — #5363's behavior is
correct and proxy.test.ts still passes unchanged.
Closes MUL-4794
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Adds `multica workspace create` (--name/--slug/--description/--context/--issue-prefix, JSON/table output). Creation does not switch the current workspace. Both --name and --slug are required to match the server contract, and the slug is immutable after creation. Docs (en/zh/ja/ko) and the CLI reference now show the executable command.
Closes#5055
Add xAI Grok Build (`grok`) as a first-class Multica runtime over ACP
(`grok --no-auto-update agent --always-approve stdio`), reusing
hermesClient like traecli and kimi. Includes daemon discovery,
protocol_family migration 174, model discovery, MCP passthrough,
thinking effort, frontend branding, and product docs.
Follows xAI's documented headless ACP flow: after `initialize`, read the
advertised `authMethods` and send `authenticate` (preferring xai.api_key
when XAI_API_KEY is set, else the cached login token) before any session
operation — a real, logged-in CLI rejects session/new and session/load
without it. Model discovery performs the same handshake so it returns the
live catalog instead of the static fallback.
`--no-auto-update` is passed as a global flag (and kept daemon-owned in
the blocked custom-arg set) so a background update check can't stall an
unattended ACP task. Thinking effort uses the current `--effort` flag,
and the minimum grok version is 0.2.89 (ACP + authenticate + session/load
+ session/set_model + MCP + --effort).
Closes#2895
* feat(server): custom issue properties — definitions, typed values, CLI (MUL-4463)
Workspace-level property definitions (issue_property table; 7 types:
text/number/select/multi_select/date/checkbox/url) plus a typed value bag
on each issue (issue.properties JSONB keyed by definition UUID, mirroring
the metadata machinery: single-key atomic writes, 16KB cap, GIN index).
- Definitions: owner/admin only; agent actors rejected (agents propose,
humans confirm). 20 active per workspace, 50 options per select,
reserved built-in names blocked, archive instead of delete.
- Values: any member or agent; per-type validation with self-correcting
error messages that enumerate legal option ids.
- API: /api/properties CRUD + PUT/DELETE /api/issues/{id}/properties/{propertyId};
issue responses always emit the properties bag.
- CLI: multica property list/get/create/update/archive/unarchive and
multica issue property list/set/unset with name→id translation.
- Events: property:created/updated, issue_properties:changed.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)
- Settings → Properties: definition management mirroring the Labels tab
(list with type badges/option chips/usage counts, create/edit dialog
with option editor, archive/restore, 20-cap indicator). Admin-gated;
members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
props' progressive disclosure — set values render as rows with
type-appropriate editors (select/multi-select pickers, calendar,
yes/no, inline input for text/number/url), unset ones live in the
same '+ Add property' menu behind a separator. Archived definitions
render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
compat), api client methods, React Query hooks with optimistic
single-key value writes, ws-updaters + realtime wiring for
property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 1 — mobile CI, option guard, mutation safety, schema tolerance
- mobile: EMPTY_ISSUE_FALLBACK gains the required properties field (mobile
typecheck was the red CI check).
- server: PATCH /api/properties/{id} rejects config updates that remove
select options still referenced by issues (409 with a per-option usage
census via jsonb ?); renames keep ids and pass. Integration test included.
- core: property value mutations are serialized per workspace via mutation
scope, snapshot the bag from detail OR list caches (board surfaces have no
detail cache — the old path overwrote whole bags with one key), roll back
to the snapshot or invalidate on error, and the last settled mutation does
an authoritative detail+catalog invalidate (usage counts reconcile).
- schemas: unknown-shaped property values (future server types) are dropped
per-entry in a preprocess step instead of failing the whole IssueSchema
and blanking lists through parseWithFallback; test updated to lock the
tolerant behavior.
- realtime: reconnect invalidation covers the property catalog; every
issue_properties:changed event also refreshes catalog usage counts.
- ui: number editor accepts decimals (step=any); settings usage count
pluralizes (issue/issues) with CJK-safe plural keys.
Co-authored-by: multica-agent <github@multica.ai>
* fix(migrations): renumber issue properties to 179 and build the GIN index concurrently
main's migration sequence advanced twice under this PR (167 collision, then
an upstream renumber wave that claimed 178), so issue properties now sits at
179 — verified against main's current tip by the prefix-uniqueness lint.
The properties GIN index moves to its own single-statement migration (180)
using CREATE INDEX CONCURRENTLY — a plain CREATE INDEX on the hot issue
table would block writes for the duration of the build. Mirrors the
119_user_created_at_index pattern; full-chain dry-run on a fresh database
passes through 180.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)
Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:
- Filter: per-definition sections in the Filter dropdown (select /
multi_select options with color dots and counts; checkbox as Yes/No
pseudo-options). OR within a definition, AND across definitions;
client-side in applyIssueFilters, mirrored into filterAssigneeGroups
for the assignee-grouped board. Included in active-filter count and
Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
Server keeps position order (fixed sort enum); the surface controller
re-sorts client-side, swimlane/gantt reuse the same comparator.
Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
definitions — one column per option (definition order) plus a
trailing No-value column, option-colored headings. Drag-drop moves
position via UpdateIssue and applies the value through
useSetIssueProperty/useUnsetIssueProperty (properties are not part
of UpdateIssueRequest). Stale persisted property groupings fall back
to status columns.
View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 2 — desc sort, option bucketing, archived-state reconciliation
- sort: direction now applies to value comparison only; issues without a
value sort last in BOTH directions (the whole-array reverse flipped them
to the front on desc). Test covers the desc+missing case.
- board: values referencing an option removed from the definition bucket
into the No-value column instead of vanishing (unmatched column ids
dropped the issue entirely). Defense-in-depth behind the new server-side
in-use guard; drag-utils test locks both behaviors.
- controller: persisted propertyFilters keyed by archived/deleted
definitions are stripped before reaching the filter predicates, and a
persisted property sort on a non-active definition degrades to manual
order — previously both kept silently applying while the header claimed
otherwise. The filter badge counts only active-definition filters.
Co-authored-by: multica-agent <github@multica.ai>
* feat(properties): server-side property filtering and sorting on list endpoints
Property filter/sort now execute in the database, so results are correct
across the full issue set — not just the loaded 50-per-status window
(closes MUL-4493 item 1's filter/sort half; requested on MUL-4463).
- New `properties` query param on ListIssues and ListGroupedIssues:
JSON {definitionId: [values]} compiled to an AND-of-ORs containment
check (double NOT EXISTS over jsonb_array_elements). One value expands
to every storage shape it could match — string (select), array element
(multi_select), boolean (checkbox) — so the handler stays type-agnostic.
Guarded at 20 definitions / 50 values.
- `sort=property:<definitionId>` resolves the definition and orders by a
typed expression (numeric CASE cast for number, NULLIF text for
date/text/url); missing values sort last in both directions. Malformed
ids 400; unknown/archived definitions degrade to position order instead
of breaking stale clients.
- Frontend: the property filter and property sort ride the IssueSortParam
window bag, so every surface (workspace + my-issues variants), query
key, and per-status load-more page carries them automatically. The
client-side re-sort layer is gone; applyIssueFilters keeps its property
predicate as an optimistic-update backstop.
- Regression test seeds 55 issues and proves a match at position 55 is
returned by a filtered 50-row page, plus sort order/missing-last,
AND-across-definitions, and the 400/fallback sort paths.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address review round 3 — cache reconcile, merged-scope order, GIN-indexable filter, pool loader
- Cache reconciliation: property value writes (mutation settle + WS event)
now invalidate every issue window whose server-side shape depends on
property values — queries filtered by `properties` or sorted by
`property:<id>` (detected via query-key predicate), covering flat lists,
assignee groups, and my-issues variants. Windows without property params
keep the cheap in-place patch. Fixes stale ordering/membership/counts
under staleTime:Infinity.
- My Issues "All" scope: merged assigned/created/involves results are
re-sorted with a comparator mirroring the server ORDER BY semantics
(including property sorts and missing-last, created_at DESC tiebreak) in
both the flat and assignee-grouped merge paths — relation concatenation
no longer overrides the user's sort.
- Filter predicate rebuilt as plain bind-parameter containment ORs
(AND across definitions): EXPLAIN now shows BitmapOr over
idx_issue_properties_gin (the correlated jsonb_array_elements form
defeated the index). Alternatives capped at 256 bind params.
- Property-grouped board gains a pool loader strip: one sentinel per
status that still has server rows, keeping every issue reachable until
per-column pagination lands (MUL-4493).
- Windowing regression test hardened: explicit positions + an assertion
that the unfiltered first page excludes the target (the old fixture tied
at position 0 and the created_at DESC tiebreak put the target on page
one, proving nothing).
- Rollback safety: /api/properties 404 (old server) degrades to an empty
catalog instead of a query error, which also keeps property params from
ever being sent to pre-property servers; migration 179's CHECK
constraints switch to NOT VALID + VALIDATE so the exclusive lock is
instantaneous.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): harden concurrency and cache coordination from clean-room review
Backend (MUL-4762 F1/F4/F5):
- withPropertyLock: pg_advisory_xact_lock helper; definition create/update
and value writes now serialize config-vs-value and cap-vs-insert races
(workspace-level 'props:' lock + per-definition 'prop:' lock, ordered).
- propertySortExpr degrades archived definitions to position sort.
Frontend (F2/F3/F6):
- onIssuePropertiesChanged invalidates plain assignee-group caches too.
- Property value mutations cancel list refetches in onMutate and roll back
only the touched key against the current bag (concurrent WS writes to
other keys survive a failed write).
- useUpdateIssue reconcile drops the stale properties bag from the server
snapshot; the property pipeline owns that field.
- Surface controller passes persisted property filters/sorts through
until the catalog query settles (cold cache no longer strips them).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): open_only branch honors the properties filter
ListOpenIssues takes the parsed AND-of-ORs containment groups as a single
jsonb properties_filter param and unrolls them with a static double
NOT EXISTS; previously the open_only path parsed the properties param and
silently dropped it (clean-room review F7a).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): toast on failed board drag to a property column
Property-column drags rolled the card back silently on failure; mirror
the status/assignee drag path (use-issue-surface-actions) so the
snap-back is explained (clean-room review F3, drag half).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The shell canvas kept its static 2px left margin after the left sidebar
collapsed offcanvas, while the right edge uses an 8px margin. Animate the
left margin to 8px (same spring as the rest of the shell) whenever the
sidebar leaves the main flow, so the floating canvas sits symmetrically.
Fixes MUL-4780
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* 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>
* 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.
* 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>
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(chat): support images/files in agent chat replies (MUL-4287)
Agents can now attach images/files to their chat replies, matching how
comment attachments already work. The write-side gap was that the assistant
chat_message is synthesized server-side from the completion callback's text
output and never bound any attachments.
Backend:
- migration 150: nullable attachment.task_id (+ partial index), the transient
handle that ties an agent's in-run upload to the reply it produces.
- POST /api/upload-file accepts task_id: gated to the task's own agent, in
this workspace, on a chat task; tags the row with task_id + chat_session_id.
- CompleteTask (chat branch) binds the task's still-unclaimed attachments to
the assistant message via BindChatAttachmentsToMessage (rejects rows already
owned by an issue/comment/chat_message). An empty-output reply that produced
files still creates a message so the images have an owner. FailTask binds
nothing.
CLI:
- `multica attachment upload <path>` uploads a file for the current chat task
(task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a
ready-to-paste markdown snippet.
Prompt:
- web/mobile chat prompt tells the agent how to attach a file to its reply.
Mobile:
- chat:done handler now always invalidates the messages list so attachments
(absent from the event payload) refetch; mirrors web's self-heal.
- chat bubbles render standalone attachment cards via the existing
CommentAttachmentList (dedup vs inline references), matching web.
Web/desktop needed no change — they already render message.attachments inline
and via AttachmentList, and self-heal on chat:done.
Tests: upload permission/isolation, bind-on-complete, empty-output+attachments,
FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output
contract, mobile refetch-on-done.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address review blockers on chat reply attachments (MUL-4287)
Two final-review blockers on PR #5164:
1. Mobile inline dedup only checked raw `url`, so an attachment referenced
inline via `markdown_url` (exactly what the CLI snippet emits) rendered
twice — once inline, once as a standalone card. Reuse the core
`contentReferencesAttachment` helper so dedup covers every real reference
form (stable /api/attachments/<id>/download path, url, download_url,
markdown_url), matching web's AttachmentList. Extracted the filter into a
pure `lib/attachment-dedup.ts` so it is unit-testable, and added a
regression test covering `content` containing `attachment.markdown_url`
(plus the other URL forms and same-identity sibling dedup).
2. CLI `attachment upload` emitted `![...]` image markdown for every file,
producing a broken-image snippet for non-images. Emit image markdown only
for image/* content types and a plain link otherwise, with a CLI contract
test for both.
Approved scope otherwise unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287)
Merged latest main; main renumbered its migrations and now occupies 150-156,
so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and
would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the
next unique prefix (157). No content change; migrate up applies cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): render agent-produced files as attachment cards, not raw links
The chat upload command handed the agent a bare `[name](url)` markdown
snippet. Pasted mid-sentence it renders as a plain text link (not a card),
and the referenced URL hides the auto-bound standalone attachment — so a
file the agent produced could end up showing as nothing.
Return the block-level `!file[name](url)` card syntax instead (images keep
`` inline), and markdown-escape the filename so names with `[`/`]`
don't truncate the label. The prompt and CLI help now state the file
auto-attaches below the reply and the snippet is optional, only for placement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): soften message-list scroll fade (32px → 16px)
The 32px edge fade washed out full-bleed content (HTML / image previews)
at the list edges. Halve the fade distance so it barely grazes previews
while still hinting at more content above/below.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): renumber attachment_task_id migration 157 -> 158
main landed 157_agent_task_delivered_comments while this branch was open,
colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet.
Bump this PR's migration to the next free prefix (158). Rename only; the
migration body (nullable attachment.task_id + partial index) is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): pin attachment upload to the token's task; build index concurrently
Two code-review findings on the chat-attachment path (MUL-4287):
- Isolation/privacy: POST /api/upload-file only checked the form task_id
belonged to the caller's agent, not that it matched the task-scoped token's
authoritative X-Task-ID. A run authorized for task A could tag an attachment
onto task B (another chat task of the same agent, possibly another user's
session), binding it into that reply on completion. Require the form task_id
to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression.
- Migration: split the task_id lookup index into its own migration (159) built
with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a
multi-command file with the ADD COLUMN in 158.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287)
Addresses the two remaining Preflight BLOCKERs on PR #5164.
Security (file.go): the task_id upload path compared the form task_id to
X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT
leaves that header empty and the middleware does NOT strip a client-forged
X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair.
So a member who learned a task ID could forge both and inject an attachment
onto another chat task's assistant reply (cross-session/privacy leak). Now the
branch requires X-Actor-Source=task_token first (mirrors chat_history.go's
load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests
now go through the real task-token headers and add a forged-JWT-403 regression.
Migration (158): task_id is a transient binding handle (written once at upload
against an already-validated task, read only during that task's own
completion; durable owner is chat_message_id). There is no app-layer path that
hard-deletes agent_task_queue rows, and orphan uploads are already reaped by
attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a
cascade dependency the app never relies on plus write overhead on the hot
attachment table. Drop the FK; task_id is now a plain UUID column. Added a
regression test that an unbound task-tagged upload is reaped on chat_session
delete. Index (159, CONCURRENTLY) unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287)
Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up
with the CLI's file-card output, so agent-produced non-image files rendered
nowhere on mobile.
- `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket
output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never
matched — the line stayed literal AND `standaloneAttachments` still hid the
fallback card (the URL is in `content`), so the file showed nowhere.
- Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label
allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is
restricted to the same allowlist (site-relative /uploads + /api/attachments/
<UUID>/download, plus absolute http(s)); disallowed schemes stay plain text.
- Unescape the label to the real filename, then re-escape only the chars that
would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed
by the renderer — unlike web's HTML data-filename), so a raw `]` never
truncates the link text.
No dedup change: once the inline `!file` renders, hiding the standalone card is
correct. Added focused unit tests covering the escaped-label case, parens/
backslash unescape, the site-relative URL form, and disallowed-scheme rejection.
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>
Dropdown, context menu, select, and popover surfaces used
--floating-shadow, which is sized for window-level overlays and reads
too heavy on trigger-anchored menus. Introduce a lighter --menu-shadow
tier (surface < menu < floating) and drop the shadow-lg override on
ContextMenuSubContent so submenus match their parent menu. Dialogs and
sheets keep --floating-shadow.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The aggregate chat unread badge used two different definitions: web/
desktop's sidebar summed unread messages while mobile's tab badge (and
the since-removed ChatFab badge it mirrored) counted sessions — the same
account state showed e.g. 5 on web and 2 on iOS. The sidebar also summed
ALL sessions while the thread list zeroes the row being viewed, so a
reply landing in the open conversation flashed a sidebar count with no
matching row.
- packages/core/chat/unread.ts: countUnreadChatMessages() as the single
shared definition (IM-style message total, optional viewed-session
exclusion); pure so mobile can import it.
- app-sidebar: use the helper and exclude the actively-viewed session,
but only while a chat surface is actually showing it (chat route or
floating window open) — a remembered selection with both surfaces
closed still counts, since nothing will auto mark-read there.
- mobile: tab badge switches to the shared message count (99+ cap like
the sidebar), ChatSessionSchema parses unread_count, and markRead's
optimistic patch zeroes unread_count alongside has_unread.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(github): fan out PR/check_suite webhooks to all bound workspaces (MUL-4343)
One GitHub App installation can be bound to several workspaces (#4855), but
pull_request and check_suite webhooks were still routed to a single workspace
via resolveWorkspaceForRepo (workspace.repos registry + oldest-binding
fallback). Every workspace but one silently received nothing for a shared repo,
with no way to opt in.
Deliver each repo event to every workspace bound to the installation. Repo
scope is whatever GitHub authorized the installation for; we no longer gate on
the workspace.repos registry (that list means "code the agent clones", not a
webhook subscription). Each workspace independently mirrors the PR, auto-links
against its own issue prefix + github toggles, records check suites against its
own PR mirror, and gets its own realtime broadcast.
- Extract mirrorPullRequestForWorkspace / recordCheckSuiteForWorkspace and loop
over all installation bindings instead of resolving one workspace.
- Remove the now-dead resolveWorkspaceForRepo / repoIdentityFromURL routing.
- Replace the registry-routing tests with PR + check_suite fan-out tests.
Co-authored-by: multica-agent <github@multica.ai>
* test(github): out-of-order fan-out test + drop dead query + document multi-workspace delivery (MUL-4343)
Addresses review feedback on the webhook fan-out change:
- Add TestWebhook_CheckSuite_OutOfOrderFansOutToBoundWorkspaces: a check_suite
that arrives before the PR must stash a pending row per bound workspace, and
each workspace must drain its own row when the PR fans out.
- Remove the now-unused ListWorkspacesWithRepos query (its only caller was the
deleted resolveWorkspaceForRepo) and regenerate sqlc; fix the stale
"picks the target workspace via the repos registry" comment on
ListGitHubInstallationsByInstallationID.
- Document multi-workspace event delivery in the GitHub integration docs
(en + zh), including an explicit self-host upgrade note: delivery is now
keyed on the GitHub connection, so a workspace that relied on the
code-repository list alone (without connecting GitHub) must connect the
installation to keep receiving events. This is an intentional, documented
behavior change — the PR description's earlier "single-binding behavior is
unchanged" claim was inaccurate and has been corrected.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)
Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:
- agent_task_queue.chat_input_task_id makes a task the owner of the user
messages it must consume; the send path creates the task + user message +
attachment bindings + session touch in one transaction, and the daemon is
notified only after commit. A claim reads exactly that batch, so a message
that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
transaction: a normal message, or a visible no_response outcome (with a
non-empty English fallback) when the final output is empty. The write failing
rolls the completion back and the handler returns 5xx so the daemon retries;
the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
(keeping the tool timeline), suppress Copy, keep it unread, and keep the
session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
selector, so a rolling deploy never replays Slack/Lark history.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)
Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
the prior behavior: empty output writes no assistant row, so chat:done carries
empty content and the channel outbound silently drops it — the no_response
fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
and preserves the dispatched task for redelivery instead of cancelling a valid
task on a transient DB error.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): bound daemon.log size with rotation (MUL-4330)
The background daemon redirected its stdout/stderr into daemon.log opened
O_APPEND and never rotated it, so the file grew without limit until it was
too large to open. Every structured log line already flows through slog
(including agent subprocess stderr, forwarded via newLogWriter), so the
daemon's logger is effectively the sole author of the file's volume.
Route the foreground daemon's slog output — both the injected component
logger and the package-global slog default — through a size-based rotating
writer (lumberjack) that keeps the active daemon.log small (20MB default,
5 gzip-compressed backups, 30d), all env-overridable. Raw crash output
(Go runtime panics, pre-logger errors) now goes to a separate daemon.err.log
so the child's inherited fds never hold daemon.log open, which would block
rotation's rename on Windows.
The Desktop app spawns the daemon via this same launcher and its log tail
already handles size-shrink, so both CLI and Desktop are covered.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): address log-rotation review — foreground output, Windows handles, bounded err log (MUL-4330)
Resolves the blocking review items on the daemon.log rotation change:
1. Windows first-upgrade rotation: a foreground managed daemon now re-points
its own stdout/stderr to daemon.err.log at startup (SetStdHandle) before
building the rotator, releasing any daemon.log handle an older self-update
launcher inherited (Go opens files without FILE_SHARE_DELETE, which would
otherwise block rename-on-rotate). No-op on Unix, where an open fd never
blocks rename.
2. `daemon logs -f` vs rotation: Unix uses `tail -F` (reopen by name); Windows
opens the reader with FILE_SHARE_DELETE so it can't block the rotator's
rename, and reopens the file on size-shrink to follow across rotation.
3. Self-update handoff no longer briefly runs two rotators on one file: the old
process closes its rotator and moves remaining handoff logs (incl. the slog
default) to the crash sink before the successor starts.
4. daemon.err.log is now bounded: it rolls to a single ".1" backup once past
5MB at open time, so a crash loop can't move the growth problem to it. It is
also surfaced in the troubleshooting docs.
5. Explicit `--foreground` in a terminal keeps live stdout/stderr logging (a
documented debugging path); only detached/background children rotate into
daemon.log. Decided by whether stderr is a terminal.
Also: rotation env knobs now reject 0/negative (0 means 100MB / keep-all in
lumberjack), preventing an accidental unbounded config. Adds unit tests for
the err-log rolling and positive-int parsing; Windows/Linux(arm64) cross-builds
and `GOOS=windows go vet` pass.
Co-authored-by: multica-agent <github@multica.ai>
* docs: sync zh/ja/ko troubleshooting with daemon.log rotation + daemon.err.log (MUL-4330)
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): bump the agent's own runtime version in quick-create parent test (MUL-4330)
TestQuickCreateIssueParentTrustBoundary bumped an arbitrary `LIMIT 1`
agent_runtime, but the handler version-checks agent.RuntimeID — the runtime
bound to the request's agent. In the shared handler test workspace, other
tests register additional runtimes, so the two diverge and the agent's real
runtime keeps the seed's empty cli_version, tripping the daemon-version gate
(422 daemon_version_unsupported) before the parent_issue_id assertions run.
Bump the runtime tied to the agent instead, making the setup deterministic.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Adds the daily v0.3.42 changelog entry to all four localized landing sites: a dedicated Chat tab, LLM-generated chat titles, cancelled issues as a first-class column, agent model/effort on hover, plus reconnect/comment-delivery/agent-mention/link/version fixes.
Typecheck and the changelog unit test pass locally.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)
Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).
Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
session selection; web + desktop routing wired up. Removes the old
chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
unread *count* badge (read-cursor model), presence-gated typing vs waiting.
Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
empty state (avatar + name + description + starter prompts), and a
deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
(migration 146, dedicated chat_pinned_agent table + handler/queries).
New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
static template).
- Design: fade the global --border token; borderless list headers on
Chat/Inbox, kept (faded) on the conversation header.
Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230)
The "meet your new agent" chat used to insert a fake user message
("👋 Hi! Please introduce yourself …") and have the agent reply to it, so
the thread looked like the creator prompting the agent.
Drop the persisted user message. Flag the auto-created session
is_agent_intro (migration 147) and drive the intro run server-side: the
daemon builds a proactive self-introduction prompt for such sessions
(buildChatPrompt) instead of a "reply to their message" prompt. The intro
stays LLM-generated; the thread now opens with the agent's own message, as
if it reached out first.
- migration 147: chat_session.is_agent_intro
- CreateChatSession carries the flag; sendAgentWelcomeChat no longer
persists/publishes a user message
- daemon: ChatIntro threaded from session flag → intro prompt
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080)
* feat(chat): Settings toggle for the floating chat window (MUL-4235)
Re-introduce the floating chat overlay on top of Chat V2 as an optional,
Settings-gated surface instead of deleting it outright.
- Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted
client preference, default ON) to show/hide the floating window.
- FloatingChat wrapper owns the two gates: the preference, and the /chat
route (hidden on the tab so the same activeSessionId isn't shown twice).
- ChatFab + a compact ChatWindow that reuse the shared useChatController and
conversation components, so activeSessionId stays in lockstep with the tab.
- Restore use-chat-context-items so the overlay's @ surfaces the current
issue/project (the 'current context' affordance) — the tab stays manual.
- i18n (en/zh-Hans/ja/ko), store unit tests.
typecheck: core/views/web/desktop green. tests: chat store 9, settings 82,
chat 39 pass.
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235)
Address review: give Chat its own Settings tab instead of a section inside
Preferences, and default the floating window OFF (opt-in).
- New Settings → Chat tab (chat-tab.tsx) under My Account; moves the
floating-window toggle out of the Preferences tab.
- floatingChatEnabled now defaults OFF — only an explicit enable from the
Chat tab mounts the FAB/overlay.
- i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko);
revert the Preferences chat section and its test mock; store tests updated
for the opt-in default.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081)
The three starter prompts (List my open tasks by priority / Summarize what
I did today / Plan what to work on next) read as filler more than help, so
remove them along with the now-unused returning_subtitle ("Try asking").
The empty state keeps its agent-aware header — avatar + "Chat with {name}"
+ optional description — and the composer stays the entry point. Locale
keys dropped across en/zh-Hans/ja/ko (parity preserved).
Based on the Chat V2 branch (parent MUL-4171, #5076), not main.
Co-authored-by: Lambda <lambda@multica.ai>
* feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082)
Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.
Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.
Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.
Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.
* feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093)
Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the
selected bg-accent reads as an inset rounded card — same treatment the
chat thread list already uses — instead of a full-bleed, sharp-cornered
highlight. Content stays 16px-inset (p-1 + px-3 == old px-4).
MUL-4253
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): stop welcome intro session repeating the same introduction (MUL-4259)
The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user.
Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address review findings + unbreak CI (MUL-4171)
- task:failed now refreshes the sessions list (invalidateSessionLists), so
the thread-list preview / unread / sort stays correct after an agent
failure — FailTask persists a failure chat_message but only broadcasts
task:failed, mirroring the chat:done success path.
- Self-heal stale chat deep links: once the sessions list has loaded and a
?session= id isn't in it (deleted / no access / never existed) with nothing
in flight, clear the selection instead of rendering an editable empty chat
that would POST into a nonexistent session. Freshly-created sessions are
exempt (they carry optimistic messages + a pending task).
- CI: add the new parameterless `chat` route to link-handler's
WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set +
expectedSegments) — keeps the two in sync, fixes the failing @multica/core
test.
- Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views
typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to
EmptyState.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171)
Re-review follow-up: the stale-session self-heal only lived in ChatPage, so
the floating ChatWindow still entered from a persisted activeSessionId and
would render an editable empty chat (then POST into a nonexistent session)
when the selected session was deleted / lost access off the /chat route.
- Move the self-heal into the shared useChatController so every surface (tab
and floating window) drops a dangling activeSessionId once the sessions list
has loaded and doesn't contain it.
- Harden ensureSession: trust the current id only when it's in the loaded list
or is a just-created session still awaiting the refetch; a dangling id falls
through to create a fresh session instead of POSTing into a 404.
- Exempt just-created sessions via an OPTIMISTIC-write signal
(hasOptimisticInFlight: pending task or optimistic- message), not hasMessages
— a session deleted elsewhere with real cached history stays eligible for
self-heal. Add a unit test for the discriminator.
Co-authored-by: multica-agent <github@multica.ai>
* test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171)
The AppSidebar personal nav gained a `chat` item, so it calls
`useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't
been updated, so `p.chat` was undefined and every render threw
`TypeError: p[item.key] is not a function`, failing @multica/views#test in CI.
- Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths.
- Route the chat-sessions query key through a mutable `chatSessions` fixture.
- Add coverage for the Chat nav: renders the link, badges the summed
unread_count, and hides the badge when all sessions are read — so this drift
is caught next time.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102)
Follow-up to the merged #5080, which shipped a hand-written, simplified
ChatWindow and lost the original's animations / drag-resize / expand-minimize.
The floating window is just a quick entry point — it should be the original
UI, not a rewrite.
- Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and
use-chat-resize.ts verbatim from main (0-diff): motion animations, drag
resize, expand/minimize and the session dropdown are back.
- Restore the empty_state.returning_subtitle + starter_prompts i18n keys the
original window renders (V2 had dropped them); drop the now-unused
window.open_full_tooltip key the rewrite added.
- Settings gating is unchanged: FloatingChat still wraps the original FAB +
window, gated by floatingChatEnabled (default off) and hidden on /chat.
typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098)
Restore an archive flow as the reversible sibling of delete:
- Chat list hover now offers Archive (not Delete); pin/stop unchanged.
- A footer entry ('Archived · N') opens an Archived view listing archived
chats; hard delete lives only there (hover -> unarchive + delete, with
the existing inline confirm).
- Conversation header ⋯ menu mirrors this: active chats archive, archived
chats unarchive/delete.
Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived
(SetChatSessionArchived), broadcasts status on chat:session_updated so other
tabs re-sort into the right list. SendChatMessage already refuses archived
sessions, so archived chats stay read-only until unarchived.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100)
* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)
Co-authored-by: multica-agent <github@multica.ai>
* refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address floating-window + archived-agent review blockers (MUL-4171)
Re-review follow-up on the restored floating ChatWindow + archive flow:
1. Floating stale-session self-heal. The restored ChatWindow doesn't use the
shared controller, so its ensureSession trusted any non-empty
activeSessionId and there was no dangling-session cleanup — a deleted /
no-access persisted session could send into a nonexistent session. Ported
the same guard used for the tab: a self-heal effect that clears a dangling
activeSessionId once the sessions list has loaded, and ensureSession only
trusts an id that's in the list or has an in-flight optimistic write
(hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds
the optimistic message + pending task before setActiveSession, so a
freshly-created session is never mis-cleared.
2. Floating dropdown bypassed archive-first safety. Its active rows offered a
hard-delete, letting the floating window destroy active chats and skip the
"archive first, delete only from Archived" model. Active rows now ARCHIVE
(reversible, one-click) like ChatThreadList; the floating window offers no
hard-delete — unarchive/delete live only in the full Chat page's Archived
view (reachable via expand). Removed the now-dead delete-confirm machinery.
3. Orphan user message on archived-agent send. SendChatMessage created the
chat_message before EnqueueChatTask, which rejects an archived / runtime-less
agent — a stale client would land a user message then get a 500, orphaning
it. Added a preflight that checks the session agent's archived / runtime
state and returns 409 before any mutation, plus a handler test asserting the
send is rejected with no message persisted.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
#5057 restricted version derivation to `git describe --tags --match 'v[0-9]*'`,
but the command was passed to `execSync` as a shell string. On Windows the
shell is cmd.exe, which does not strip the POSIX single quotes around
'v[0-9]*', so git received the quotes literally, matched no tag, fell through
to `--always`, and the version degraded to the `0.0.0-g<hash>` fallback.
That is what shipped a `0.0.0-gc05b67ae4` Windows Desktop build (electron-builder
`--publish always` then auto-created a bogus release) during the v0.3.41 release,
even though the tag was sitting exactly on HEAD. Linux/macOS were unaffected
because /bin/sh strips the quotes.
Fix: invoke git with an argv array via execFileSync in every version-derivation
path, so the match pattern reaches git as one literal argument regardless of
platform:
- apps/desktop/scripts/package.mjs (Desktop version → electron-builder)
- apps/desktop/scripts/bundle-cli.mjs (bundled CLI ldflags version)
- apps/desktop/src/main/app-version.ts (dev-mode version fallback)
The Makefile is intentionally left as-is: make's `$(shell ...)` always runs via
/bin/sh (even on Windows) and the CLI release runs on Linux, so its single
quotes are stripped correctly.
Tests: export `deriveVersion` and `DESCRIBE_ARGS` and add coverage that runs the
real `git describe` against throwaway repos (clean semver tag, semver tag chosen
over a nearer non-semver tag, and the no-tag fallback), plus a structural check
that the match pattern is a bare argv token with no embedded quotes. The prior
suite only unit-tested the `normalizeGitVersion` string transform, which is why
this slipped through.
MUL-4256
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Non-semver tags (e.g. release-train tags) could become the nearest match
for `git describe --tags`, producing a version string that is not a valid
semver prefix. Restrict describe to `v[0-9]*` tags across the CLI ldflags,
desktop bundling, and app-version paths so the resolved version always has
a `major.minor.patch` shape.
The /login page's already-authenticated effect fired on any user change,
including the one verifyCode writes mid form-login while handleVerify is
still fetching the workspace list. It then read the cold list cache
(getQueryData ?? []) and raced handleSuccess with a replace to
/workspaces/new — users with existing workspaces could land on the
create-workspace page after login.
Two changes, both in the arrival effect:
- Ownership: latch once auth settles as logged-out on this page; any
user appearing afterwards came from the login form, whose
handleSuccess owns post-login navigation. The effect now only serves
visitors who arrived authenticated. The desktop-handoff branch stays
above the latch so form logins with platform=desktop still mint the
deep-link token.
- Correct data: for genuine arrived-authenticated visitors, fetch the
list via ensureQueryData instead of reading a possibly-cold cache,
which misrouted workspace owners to /workspaces/new on fresh page
loads.
Regression tests verified red against the previous implementation.
Fixes#5009
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>