4195 Commits

Author SHA1 Message Date
Multica Eve
8ca30e794e docs(changelog): v0.4.4 (2026-07-17) release entry (#5577)
* docs(changelog): add v0.4.4 (2026-07-17) release entry

Adds today's user-facing changelog entry across en/zh/ja/ko: a new autopilot schedule editor, an interactive diagram viewer, plus the day's improvements and fixes.

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

* docs(changelog): use 自动化 for autopilot in zh, matching the standard term

Per Bohan's review: the Chinese standard for autopilot is 自动化 (as used elsewhere in zh.ts, e.g. 定时自动化 / 触发式自动化), not 自动驾驶. Normalizes all 10 occurrences in the changelog, including the new v0.4.4 entry and earlier historical entries, for consistency.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
v0.4.4
2026-07-17 17:51:36 +08:00
Bohan Jiang
28969a8493 fix(autopilots): clarify Chinese schedule day-pattern labels (MUL-4927) (#5576)
* fix(autopilots): clarify Chinese schedule day-pattern labels (MUL-4927)

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

* fix(autopilots): make weekly day-pattern label more explicit (每周特定几天) (MUL-4927)

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 17:11:20 +08:00
YYClaw
71141559c2 fix(shortcuts): ignore synthetic key events without a key
Guard shortcut parsing against synthetic key events that do not expose a string key.
2026-07-17 16:51:21 +08:00
Bohan Jiang
6dba74c3c1 fix(task): auto-retry transient "Connection closed mid-response" like chat (MUL-4910) (#5565)
* fix(task): auto-retry transient provider stream cut like chat (MUL-4910)

Claude Code's "API Error: Connection closed mid-response" is a transient
network cut. In unattended issue runs it fell through to
agent_error.unknown / process_failure — neither in retryableReasons — so
the task terminated. Interactive chat only appeared resilient because the
CLI's own in-process retry usually recovers first; there is no
chat-specific retry in Multica. Both paths share the same
finalizeStreamResult -> Classify -> retryableReasons pipeline.

- classify: route "connection closed" / "mid-response" to
  agent_error.provider_network, before the exit-status rule so the
  "exited with error: exit status N" variant also lands here.
- retry: add provider_network to retryableReasons. It is resume-safe, so
  the retry child inherits the session and continues the truncated
  conversation instead of restarting.

Tests cover the new classification (incl. exit-status variant) and the
retry/resume flags. Note: mirror the substrings into the MUL-1949 offline
backfill SQL to keep in-flight and historical taxonomies aligned.

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

* feat(task): defer provider_network's final retry ~5s — three-tier (MUL-4910)

Follow-up to the immediate-retry fix: make the connection-closed retry a
three-tier schedule — first run + immediate retry + one retry deferred ~5s —
so a blip that survives the immediate retry gets a short cooldown before the
final attempt instead of firing back-to-back.

Reuses the existing deferred/fire_at primitive (the comment-routing escalation
mechanism) rather than adding new infrastructure: CreateRetryTask gains an
optional fire_at; when set, the child is inserted 'deferred' and the existing
PromoteDueDeferredTasksForRuntime sweeper — already run promote-first on every
claim poll — flips it to 'queued' at fire_at. No migration, no claim-query
change, no daemon change.

- retryAttemptCeiling: raise provider_network's ceiling to 3 (other reasons
  keep max_attempts=2); applied in both retryEligible and
  MaybeRetryFailedTask's budget pre-check so the primary and sweeper paths agree.
- retryDelayForAttempt: only provider_network's final attempt is deferred (5s);
  every other retry — including provider_network's first — stays immediate.
- FailTask + MaybeRetryFailedTask pass fire_at and skip the queued
  broadcast/notify for a deferred child (promotion emits them at fire time).

Timing: the deferred child fires on the first claim poll at/after fire_at, so
>= 5s; on an otherwise-idle runtime it can stretch to the poll interval — the
same behaviour deferred escalations already have.

Tests: pure schedule/eligibility coverage (TestProviderNetworkRetrySchedule)
plus a DB test asserting fire_at controls deferred-vs-queued, attempt=3, and
resume-safety.

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

* fix(task): persist reason-aware retry budget; respect max_attempts=1 disable (MUL-4910)

Addresses the pre-merge review must-fix: retryAttemptCeiling raised
provider_network to 3 unconditionally, which (1) overrode the
max_attempts=1 "auto-retry disabled" contract and (2) persisted a
self-contradictory child (attempt=3, max_attempts=2) that leaks to the
task API, so a naive attempt < max_attempts consumer would misjudge the
budget.

- retryAttemptCeiling now returns taskMaxAttempts unchanged when it is <= 1
  (disabled stays disabled) and only ever WIDENS otherwise (max(col, 3) for
  provider_network) — a higher configured budget is kept.
- CreateRetryTask takes an optional max_attempts; FailTask and
  MaybeRetryFailedTask write the reason-aware effective ceiling into the
  child so the whole retry chain self-describes (attempt=3, max_attempts=3).
  NULL inherits the parent column, so non-provider_network reasons are
  unchanged.

Tests:
- pure: ceiling widens to 3, keeps a higher budget, and never revives a
  disabled (max_attempts=1) task; eligibility rejects the disabled case.
- DB (end-to-end FailTask): default budget → deferred final child at
  attempt=3/max_attempts=3; first failure → immediate child; max_attempts=1
  → no child. Plus CreateRetryTask persists the passed budget / inherits on NULL.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 16:47:59 +08:00
YYClaw
465546b83b feat(autopilots): redesign the autopilot schedule editor (#5457)
Replace the free-form trigger-config form with a structured schedule
editor built on an orthogonal cron model: separate frequency, time, and
day-of-week/day-of-month dimensions map to and from cron expressions via
a dedicated grammar and mapping layer, with validation and a
human-readable describe() summary. The grammar suite drives the editor
against a combinatorially generated corpus of 51,755 distinct cron
expressions - every token form of every field, crossed - each judged
against a reference robfig/cron v3 parser.

Add a server-side /autopilot/cron-preview endpoint (plus schema and
React Query hook) so the editor shows upcoming run times, and echo
wildcard-carrying cron lists correctly instead of collapsing them.

Supporting pieces: timezone-aware formatting helper, segmented-toggle
and debounced-value utilities, a reworked time-input, and refreshed
en/ja/ko/zh-Hans locale strings.
2026-07-17 16:44:31 +08:00
Naiyuan Qing
17ee59ccc5 feat(editor): standard Mermaid viewer with pan/zoom, exports and a real dialog (MUL-4908) (#5564)
* feat(editor): standard Mermaid viewer with pan/zoom, exports and a real dialog (MUL-4908)

Mermaid's fullscreen view was a bespoke lightbox: no visible close button, no
canvas interaction, and a toolbar that scrolled away with wide diagrams. Escape
also stopped working once the iframe took focus, which is why it read as
"impossible to close".

Replace it with a viewer built on the shared Dialog, so it inherits the
backdrop, focus trap, scroll lock, focus restore and Escape handling that HTML
preview already had.

The diagram stays in an `sandbox=""` iframe — isolation is not relaxed to buy
interactivity. Instead the iframe is `pointer-events: none` and pan/zoom is
applied from the host document as a transform on its wrapper. That inversion is
also what keeps Escape working: the iframe can never take focus.

- Viewer: fit-on-open, drag pan, wheel/pinch/keyboard zoom (25%-400%) anchored
  at the pointer, Fit / 100% / Reset, and a header toolbar that cannot scroll
  away. Panning is clamped so the canvas can never be lost.
- Export: `.mmd`, `.svg` and `.png`. Requires `htmlLabels: false` — browsers do
  not rasterize Mermaid's default HTML-in-foreignObject labels through an
  `<img>`; verified in Chromium that they paint zero pixels AND taint the
  canvas, so PNG export silently produced nothing.
- Inline: toolbar lifted out of the scroll container, natural-size rendering
  (small diagrams centered, wide ones scroll at a readable size with edge
  fades), copy source, and a parser message on the error fallback.
- Theme switches no longer close the viewer or discard zoom/position; the
  previous diagram stays on screen until its replacement is ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): stop Mermaid drags selecting text and misfiring the viewer (MUL-4908)

Two gesture defects on the inline diagram, reported by Naiyuan.

1. Dragging a diagram started a native text selection. The iframe is a replaced
   element, so the whole diagram box got painted with the selection highlight
   and the drag ran on into the surrounding comment text. Fixed with
   `user-select: none` on the inline scroller and the viewer canvas.

   Deliberately NOT by preventDefault-ing pointerdown: verified in Chromium that
   it also drops the default focus, which silently kills the viewer's keyboard
   controls (+/-, 0, arrows).

2. Any drag ended in a `click`, so trying to look along a wide diagram opened
   the viewer on top of the user. The inline diagram had no drag handling at
   all — only `onClick`. Suppressing the selection alone would have made this
   fire more reliably, not less, so both had to land together.

   Past a 5px threshold the gesture is a drag for good: releasing no longer
   taps, and a horizontal drag pans the scroller instead. This holds even when
   the diagram has no room to scroll, so an unscrollable diagram cannot open on
   release either. A still click, a sub-threshold jitter, and the expand button
   all still open the viewer.

Touch is left alone — it already pans natively and its vertical drags belong to
the page; the browser announces its takeover with pointercancel. Mouse and pen
have no native drag-to-scroll and are panned here.

Verified in Chromium: still click opens; a 150px drag pans and does not open; a
drag on an unscrollable diagram does not open; 3px jitter still opens; and no
gesture selects text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

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>
2026-07-17 16:07:37 +08:00
leroy-chen
e13eb6c216 feat(cli): persist daemon flags in config.json (#3824)
Merge approved PR #5161.
2026-07-17 15:43:29 +08:00
Bohan Jiang
d1c7e1c56d fix(chat): always scroll to bottom when switching chat session (#5566)
The Chat tab (and floating chat window) both render the shared
ChatMessageList, re-keyed by activeSessionId so it fully remounts on
every session switch. The Virtuoso had no initial-position prop, so a
fresh mount rendered from the top; the only downward scroll driver was
followOutput, which reacts to post-mount data growth and is gated on
isNearBottom. Whether it landed at the bottom depended on a race
(cached sessions resolve synchronously and stuck at the top; fetched
ones sometimes caught a growth tick), producing the inconsistent
top-vs-bottom behavior.

Pin the newly-mounted list to the newest message with
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }} so switching
sessions always lands at the bottom. align: 'end' also bottom-aligns a
last message taller than the viewport.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 15:01:27 +08:00
ZIce
910671b185 Fix Codex task MULTICA_TOKEN passthrough (ZIC-82)
Merge approved PR after independent final review; all required CI checks passed.
2026-07-17 14:29:46 +08:00
Bohan Jiang
3ce25d16e7 fix(migrate): auto-backfill attribution before migration 198 to unblock self-host upgrade (MUL-4897) (#5558)
Self-hosted upgrades to v0.4.3 failed closed on migration 198's VALIDATE of the strict attribution constraint, because the legacy rows migration 190 exempted were only backfilled out-of-band on cloud. Registers a pre-198 preMigrationHook that idempotently mirrors originator_user_id into accountable_user_id in batches before VALIDATE, with FOR UPDATE + repeated predicate to avoid clobbering concurrently-written rows, so a stuck-at-197 instance auto-heals on migrate up with no manual SQL. Originator-NULL rows are left untouched. Verified with unit + concurrency + end-to-end tests against real Postgres.

Fixes #5544
2026-07-17 14:06:03 +08:00
Naiyuan Qing
ce15300493 MUL-4884: feat(issues): anchor the working chip on agents + real colour tiers (#5540)
* fix(issues): anchor the working chip on issues the filter actually shows (MUL-4884)

The header chip showed three units at once: the number counted distinct
issues, the avatar stack counted agents (with a rival "+N"), and the hover
card counted tasks — under a label with no noun at all. Each figure was
self-consistent; together they read as a miscount. c4209ec7c flipped the
number from agents to issues but left the other three surfaces on their old
units.

The chip now says one thing — "N issues in progress" — where N is the number
of rows clicking it leaves.

Data layer:

- The count is no longer re-derived from the task snapshot. The surface
  exposes `workingScopeIssues`: the render pipeline's own output with
  `workingOnly` forced on, so "chip count === row count" holds by
  construction instead of by convention. It previously counted running
  issue_ids against the PRE-filter set, so any active status/assignee/label
  filter — or a sub-issue hidden by the display toggle — made the chip
  disagree with the list it was filtering.
- Chat/autopilot tasks carry issue_id "" (not null). They used to collapse
  into one bucket and read as a phantom issue, inflating the count by exactly
  one whenever any were running. They are now bucketed out and disclosed.
- The list loads one page per status (50), so running work can exist past the
  window. The count stays list-anchored — counting rows a click cannot show
  would break the chip's whole promise — and the gap is stated in the hover
  card rather than dropped silently.

UI:

- Avatar stack is ambience, not a statistic: no "+N"; the tail fades and the
  exact roster lives in the hover card.
- Hover card groups rows by issue, names both counted units in its header
  ("3 issues in progress · 4 tasks"), and footnotes anything excluded — only
  when non-zero.
- Colour is two-step: idle activity is a brand tint, the filled state is
  reserved for "filter is ON".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): scope the working chip to the rows swimlane and gantt actually draw (MUL-4884)

Review found two modes where `workingScopeIssues` still came from a
different set than the one on screen — the exact drift this change set is
meant to remove.

Swimlane: scoped to the statusless `swimlaneIssues`, but SwimLaneView draws
its cards from `issues` (status filter applied) and only uses the statusless
set for LANE DISCOVERY. A status-filtered swimlane counted rows the canvas
never drew. Swimlane needs no branch at all — it renders the flat filtered
list like board and list do — so the special case is gone rather than
corrected.

Gantt: the canvas applies two rules of its own before drawing — a row needs a
date to be placed, and done/cancelled hide unless `ganttShowCompleted` is on
— so a done issue with a running agent counted toward the chip while the
canvas refused to draw it.

Those rules now live in the surface (`ganttCanvasRows`) instead of privately
inside GanttView, and both `filteredGanttIssues` and the working scope go
through them. Mirroring the rules in a second place would have reproduced the
original bug with extra steps; hoisting them means the chip narrows the same
set the canvas draws, by construction. GanttView goes back to being a
renderer: it orders rows, it does not decide which ones exist.

Regression tests cover both, and each was confirmed to fail against the code
it guards: swimlane asserts the statusless lane source still holds the wider
set, so a revert fails loudly rather than passing by luck; gantt covers the
hidden done row, the undated row, and the show-completed toggle widening both
the canvas and the scope together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): anchor the working chip on agents, and give it real colour tiers (MUL-4884)

Follows the approved final mock. Two changes, one root cause each.

SEMANTICS: the chip now counts agents, not issues.

The original bug was "+1" (agents) sitting next to "3" (issues) — two units
in one control. Anchoring on issues fixed which number was authoritative but
left the units mismatched, so the avatar stack had to lose its +N and the
label had to grow a noun to suppress the contradiction. Counting agents makes
the number and the heads beside it the same list: the mismatch is gone rather
than managed. It also settles the subject — only agents emit a runtime
signal, so "N agents working" cannot be misread as "N issues someone is
working on", which "N issues in progress" invited.

Accepted trade-off: the number no longer predicts the row count of the click.
One agent on two issues reads "1 agent working" and opens two rows. The chip
answers WHO, the list answers WHERE — different questions, so they don't
compete, and the hover card's issue grouping shows the mapping. The
workingScopeIssues pipeline stays: it still decides WHICH agents count.

The hover card drops both "not counted" footnotes. They explained an absence
the user never perceived — chat/autopilot runs leave no row, no head, no
indicator on this page — so they invented a discrepancy instead of resolving
one. The empty-issue_id guard stays: that is data correctness (those agents
aren't on any issue), independent of whether we narrate it.

Avatar overflow returns to the component's standard +N badge; the fade
variant is deleted. With an agent-anchored number, "3 shown + 1 = 4"
corroborates the text instead of competing with it.

COLOUR: three tiers, each a self-contained Button variant.

Layering brand classes over `outline` could never work in dark mode:
`outline` ships dark:bg-input/30 / dark:border-input / dark:hover:bg-input/50,
tailwind-merge keeps them (different modifier group), and they win the
cascade — `dark` compiles to `&:is(.dark *)`, so they outrank a bare
.bg-brand on specificity, not just order. Both brand tiers were dead in dark;
--brand-foreground equals --foreground there, so filter-on and filter-off
rendered pixel-identical.

New `brand` / `brandSubtle` variants own every state instead: hover, pressed,
and aria-expanded pinned to the hover value so opening the popover doesn't
read as a colour change. `brand` needs no dark: at all — the token flips per
theme. `brandSubtle` runs one notch hotter in dark, where the same alpha
reads weaker.

Verified against the compiled stylesheet rather than by asserting class
strings — a string assertion is what let the dark bug through. A cascade
check resolves each tier x {default,hover,pressed,popover-open} x
{light,dark} by specificity and source order; all 16 brand cells land on
their intended notch, identical in both themes for `brand`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): stop the empty-state muted text from overriding the brand tier (MUL-4884)

Review caught the colour rule breaking itself, and the test that was supposed
to guard it being unable to.

`filter ON + 0 agents` is a real state — the filter stays on after the last
agent finishes. There the variant is `brand`, and the chip appended
`text-muted-foreground` for the empty state regardless of the filter.
tailwind-merge keeps the last class in a group and `className` is merged
after the variant, so the muted grey WON: grey text on a brand-blue fill. The
irony is exact — this change set exists because colour classes in `className`
lose to a variant's `dark:` chain, and here one beat the variant instead.
Either way the lesson is the same: a tier's colours only ever come from its
variant. `chipAppearance` now makes that a rule with one gated exception
instead of an inline ternary, and says why.

The variant assertion could not catch this: the variant IS `brand` while the
text is overridden. That is the same shape of false confidence as the class
-string assertion it replaced, so this commit brings the cascade check into
the repo instead of leaving it as something a commit message claimed.

`apps/web/app/brand-variant-cascade.test.ts` compiles the real globals.css —
web's and desktop's, since each defines its own `dark` variant — and resolves
what a browser would paint for the merged class strings: filter declarations
matching the element's classes and state, then take the winner by
specificity, then source order. It asserts both tiers across default / hover
/ pressed / popover-open x light / dark, and pins the two ways the colour has
actually been lost as executable failures: layering brand over `outline`
(dark:bg-input/30 wins by specificity) and appending a colour class (wins by
merge order).

Both new guards were confirmed to fail against the code they guard: reverting
the gate fails the chipAppearance test; giving `brand` a dark: rule fails the
cascade test in both bundles.

Also refreshes the comments still describing the retired issue-anchored
invariant ("chip count === row count"). The scope pipeline they describe is
unchanged and still load-bearing — it decides WHICH agents count — but the
chip's number is agents, so it is no longer this list's length.

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>
2026-07-17 13:56:23 +08:00
Naiyuan Qing
1507997272 fix(agent): stop agents shipping local-path links, make Desktop 404 recoverable (MUL-4899) (#5557)
Agents were writing runtime-local paths into deliverables as clickable
links (`[screenshot](/Users/agent/work/shot.png)`). Two root causes, both
fixed here.

A. The brief never stated the delivery contract. Add an always-on delivery
invariant (outside writeOutput's kind switch, so no task kind can inherit
none) plus a per-surface file-delivery line for each of the five surfaces.
Chat splits into two: `attachment upload` works only on web/mobile chat,
never on an IM channel, so ChatChannelType is now threaded into
TaskContextForEnv.

The claim path only ever looked up Slack bindings, so a Feishu session
reported as a web chat and got upload guidance for a channel that cannot
carry attachments. Probe every channel type. The chat policy is two
independent layers and stays that way: delivery keys off "is there a
channel at all"; the `chat history` / `chat thread` commands stay
Slack-only because both endpoints are hardwired to h.SlackHistory and
there is no Feishu reader — ChatInThread only selects between those two
commands, so it stays Slack-only too.

Add a CLI hard-fail lint on `issue comment add` / `issue create` /
`issue update` as the enforcement backstop. Scoped narrowly, since a false
positive blocks a real deliverable: agent task context only (a human's PAT
run is untouched), real CommonMark link/image/autolink destinations only
via goldmark (a path in a code span or fence — how an agent quotes a path
it is discussing — is structurally invisible), and three high-confidence
signals only (`file://`, inside the workdir, or an existing local file).
A bare `/foo` is a valid origin-relative URI and is deliberately allowed.
`issue update` has no --attachment flag, so its hint redirects to
`comment add` rather than naming an argument it rejects.

B. Desktop presented the resulting router 404 as an unknown crash. 8 of 18
desktop_route_error reports were users clicking such a link and being told
the app broke and to file a bug. Split the 404 into a first-class Not Found
view: no crash framing, no Report error. Its recovery entry comes from the
tab store's active workspace, never from the failed pathname — deriving a
slug from `/Users/me/shot.png` yields "Users" and a button to `/Users/issues`,
a second 404.

Also add a will-navigate trusted-origin guard via the shared loadRenderer
(main + issue windows). This is origin hardening only, NOT the mechanism for
in-app links: client-side routing never fires will-navigate, so app paths
never reach it. Issue windows need no 404 work — their router only accepts
paths validated by parseIssueWindowPath and they do not listen for
multica:navigate, so a bad path cannot reach them.

Server-side completion observation is metric/log only and never blocks: it
is lexical (`file://` + task work_dir prefix) because the server cannot stat
the daemon's filesystem, and the metric label is a closed enum so no path or
reply text reaches Prometheus.

Verified: pnpm typecheck/lint/test (3582 tests), go vet, full Go suite
including new claim-path integration tests. cmd/multica was verified outside
the daemon workdir — inside one, 93 of its tests fail identically on
origin/main because the suite walks up and finds the runtime's own task marker.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:40:23 +08:00
Jiayuan Zhang
82906bd9e9 feat(settings): move create-issue field config into a Settings Issue tab (#5556)
The quick-create field toggles used to live inside the quick create
popover; per feedback on MUL-4873 they belong in the Settings page.
Adds a My Account 'Issue' tab with one group per create mode backed by
a new per-workspace issue-create-settings store, extends field
visibility to the manual dialog (status/priority/assignee/labels/
project/due date/start date with value-reveal and overflow re-entry),
and routes both dialogs' 'Customize fields' items to the tab.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:36:13 +08:00
YYClaw
7d04b1d9a3 fix(cli): fail fast with actionable daemon startup errors
* fix(cli): fail fast with login hint when starting daemon unauthenticated

'multica daemon start' (background mode) spawned the child first and
only then polled its health port. When the user never ran 'multica
login', the child died instantly on resolveAuth, but the parent kept
polling for the full 45s readiness window and ended with a vague
"check logs" warning and exit code 0 — looking like a silent hang.

Check the stored config token before spawning (mirroring
daemon.resolveAuth, which only accepts the config token) and exit
immediately with an actionable "run 'multica login'" hint.

'daemon restart' gets the same guard BEFORE its stop phase: it used
to stop the running daemon first and only then fail auth inside the
start phase, leaving the user with no daemon at all. The foreground
path already failed fast and is unchanged.

* fix(cli): report early daemon child exit with an actionable reason

Background 'daemon start' Release()d the child immediately and then
polled the health port blind. Any preflight failure — server
unreachable, stored token rejected with 401 — killed the child within
a second, but the parent still sat through the full 45s readiness
window and ended with a vague "check logs" warning and exit code 0.

Keep a Wait() goroutine on the child and select on it inside the
readiness poll. When the child dies before reporting ready, classify
what this startup attempt appended to the log and fail with exit
code 1 and a one-line reason plus next step:

  - token rejected / 401  -> run 'multica login' (profile-scoped)
  - connection refused / DNS / timeout -> server unreachable at <url>
  - anything else -> short log excerpt with DBG/INF noise dropped

* fix(cli): probe token validity and server reachability before restart stops the daemon

requireDaemonAuth only rejects an empty stored token, so a revoked or
expired token — or an unreachable server — passed the restart guard,
the running daemon was stopped, and the replacement child then died in
preflight, leaving no daemon at all (#5165).

daemon restart now runs a whoami round-trip (same /api/me call as
'multica auth status') against the server the daemon will talk to,
using the stored token, before entering the stop phase — and only when
a daemon is actually running, so plain 'daemon start' keeps its
zero-round-trip happy path. On 401 it reports the re-login hint; on a
transport error it reports the unreachable server; both state that the
running daemon was left untouched.

Regression tests cover a non-empty stored token against a fake 401
server and an unreachable server, asserting /shutdown is never
requested on the fake running daemon.
2026-07-17 13:27:06 +08:00
Multica Eve
ea8ccf3123 MUL-4903 fix(agent): harden Cursor terminal failures (#5559)
* fix(agent): harden Cursor terminal failures

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

* fix(agent): preserve UTF-8 stderr tails

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:09:20 +08:00
Multica Eve
07538e9928 fix(execenv): isolate Hermes SQLite state (#5560)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:07:33 +08:00
Multica Eve
18d41151eb feat(gc): batch issue reconciliation (#5534)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 12:33:36 +08:00
Wangjue Yao
3547201bbe fix(daemon): reuse managed workdirs for squad leaders (#5429)
Squad-leader follow-ups on the same issue now reuse the prior daemon-managed workdir and provider session instead of starting fresh, while never binding or locking a user-provided local_directory. Reuse eligibility is keyed off a Prepare-time .managed_env.json provenance marker, so it does not race the completion→GC-metadata write.

Closes #5535

Co-authored-by: Bohan <bohan@devv.ai>
2026-07-17 12:12:47 +08:00
milymarkovic
1e34dab672 fix(cli): set agent type when updating autopilot (#5543) 2026-07-17 11:36:08 +08:00
lizhongxuan
6c84664089 test(agent): serialize thinking cache tests (#5551) 2026-07-17 11:20:57 +08:00
Jiayuan Zhang
ed9adc2bbe feat: improve create issue field controls (#5532)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 11:11:52 +08:00
Jiayuan Zhang
d833ef520d fix(editor): preserve ordered list numbering on rich paste (#5538)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 11:11:17 +08:00
Naiyuan Qing
a18cc65b35 fix(inbox): render archived rows as read (MUL-4893) (#5549)
Archiving deliberately leaves `read` untouched so unarchiving can restore
the real unread state (MUL-3736). InboxListItem did not account for the
view, so archived rows kept an unread dot and bold title that no action in
the archived view can clear.

Gate the unread affordance on the view at the display layer, matching the
precedent set for chat in MUL-4360. The `read` field and all archive write
paths are unchanged, so unarchiving still restores real unread state.

Route all four read-driven styles (dot, title weight, detail label,
timestamp) through one `showUnread` flag so an archived row renders
consistently as read rather than half-dimmed.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 09:53:38 +08:00
Multica Eve
ad7b23896d docs(changelog): add v0.4.3 release entry (en/zh/ja/ko) (#5533)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
v0.4.3
2026-07-16 17:37:34 +08:00
Wangjue Yao
5664c0d755 MUL-4874: fix(execenv): seed Codex model cache in task homes (#5449)
* fix(execenv): reuse Codex startup caches

* fix(execenv): preserve task-local Codex model cache

* fix(codex): bind model cache to provider config

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 17:13:52 +08:00
Bohan Jiang
ae68799c64 feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869) (#5525)
* feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869)

A manual retry (execution-log "retry" button -> POST /api/issues/{id}/rerun)
previously set force_fresh_session=true unconditionally, so the daemon threw
away the failed run's workdir and started from an empty one. For a transient
failure (network blip, provider 5xx / rate-limit, runtime_offline, timeout)
that discards work the agent already did.

New contract: a retry ALWAYS reuses the source task's workdir when it still
exists on disk; only the agent SESSION is gated, on whether the source task's
failure poisoned the conversation.

- RerunIssue derives force_fresh_session from the source task's failure_reason
  via resumeUnsafeFailureReason instead of hardcoding true. Transient/cancelled
  failures resume the session; conversation-poisoning failures start fresh.
- The daemon claim handler resolves the retry's session/workdir precisely from
  rerun_of_task_id, not the most-recent (agent,issue) row that a parallel task
  could hijack. PriorWorkDir is returned whenever present; PriorSessionID only
  when resume-safe AND on the same runtime.
- force_fresh_session now gates only the session, never the workdir.
- Add agent_error.context_overflow to the resume-unsafe set (Go helper plus the
  GetLastTaskSession / GetLastChatTaskSession blacklists): resuming an overflowed
  context would immediately overflow again.

Objectively-unreusable cases (workdir GC'd, different runtime, failed before a
workdir was recorded) fall back to a fresh Prepare via the daemon's existing
execenv.Reuse / gateResumeToReusedWorkdir path, so no daemon change is needed.

Tests: RerunIssue force_fresh classification by failure_reason; claim-layer
workdir-always-reused plus session gating (same/different runtime, poisoned);
context_overflow classifier.

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

* fix(task): make manual-retry workdir reuse rollback-safe and error-text aware (MUL-4869)

Addresses code review on #5525.

- Rollback safety: RerunIssue no longer writes force_fresh_session based on the
  source failure. Rerun rows are pinned to force_fresh_session=true again, so an
  OLD claim handler picked up mid rolling-deploy degrades to a clean start
  instead of falling back to the (agent, issue) most-recent lookup and resuming
  a *different* execution. The new claim handler ignores the flag for reruns and
  computes session reuse from the exact source task (rerun_of_task_id).

- Legacy poison defense: add shared service.ResumeUnsafeFailure(failureReason,
  errorText), which combines the failure_reason poison set with the same
  400/invalid_request_error raw-error-text guard GetLastTaskSession applies. The
  claim handler's exact-source path uses it, so legacy 'agent_error' /
  deploy-window rows carrying a 400 marker are no longer resumed.

- CI: TestRerunIssueTargetsSourceTaskAgent passes again (rerun rows stay
  force_fresh_session=true). Service test now asserts that rollback-safe
  invariant across failure classes; the claim test drives session gating from
  the source task and adds a legacy-400 case.

- Docs: tasks.{mdx,zh,ja,ko}.mdx and the GetLastTaskSession SQL comment now
  distinguish execution-log per-row retry (task_id: reuse workdir, conditional
  session) from CLI/API rerun (no task_id: fresh session + fresh workdir).

- Clarify cross-runtime workdir is best-effort: the daemon offers the source
  workdir regardless of runtime (a shared mount may resolve it) and only the
  per-cwd session is runtime-gated.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 16:38:18 +08:00
milymarkovic
983545b34d KAP-867: retry safe Codex initialize timeouts
Merge reviewed changes after fresh approval and green CI.
2026-07-16 16:35:51 +08:00
Naiyuan Qing
c52ebeaa0f MUL-4864 fix(chat): use a single New Chat draft, keyed by session not agent (#5519)
* fix(chat): use a single New Chat draft, keyed by session not agent (MUL-4864)

An uncreated chat kept one draft per agent (`__new__:<agentId>`), so
switching the agent picker mid-compose swapped the draft out from under
the user and left invisible per-agent drafts behind. That slot shape was
never a product decision: it fell out of 34e452776, which keyed the
editor by agent to remount it and refresh the Tiptap placeholder. The
placeholder now syncs live, so the motive is gone — and mobile already
implements the target contract.

An uncreated chat now has ONE draft slot per workspace. `selectedAgentId`
is the send target, not draft ownership. Created sessions keep their own
per-session slots, so conversations stay isolated.

Three connected changes this needs to actually hold:

- Editor identity no longer tracks the agent. Keying it by agent kept the
  draft slot correct but still remounted on switch, dropping whatever the
  100ms draft debounce had not yet persisted — the last thing typed.
- The post-send "scrub the composer?" rule reads `activeSessionId` alone.
  Moving the picker is no longer navigation, so counting it as such left
  a completed send's text in the composer, primed to be sent a second
  time to the agent just picked. Both send chains (the chat tab's
  controller and the floating ChatWindow) now share one exported rule
  rather than two copies of the predicate.
- Legacy `__new__:<agentId>` slots are folded on load. They carry no
  timestamp, so when several exist none can be shown to be newest: adopt
  the persisted `selectedAgentId`'s slot (the draft the workspace would
  have opened with) and drop the rest — those extras are the invisible
  multi-draft state this removes. Text and attachments migrate together
  so a draft can't end up with another agent's files.

Tests cover the acceptance scenarios: agent switch preserves text,
attachments and the live editor instance; sessions stay isolated; the
first send clears the draft and routes to the agent selected at send
time; a failed create keeps the input; and the migration adopts, prunes,
persists, and is idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): file unflushed keystrokes under the draft they were typed in (MUL-4864)

Review blocker: one editor instance serves every chat draft and its
onUpdate is debounced 100ms. Typing in session A and switching to B
inside that window fires the timer with B's draftKey in scope — because
onUpdate always resolves to the latest render's closure — writing A's
document into B's draft. Worse, ContentEditor's dirty guard suppresses
B's incoming sync while those unflushed bytes are live, so B's own draft
never loads and A's context could be sent to B's agent.

This predates the per-agent draft change: on main `editorKey` is
`selectedAgentId`, so any two sessions of the SAME agent already shared
one editor instance and already cross-wrote. Verified by running the new
regression against main's editor identity with the flush removed — it
fails there too. Unifying the New Chat draft widened the same hazard to
cross-agent switches, so it is fixed here rather than left latent.

Fix at the root: a debounced write must land on the key it was typed
under, not wherever the composer now points.

- ContentEditor gains `flushPendingUpdate()`: cancels the armed debounce
  and hands its markdown back rather than firing it, and advances the
  emit watermark so the editor reads clean and the dirty guard stops
  blocking the incoming sync. Distinct from flushPendingOnUnmount — the
  instance is alive, so it reads the live document.
- ChatInput flushes on draftKey change and commits the result to the
  PREVIOUS key. useLayoutEffect, not useEffect: passive effects run
  child-first, so a passive flush would land after ContentEditor's sync
  had already skipped on a dirty editor; a layout effect is part of the
  commit, so no pending timer can fire ahead of it.
- onUpdate and the flush now share one `commitDraft`, so text and
  attachment pruning cannot diverge between the two paths.

Tests exercise the REAL debounce and REAL dirty guard, not an instant
mock onUpdate:

- chat-input-draft-isolation.test.tsx (new): mounts the real
  ContentEditor with only Tiptap's primitive mocked. Pins A/B isolation
  across a mid-debounce switch, that B's draft actually loads, that a
  lazy session create keeps its bytes in the New Chat slot, and that an
  agent switch still preserves the New Chat draft. Three of four fail
  without the flush.
- content-editor.test.tsx: flushPendingUpdate hands back the pending
  markdown, kills the timer, unblocks the next sync, and leaves the
  guard intact when NOT flushed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): keep an in-flight upload bound to the draft it started in (MUL-4864)

Second review blocker. An upload PINS the editor to its source document:
Guard 0 refuses to setContent over an `uploading` node, because wiping it
strands the upload's finalize and the file silently vanishes. So while an
upload runs, the instance still holds A's document even though the user
has navigated to B — and the upload's own completion dispatch fires
onUpdate on that instance, which resolves to the latest render's closure.
Result: A's body and its attachment URL were written into B's draft, B's
own draft was destroyed, and A lost the upload it was waiting for.

Nothing stopped this: useUploadGate gates submit, not session/agent
navigation. Reproduced as a test before fixing — B's "B's own words" was
replaced by A's body + A's CDN URL.

Root cause is the same shape as the debounce bug, one level up: the
editor's writes were keyed off what is SELECTED, when they belong to what
is LOADED. Those are the same key except while an upload pins the
document.

- chat-input models that explicitly with `editorDraftKeyRef` — the draft
  whose document the instance holds. Every editor-driven write (onUpdate,
  the upload's attachment binding) uses it, not `draftKey`.
- The draft switch defers while `hasActiveUploads()`, leaving both the
  document and its writes on the source key, then completes when the gate
  clears. Blocking navigation instead would make the user wait on a
  network round-trip to change tabs.
- The deferred case must force the adopt: ContentEditor's sync effect
  CONSUMED the defaultValue change while Guard 0 was up
  (lastDefaultValueRef advances before the guard), so it never re-runs and
  B's draft would never load. New `adoptContent()` ref method lands it;
  its body is the sync effect's own apply path, extracted so both agree.
- handleSend refuses while loaded !== selected. The upload gate covers
  most of that window but not the sliver between the upload's final
  dispatch and the adopt re-render — React flushes that on a scheduler
  task, so Mod+Enter can land first.

Tests (real ContentEditor, real Guard 0, real debounce, real upload
lifecycle incl. the transaction that publishes the queue flip):
completing upload's markdown stays in the source draft and never reaches
the target; an attachment dropped while pinned binds to the source; the
target draft loads once the guard clears; send is refused mid-divergence.
Each verified to fail without its fix — the send guard initially passed
vacuously (the disabled SubmitButton swallowed the click) and now drives
the real Mod+Enter path.

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>
2026-07-16 15:56:55 +08:00
Bohan Jiang
69744241e8 fix(comment): restore autopilot @mention delegation authority (MUL-4857) (#5512)
* fix(comment): restore autopilot @mention delegation authority (MUL-4857)

A schedule/webhook autopilot run is unattributed by design (no top-of-chain
human originator, MUL-4302). Since MUL-3963 the A2A invoke gate (canInvokeAgent)
keys on that originator, so a mid-run @agent/@squad delegation on an
autopilot-created issue fails closed for the DEFAULT private agent (and
member-scoped public_to agents): the mention renders but no run is enqueued.
The SAME autopilot's first dispatch is admitted via the autopilot creator
(autopilotAdmitInvoke -> canCreatorInvokeAgent), so first-dispatch and mid-run
delegation disagreed.

Align them: when an unattributed agent/system-authored comment on an
autopilot-origin issue reaches computeCommentAgentTriggers with no originator,
fall back to the autopilot creator as the effective invoking user for the gate.
The gate still runs (no unrestricted agent-to-agent bypass); it is authorization
only -- the enqueued task's originator/attribution stays unattributed. Scoped to
autopilot-origin issues so other unattributed chains stay fail-closed.

Adds a DB-backed regression test covering: creator-owns-target admits, a
non-autopilot unattributed run stays denied, and a creator without invoke rights
stays denied.

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

* fix(comment): bind autopilot @mention authority to verified task lineage (MUL-4857)

Address the review's confused-deputy finding on the P0 fix. The first cut keyed
the invoke-gate fallback on issue provenance + an empty originator alone
(invokeAuthorityForAutopilotIssue took only the issue), so any unattributed run
could borrow a stranger autopilot creator's rights merely by commenting on that
autopilot's issue — and the fallback also leaked past explicit @mention into the
plain-comment squad-leader path and system actors.

Rework it so the autopilot-creator authority is granted ONLY when the SPEAKING
task's lineage is verified against this issue:

  - resolve the authority separately (new AutopilotDelegationAuthorityUserID on
    commentTriggerComputeOptions), never by overwriting OriginatorUserID; the
    gate reads it through opts.effectiveInvoker() only when no human originator
    resolved, so attribution stays untouched;
  - resolve from a server-trusted speaking task — X-Task-ID on create/preview,
    comment.source_task_id on edit/reconcile — via autopilotDelegationAuthority,
    which admits only when author == task agent AND task.issue_id == this issue
    AND the issue is autopilot-origin, then keys on the member autopilot creator;
  - do NOT key on autopilot_run_id: in create_issue mode (the reported case) the
    leader task is enqueued through the ordinary issue-assignment path and has no
    autopilot_run_id — the task.issue_id == issue binding is what proves the run
    is part of this autopilot's work while rejecting foreign-issue runs.

Tests: replace the provenance-only regression with lineage-bound coverage —
verified-lineage-admits, creator-without-rights-denied, non-autopilot-denied,
missing-source-task-denied, cross-issue-source-task-denied, author!=task-agent-
denied — plus an end-to-end CreateComment path asserting the private worker is
enqueued and the delegated run stays unattributed. Verified the fallback is
load-bearing (positive + e2e fail with it disabled) and the full internal/handler
package passes. Skill docs (multica-mentioning) updated to the lineage-bound
contract and new helper names.

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

* fix(comment): make autopilot @mention authority consistent across defer/edit (MUL-4857)

Second review round (Elon) surfaced two must-fixes on top of the lineage binding.

1. Busy-target completion reconcile lost the authority. A delegation to a target
   that is already running is deferred to that target's completion reconcile
   (reconcileCommentsOnCompletion). That path recomputed triggers with only the
   (empty) originator, so an unattributed autopilot delegation's follow-up was
   gate-denied again and silently dropped. It now restores the delegation
   authority from comment.source_task_id, so the follow-up fires once the target
   frees up — still unattributed.

2. Edit could borrow the old authoring run's authority, and preview != save. The
   edit preview keyed authority on the current request task while save keyed it on
   the comment's original source_task_id, so an agent editing its old autopilot
   comment from a task on an UNRELATED issue would fail-closed in preview but reuse
   the old autopilot creator's authority on save (cross-issue confused-deputy, and
   a preview/side-effect divergence). Fix: treat source_task_id as the persisted
   per-action authority lineage and re-stamp it on edit to the CURRENT editing
   task, issue-scoped exactly like CreateComment. A cross-issue edit re-stamps it
   to NULL, so preview, save, AND the deferred reconcile all fail closed
   identically. UpdateComment query gains a source_task_id param (sqlc regen).

Also locks the review-accepted behavior that effectiveInvoker() carries the
autopilot-creator authority into the plain assigned-squad-leader wake (a worker's
result comment on the autopilot issue can still wake the private leader).

Tests: reconcile-restores-authority (owns -> one unattributed follow-up; no rights
-> none); edit re-stamp (same-issue keeps authority and triggers; cross-issue
clears source_task_id and fails closed); worker-result wakes private squad leader.
Verified both fixes are load-bearing (each negative control reproduces the exact
regression Elon described), full internal/handler + internal/service packages pass,
gofmt/vet clean. Skill docs (multica-mentioning) updated.

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

* fix(comment): clear stale task lineage on non-author comment edits (MUL-4857)

An admin editing an autopilot Agent's comment previously preserved the
comment's original source_task_id. The immediate save is judged on the
admin's member identity and correctly fails closed, but the deferred
completion-reconcile routes the comment under its original agent author
and resolved the delegation authority from the stale source_task_id,
resurrecting the autopilot creator's invoke authority once the busy
target freed up — an admin (manage rights) could thereby trigger another
owner's private agent (invoke rights).

Now a content edit re-derives lineage from the edit action: only the
agent author editing its own comment re-stamps source_task_id to the
current editing task; every other editor (member/admin, or any
non-author) clears it, so preview, save, and reconcile all fail closed.

Adds a regression covering the admin-edit + busy-target path and syncs
the multica-mentioning skill docs.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:23:56 +08:00
Multica Eve
cf1fc64671 MUL-4824: fail closed on incomplete Claude-style result streams (#5522)
* fix(agent): fail closed on incomplete stream results

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

* fix(agent): tighten empty result fallback

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:22:46 +08:00
Multica Eve
5251a0958d fix(daemon): bound silent OpenCode streams (#5523)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:14:36 +08:00
Jiayuan Zhang
47e9c8add5 fix(web): add macOS Intel downloads to landing page (#5517)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:11:27 +08:00
Naiyuan Qing
6b2097ccbb feat(inbox): archived notifications sub-view (MUL-3736) (#5518)
Adds an "Archived" sub-view to the Inbox, reachable from an entry at the
bottom of the main list, with per-row unarchive. Mirrors chat's archived
sub-view so the two surfaces share one mental model.

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

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

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

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:58:42 +08:00
Naiyuan Qing
849df8bb3b fix(desktop): fade tab separators on hover, correct their weight (MUL-4811) (#5500)
The tab strip's hairlines used --border, which is authored for near-white
surfaces: its other uses sit on --surface-raised (L=1.0) where it gives a
reasonable dL 0.055, but on --app-shell (L=0.964) it collapses to dL 0.019 --
about a quarter of the 0.068-0.080 the rest of the chrome's 1px keylines run
at, so it read as almost invisible. Move both the tab separator and the
pinned-zone divider to --surface-border, which the surrounding chrome (the
active tab's border, the flare arcs, the content card's ring) already uses,
and which is what the system draws for separators on --sidebar -- a surface
whose lightness is identical to --app-shell.

Also fade a hairline out while either tab it divides is hovered, so it no
longer lingers 2px off the hover pill's rounded edge. The hairline sits on a
tab's own left edge, so the pair's other half belongs to the neighbouring
component instance and no ancestor-based variant can reach it; hence the
adjacent-sibling custom variant.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:49:34 +08:00
Rusty Raven
24ea38dcb2 fix(comments): safely bound completion fallback output (#5492)
* fix(comments): cap completion-fallback comment synthesized from raw output (#5455)

On long, tool-heavy runs some runtimes emit the entire raw execution stream
(text deltas + a `tool call` line per tool_use) as the task's final Output.
When the agent left no comment of its own, CompleteTask synthesizes a fallback
comment from that Output and posted it verbatim, dumping 190-264KB raw streams
onto the issue thread (author_type=agent, type=comment, source_task_id=NULL).

Cap the synthesized body: redact then truncate to a head that comfortably fits
a real final message, appending a marker naming the omitted length instead of
the multi-hundred-KB tail. Normal short outputs are unchanged.

* test(comments): pin completion-fallback comment cap (#5455)

Covers passthrough for real short messages, exact-cap boundary, rune-based
(not byte) counting for multibyte content, and the reporter's fingerprint —
a narration head followed by hundreds of `tool call` lines — asserting the
stored body is bounded, the head preserved, and a truncation marker appended.

* fix(comments): replace oversized fallback output with safe notice

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:24:47 +08:00
Bohan Jiang
10391939c0 test(chat): de-flake use-chat-draft-restore reconciliation waitFor (MUL-4856) (#5516)
The reconciliation test drives real react-query fetches and mutations, so
each waitFor gates on a multi-hop async chain (fetch -> effect -> mutate ->
settle -> refetch). The 're-consumes on the next fetch after the consume
failed' case chains the most hops and timed out under the default 1s waitFor
budget when the full Vitest suite saturated the CI runner (whole run 405s,
import 364s), failing with 'expected 2 times, but got 1 times' on the main
merge of an unrelated Go-only PR.

Widen this file's async-utility and per-test timeouts so whole-suite
contention can't starve the chain. Test-only; defaults stay in force
elsewhere (Vitest isolates config per file).

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:07:52 +08:00
Multica Eve
2d9cf045d0 MUL-4861: align runtime CLI update permissions (#5514)
* fix(runtimes): align CLI update permissions

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

* fix(runtimes): address update review nits

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 13:54:01 +08:00
Multica Eve
f0d7a444ac MUL-4860 fix(acp/kiro): GPT-5.6 Sol completion — object rawOutput + positive-proof guard (follow-up to #5511) (#5515)
* fix(kiro): require positive proof for -32603 completion rescue (MUL-4860, #5509)

Addresses the #5511 review (Elon):

Must-fix 1 — a missing result is not proof of success. A mid-command
crash / internal session failure produces the same 'tool use, no result,
-32603' shape as a genuine completion, so the previous 'saw the use but
never saw a result' rescue could mark an unfinished task completed. The
guard now rescues only on POSITIVE proof: a terminal ToolResult with
status=='completed' for a finishing tool (goal_complete / comment-add).

Must-fix 2 — the previous sticky booleans only ever wrote true, so a
later failed delivery could not override an earlier success, and call
ordering was ignored. Completion state is now the status of the MOST
RECENT finishing-tool result (keyed per CallID): 'progress completed →
final failed → -32603' stays failed; 'first failed → retry completed →
-32603' is a real completion.

Comment-add is still recognized by its command payload regardless of the
tool's display title (the #5509 core ask), so a completed comment-add
carrying a non-terminal title is preserved through the close handshake.

Tests: result-less running tool stays failed (both goal_complete and
comment-add), completed non-terminal-title comment-add is preserved,
failed-final-overrides-earlier-success, and completed-retry-after-failure.

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

* fix(acp): don't drop completed tool_call_update when rawOutput is an object (MUL-4860, #5509)

Captured a live kiro-cli 2.12.3 + gpt-5.6-sol ACP trace and found the
true root cause: the completed tool_call_update sends rawOutput as an
OBJECT ({"items":[{"Json":{...}}]}), but handleToolCallUpdate typed
rawOutput/output as Go strings. json.Unmarshal then failed with
'cannot unmarshal object into Go struct field .rawOutput of type string'
and the handler returned early — silently dropping the ENTIRE update,
status:"completed" included. So no completion signal ever reached the
kiro completion-preservation guard, and the durable-but-closed task was
marked failed. This also explains the issue's 'shell tool recorded as
running': the tool_call title is literally 'Running: <cmd>' (kind
execute), which normalizes to 'running', not 'terminal'.

Fix: type rawOutput/output as json.RawMessage and render them via a new
acpRawText helper that accepts both a JSON string and a structured
value. This is a shared ACP-layer fix (hermes/kimi/kiro).

Combined with the earlier payload-based comment-add recognition and the
positive-proof completion guard, the completed result now flows through
and the -32603 close handshake correctly preserves completion.

Adds TestKiroBackendPreservesCompletionOnRealGPT56SolFrames (the exact
captured wire shape end-to-end) and TestACPRawText. Verified the
end-to-end test fails (status=failed) when rawOutput is typed as string
and passes after the fix.

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

* fix(kiro): remove duplicate stale-session branch that faked completion (MUL-4860, #5509)

Addresses Elon's 3rd-round review of #5515. The -32603 guard had two
consecutive, identically-conditioned else-if branches for a stale resumed
session (opts.ResumeSessionID != "" && isACPSessionNotFound(err)). The
first wrongly forced finalStatus="completed" and kept the stale
SessionID; the second — the correct handler that clears SessionID so the
daemon's fresh-session retry fires — was unreachable. So a recovery whose
resumed session was gone at session/prompt time was reported as a fake
success AND skipped the retry.

Remove the bogus first branch, keeping the positive-proof completion
branch and the original stale-session (SessionID="") branch.

Adds TestKiroBackendClearsSessionIDWhenPromptSessionNotFound (the prompt
path; existing coverage only exercised session/set_model). Verified it
returns completed+ses_stale on the pre-fix code and failed+empty
SessionID after. Also gofmt on the touched files.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 13:36:45 +08:00
Bohan Jiang
4fcb27a729 fix(issues): attach labels in the create transaction (MUL-4832) (#5510)
* fix(issues): attach labels in the create transaction (MUL-4832)

Labels chosen at issue creation were attached in a second, non-atomic
round-trip after the issue was already committed (web modal looped
attachLabelToIssue per label; the create endpoint took no labels). A
partial failure of that follow-up left the issue committed but
mis-categorized, surfaced only as a toast.

Carry label_ids through the create request instead: the service
validates them (workspace + resource_type='issue') and attaches them
inside the same transaction as the issue insert, so the issue and its
labels commit together or not at all. An unknown or wrong-scope label
id now fails the whole create with 400 rather than being silently
dropped. Duplicate ids are idempotent (dedupe + ON CONFLICT DO NOTHING).

- server: IssueCreateParams.LabelIDs + ErrIssueLabelNotFound; validate
  and attach in IssueService.Create; handler parses label_ids and maps
  the error to 400.
- web: create-issue modal forwards label_ids and drops the post-create
  attach loop and its dead toast key.
- tests: handler coverage for atomic attach, stale-id 400 (no issue
  left behind), duplicate-id idempotency, wrong-scope rejection; web
  test asserts label_ids is forwarded.

Scope is deliberately labels-only. The contributor PR #5475 also
auto-parsed an acceptance_criteria field from description text; that
introduces a new user-facing data contract with no defined edit/display
rules and is left out for a separate product decision.

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

* fix(issues): echo created labels + guard old-backend compat (MUL-4832)

Addresses review of #5510.

1. Create response + issue:created event now carry the labels attached in
   the create transaction. IssueService.Create returns the authoritative
   label snapshot; the handler sets it on both the HTTP response and the
   issue:created broadcast payload. Without this, online members other than
   the creator saw the new issue unlabeled indefinitely (staleTime: Infinity,
   no invalidation) because this PR removed the old post-create
   issue_labels:changed broadcast.

2. New web + old backend compatibility. During the rolling deploy window the
   web app can run ahead of the backend (web auto-deploys on merge, backend
   deploys manually). An older backend ignores label_ids and returns an issue
   with no labels field. The create modal now falls back to the legacy
   per-label attach only when the response omits labels, so labels aren't
   silently dropped; when labels is present the atomic path already ran and
   no fallback fires. The backend always returns an explicit labels array
   (empty when none) as the detection signal.

Tests: handler issue:created-carries-labels + response-carries-labels +
response-always-includes-labels; web modal no-fallback (new backend) and
fallback (old backend); ws-updaters keeps the label snapshot in list cache.

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

* fix(issues): validate create response through a schema (MUL-4832)

Addresses review of #5510 (must-fix 3).

The create modal keys its label-attach compatibility fallback off
`issue.labels` being absent (older backend) vs a validated Label[]
(current backend). But api.createIssue cast the raw JSON straight to
Issue, so a malformed labels value (null, an object, a garbage array)
would be !== undefined and wrongly suppress the fallback, caching a bad
shape — violating the repo's API Compatibility rule.

Parse the create response through CreateIssueResponseSchema:
- labels absent  -> undefined (older-backend signal; fallback runs)
- labels valid   -> Label[] (fully validated elements, not z.unknown())
- labels malformed -> undefined via .catch (safe: never masquerades as
  handled; worst case a redundant re-attach, never a silent drop)
A whole-body parse failure degrades to EMPTY_ISSUE (never throws into
React), matching the existing parseWithFallback contract.

Tests: schema.test.ts covers absent / valid / null / wrong-element-shape
labels and the empty-issue degrade.

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

* fix(issues): reject a malformed create response instead of faking success (MUL-4832)

Follow-up to review of #5510. The prior fix degraded a schema-failed
create response to EMPTY_ISSUE, which React Query treats as a successful
mutation: the empty issue is written into the list cache, a blank
"created" toast shows, the "View issue" link points at an empty id, and
with labels the fallback attach runs against an empty issue id.

A create that returns an unusable body is a failed mutation, not a
safe-empty read. Fall back to null and reject: mutateAsync is already
inside the create modal's try/catch, so a controlled rejection preserves
the draft and shows the failure toast, and onSettled still refreshes the
list so a genuinely-created issue can still surface.

- CreateIssueResponseSchema tightens id to non-empty; an id-less body
  routes to the same reject path.
- createIssue throws on parse failure (empty-message Error so the modal
  renders its localized "failed to create" toast; parseWithFallback
  already logged the schema issues + raw body).
- Dropped the EMPTY_ISSUE fallback constant.
- Tests: whole-body malformed and empty-id now assert createIssue
  rejects; only-labels-malformed still returns the real issue with
  labels undefined.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 13:31:06 +08:00
Bohan Jiang
b13b6d06bc fix(daemon): writable per-task HOME for Linux Codex sandbox (MUL-4856) (#5508)
Give Linux codex tasks a writable per-task HOME (+ XDG/npm_config_cache) so npm/Prisma stop hitting EROFS on the read-only sandbox home. Gated to Linux; macOS/Windows and non-codex providers unaffected.

Note: does NOT resolve the worktree git-metadata read-only problem tracked in multica-ai/multica#2925 — Codex workspace-write resolves the worktree .git pointer and force-protects the real gitdir read-only even inside writable_roots, so that needs a separate Codex metadata-write permission path.
2026-07-16 13:01:43 +08:00
BrianBoyCN
a40481805a fix(storage): encode non-ASCII filenames in Content-Disposition
Preserve non-ASCII attachment filenames with RFC 5987 while retaining a safe ASCII fallback.
2026-07-16 12:47:14 +08:00
Multica Eve
dd692058d7 fix(kiro): preserve completed status on GPT-5.6 Sol -32603 close handshake (#5509) (#5511)
The -32603 completion-preservation guard coupled to the Claude/Kiro ACP
event shape. GPT-5.6 Sol leaves the finishing tool (goal_complete /
'multica issue comment add') parked at 'running' and titles the shell
tool with a name that does not normalize to 'terminal', so:

- isKiroIssueCommentAddTool's msg.Tool=='terminal' gate dropped the tool
  use entirely, and
- with no completed/failed ToolCallUpdate, no ToolResult is emitted, so
  the saw*Completed flags never flipped.

Completed tasks were therefore reversed to failed with
agent_error.provider_server_error ~17s after finishing their work.

Fix:
- Recognize comment-add by its command payload, independent of the
  normalized tool title.
- Track each finishing tool along use / result / completed axes and
  preserve completed when the -32603 close handshake follows a tool use
  that never produced a terminal result. A genuinely failed ToolResult
  still trips saw*Result and keeps the task failed.

Adds regression fixtures for the running-tool path (comment-add and
goal_complete) plus a failed-result safety case.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 12:33:15 +08:00
Xzero
650f933367 fix(desktop): rename Linux executable to multica-desktop (#5483)
The Electron desktop app's Linux packages (deb/rpm/AppImage) installed
their launcher binary as `multica`, colliding with the Go CLI binary
of the same name. Whichever won PATH resolution silently shadowed the
other; hitting the Electron binary from a CLI invocation exits 0 with
empty stdout (its own Chromium flags eat args like `--output json`),
which reads as a healthy but empty CLI response instead of "wrong
binary".

Rename the packaged executable to `multica-desktop` so `multica` on
PATH is unambiguously the Go CLI. StartupWMClass is unaffected since
it derives from app.getName(), not executableName.

Fixes #5481

Co-authored-by: Product Engineering Specialist <support@weekome.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 12:15:41 +08:00
Suoriks
c5e004700f chore(deps): override vulnerable transitive packages (#5493)
Co-authored-by: chernov.e <evgeniy.chernov@medfarmu.ru>
2026-07-16 12:15:18 +08:00
Christian Rodriguez
7b89c5104c feat(helm): add per-component affinity and tolerations (#5490)
Allow scheduling backend, frontend, and postgres pods onto tainted or
labeled nodes independently via values.yaml.
2026-07-16 11:59:21 +08:00
Multica Eve
d74aed1e07 MUL-4302 Remove legacy attribution invariant exemption (#5504)
* fix(attribution): remove legacy invariant exemption

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

* test(attribution): enforce strict legacy row rejection

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 11:21:20 +08:00
Naiyuan Qing
f9f41e991a fix(ui): drop native title that stacked on custom hover surfaces (MUL-4836) (#5502)
ActorAvatarBase hardcoded `title={name}` on the avatar div, so every one of
the 155 avatar renders emitted a browser tooltip. On the 34 call sites that
also pass `enableHoverCard`, the native title sat on the inner div while the
PreviewCard trigger sat on an ancestor span — different elements, so neither
suppressed the other and both surfaces showed at once.

Same class of bug in two more places: AgentStatusDot added a third `title`
inside the same trigger on the 16 sites that also pass `showStatusDot`, and
RepoUrlText rendered a `title` carrying the exact string its Tooltip already
showed.

Phase 1 scope only — remove the three duplicate titles. PreviewCard trigger
semantics, `profileLink`, ancestor detection, the 159 call sites, and the
remaining native-title cleanup are deliberately untouched.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 11:13:31 +08:00
machete
59ca4641f7 SNDBX-491 refresh autopilot project binding at dispatch (#5499)
Co-authored-by: Asere AI <asere.ai@icloud.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 11:01:34 +08:00
CAVIN
310634064f fix(transcript): explain unavailable Antigravity live events (#5182)
* docs: design Antigravity live transcript empty state

* docs: plan Antigravity live transcript empty state

* fix(transcript): explain unavailable Antigravity live events

* chore: remove agent planning artifacts

---------

Co-authored-by: CAVIN <zzz163519@users.noreply.github.com>
2026-07-16 10:28:23 +08:00
Naiyuan Qing
3b08456264 feat(views): block draft-fixing actions while attachments upload (#5465)
Submitting a composer mid-upload silently lost the file: the editor holds a
`blob:` placeholder that gets stripped during serialization, and the
attachment id does not exist yet, so the send succeeded without the file.
Chat and Quick Create gated this; manual issue create, Feedback's button,
comments, replies and comment edit did not.

Gate every action that FIXES a draft — Send/Create/Save, Cmd/Ctrl+Enter,
Enter on the title, and the manual/agent mode switches — behind one source
of truth: the editor document, which IS the upload queue. ContentEditor
publishes queue transitions via `onUploadingChange` (a transaction listener,
not `onUpdate` — that path is debounced and skips no-change emissions, and a
failed upload leaves byte-identical markdown, so the un-gate would never
fire). `useUploadGate` pairs that render state with an `isBlocked()` re-read
at submit time, since the shortcut paths never consult the button.

The publisher emits its current answer on subscribe rather than only on
flips: hosts outlive editor instances (comment edit remounts on cancel,
chat swaps by key on agent switch), and an editor torn down mid-upload would
otherwise leave submit wedged shut with no pending node left to reopen it.

Also fixes the failure toast that never fired: `uploadWithToast` only
reported errors if a caller passed `onError`, and none did, so failed uploads
removed their placeholder and vanished unexplained. `useEditorUpload` now
supplies it once for every composer.

Per MUL-4808: drops Quick Create's upload-time lock on the attach button
(files can queue), and leaves description autosave ungated by design.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 09:56:24 +08:00