1327 Commits

Author SHA1 Message Date
Multica Eve
bdd90dd9ca docs(changelog): add v0.4.16 release entry (2026-07-31) (MUL-5596) (#6242)
* docs(changelog): add v0.4.16 release entry (MUL-5596)

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

* docs(changelog): rewrite v0.4.16 entries for clarity (MUL-5596)

Each line now states the user-visible result instead of the mechanism:
name the agents whose replies were truncated, say what the Linux Codex
change actually gives you, drop WebSocket/migration internals, and use
the same word for link chips across all four languages.

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: Bohan-J <bohan@devv.ai>
2026-07-31 17:48:15 +08:00
Jiayuan Zhang
28b6105edc fix(subscribers): notify the human an agent files sub-issues for (MUL-5483) (#6209)
When an agent created a sub-issue while working on a human's behalf, that human
received no notifications for it at all. issue_subscriber modelled ACTOR
identity, so an agent-created, agent-assigned issue had a full subscriber list
and zero members to deliver to. The platform already knew who the work was for
(agent_task_queue.originator_user_id, MUL-4302); notification never asked.

- attribution.DelegatedSubscriber: one shared rule over the same origin
  waterfall ClassifyDirect uses. agent_create subscribes the originator as
  'delegated'; quick_create keeps the direct 'creator' tier; autopilot and
  degraded attribution subscribe nobody.
- Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus
  failures and mentions. Routine churn is suppressed, and the parent bubble
  cannot re-deliver what the tier dropped.
- Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later
  rule passes, and opt_out_scope distinguishes "this issue" from "this subtree"
  so a narrow opt-out no longer silently suppresses future children.
- Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly
  against an older backend (Go drops unknown fields); an unknown route 404s,
  which the UI now surfaces with a distinct message.
- Eligibility and the write share one statement under a (workspace, user)
  advisory lock that subtree unsubscribe and member revoke also take, closing
  the check-then-insert races. Revoke additionally clears the departing
  member's subscriptions in the same tx.
- UI explains a delegated subscription and offers both unsubscribe scopes.

Migrations 249/250 add the delegated reason, the opt-out tombstone, and the
opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does
not scan issue_subscriber under an exclusive lock.

Reviewed across eight rounds; an earlier write-time subtree roll-up was built
and then removed in full once it proved unfixable without serializing every
topology mutation. The parent's own status transition already carries that
signal.

Closes MUL-5483.
2026-07-31 16:52:17 +08:00
Jiayuan Zhang
d4ae220cc1 feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) (#6141)
* feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499)

A project has no `MUL-123`-style identifier — only a UUID and a free-text
title — so there is nothing for the bare-identifier autolink preprocessor to
detect, and the link copied out of the app is how people actually reference
one. It rendered as a raw URL.

RichLink now unfurls a bare in-app entity URL into the same chip the
`mention://project/<uuid>` form already produces (issue URLs go through the
same path for symmetry). Render-only: stored markdown is untouched, and the
editable Tiptap path is deliberately unaffected.

Three guards, each load-bearing: the link must be bare (an authored label is
never discarded), same-workspace (a chip resolves its title in the current
workspace only), and address exactly one entity page by UUID with no query or
fragment.

Also:
- mobile: tapping a `mention://project/` link navigated nowhere despite the
  `project/[id]` route existing — it now pushes the project detail.
- agents had no documented way to emit a clickable project reference: add the
  link form to the runtime brief's Mentions section and to the projects skill,
  and record in the mentioning skill why `project` sits outside `MentionRe`
  (render-only, enqueues nothing).

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

* fix(rich-content): unfurl issue URLs in identifier form

The unfurl required a UUID id, on the stated grounds that "every link the
app itself produces carries a UUID". That holds for a project but not for
an issue: `copyLink` and `openInNewTab` both build
`paths.issueDetail(issueIdentifier || issueId)`, and the issue route
rewrites a UUID URL back to the identifier — so `MUL-123` is the shape a
user actually copies, out of the app or out of the address bar. The issue
half of the feature could not fire on the links people paste, while bare
`MUL-123` prose did become a chip: the fuller reference lost to the
shorter one.

`parseWorkspaceEntityLink` now accepts an issue identifier as well as a
UUID. A project still requires a UUID — it has no shorthand, so an
identifier-shaped id under /projects/ addresses nothing.

An identifier needs a lookup, which means it can miss, and the miss has to
differ by entry point. `AutolinkedIssueMentionLink` degraded to plain text,
which is right for autolinked prose and wrong for a URL: the author wrote a
link, and an issue this workspace cannot see must not cost them the only
pointer to it. The fallback is now a prop — plain text for the autolink
path, the original anchor for a URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): stop drawing link chrome over mention chips

A mention chip already carries its own affordance — border, icon, hover
background — so the generic `.rich-text-editor a` color and underline draw a
second, competing one straight through the card. `.issue-mention` reset it;
`.project-mention` never did, so project chips shipped with a brand-coloured
underline through them. The rule belongs to the chip shape rather than to one
entity, so both selectors now share it and a future chip is one line.

The hover card had the same gap: it skipped `.issue-mention` only, so hovering
a project chip opened a URL card offering to copy `/{slug}/projects/{uuid}` —
an in-app path, not the shareable link that wording implies.

Both are pre-existing, but a bare project URL now renders as a chip, so what
used to surface on hand-written mentions alone shows up on ordinary pasted
links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(rich-content): decide in-app by resolving the URL, not by its prefix

`href.startsWith("/")` was standing in for "this deployment". It is not: a
browser reads `//other.example/x` and `/\other.example/x` as another host and
goes there, and both start with a slash. The parser skipped the origin check
for exactly the hrefs that most needed it.

Nothing shipped from this: an unfurled chip links to
`paths.projectDetail(uuid)`, so the href it was parsed from is discarded and a
misparse could not send anyone anywhere. The prefix test was still the wrong
instrument. Adding `&& !startsWith("//")` would have looked like a fix while
leaving the backslash spelling through — the gap is the technique, not the
case, so this resolves the href against the app origin with `URL` and compares
`origin`, which is one comparison for every spelling and for the schemes
(`javascript:`, `data:`) whose opaque origin can never match.

Relative and absolute now take the same path, so the slugless legacy form
parses identically whether or not it carries the origin — previously the
absolute spelling was rejected by a reserved-slug test meant for workspace
slugs, and the two disagreed.

`openLink` still tests the prefix, and its result IS navigated. That is a live
issue, older than this feature and wider than it; it needs its own change
rather than a quiet ride here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skills): state what mobile actually does with a project mention

The projects skill told agents a `mention://project/<uuid>` link "renders as a
navigable project chip on web, desktop, and mobile", and that a pasted project
URL is unfurled into that same chip by "the reader's client". Neither holds on
mobile: `apps/mobile/lib/markdown/markdown.tsx` renders the default enriched
link and only routes the tap, and a bare URL still goes to `Linking.openURL`,
which leaves the app.

These files enter agent context and read as product contract, so an agent
choosing between a mention link and a pasted URL was choosing on false
information — and the URL is the option that strands a mobile reader in a
browser. Both skills and both source maps now say chip on web/desktop, ordinary
link that opens the project on tap on mobile, and unfurling as web/desktop only.

The projects skill also now states the preference outright rather than
presenting the two forms as equivalent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(views): give a project mention the component an issue mention has

`IssueMentionCard` owns "chip inside a link" for issues; the project equivalent
lived inline in the readonly renderer, so nothing named the pairing and nothing
held the rules that come with being a link.

That cost was not hypothetical. Both gaps fixed a commit ago landed on project
mentions alone: `.project-mention` never got the CSS rule cancelling generic
link chrome, and the hover card never learned to skip it. Each was written for
`.issue-mention` at the component that owns it, and project had no such place
for the second half to be written. `ProjectMentionCard` is that place.

No behaviour change: same anchor, same href, same hover affordance, same
accessibility contract that project-mention-a11y.test.tsx pins. The "open in
new tab" preference stays out — it is scoped to issue links, and inheriting it
by symmetry would be inventing product.

Also drops `not-prose` from both cards. It has no definition anywhere in the
repo — Tailwind's typography plugin is not installed, and the class does not
appear in built CSS — so it read as protection that was not there.

The editor's `MentionView` keeps its hand-rolled anchors: it needs a
modifier-click intent hook `AppLink` does not expose, and it does the same for
issues, so the two stay symmetric there too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:18:13 +08:00
Multica Eve
e4b6f7a31b MUL-5581: add Qoder CN CLI runtime (#6232)
* feat(agent): add Qoder CN CLI runtime

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

* fix(agent): address Qoder CN review nits

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

* fix(agent): defer Qoder CN version gate

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-31 16:01:53 +08:00
Bohan Jiang
98766cc06a feat(daemon): remove the Linux Codex per-task HOME; default Linux to danger-full-access (MUL-5578) (#6233)
* feat(daemon): default Linux Codex to danger-full-access on the real HOME (MUL-5578)

Linux Codex tasks ran under the `workspace-write` Landlock sandbox with a
generated per-task HOME: the daemon rewrote HOME/XDG_*/npm_config_cache into
`<envRoot>/home`, symlinked a hand-maintained allowlist of seven credential
paths back into it, and granted that directory as a `writable_roots` entry.

That mechanism could not converge. Any host CLI outside the allowlist (aws,
kubectl, gcloud, glab, rclone, cargo, …) started every task as if unconfigured
even though it works in the daemon user's shell, and three open issues asked to
extend it in three incompatible directions (#5636, #5573, #3867). The
containment it bought was also narrower than it looked: workspace-write
restricts writes only — reads and network were already unrestricted, and `.ssh`
was seeded into the task home — so credentials under the real HOME were
readable and exfiltratable regardless.

Linux now runs `danger-full-access` on the daemon user's real HOME and inherited
XDG environment, matching macOS and Windows. The task filesystem boundary is the
boundary the daemon itself runs inside (VM, container, or dedicated Unix user),
which is what the other providers already assumed — Claude Code runs with
`--permission-mode bypassPermissions` today.

This also removes the split-brain the mechanism could enter: the task-HOME
decision read the platform default only, so a `-c sandbox_mode=danger-full-access`
override (which passes arg filtering and wins over config.toml) left a task
running unsandboxed while the daemon still redirected HOME and emitted
writable_roots for a sandbox that was not in effect. With no HOME rewrite there
is only one environment contract left to disagree about.

Removed: prepareTaskHome, prepareCodexSandboxHome, TaskHomeEnv, Env.TaskHome,
both seed allowlists, and the now-unfed WritableRoots plumbing through
codexSandboxPolicy / CodexHomeOptions. Env roots created by older daemons keep
working; their leftover `home/` directory is simply ignored and reclaimed with
the env root.

Task-scoped CODEX_HOME is untouched — it is managed Codex state, not the Unix
HOME. macOS, Windows, and non-Codex providers are unchanged.

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

* docs: document the agent execution security model (MUL-5578)

The docs had no page describing what a task can reach on the machine that runs
it — the sandbox posture was only discoverable from source. Adds one, stating
plainly that tasks run with the full permissions of the daemon user and that
isolation must come from a dedicated Unix user, container, or VM.

Also separates what Multica genuinely isolates (per-task workdir, task-scoped
CODEX_HOME, agent+task-bound API tokens) from what is not a boundary (the coding
tool's own sandbox and approval settings), and links it from step 5 of the
self-host quickstart, where the daemon is first installed.

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

* fix(daemon): address review on the Linux full-access default (MUL-5578)

Docs, both must-fix:

- The Chinese page rendered the product concept as `Agent`; the repo glossary
  in developers/conventions.zh.mdx mandates 智能体. Fixed all nine occurrences.
  ja/ko already used エージェント / 에이전트 and needed no change.
- The security page asserted without qualification that every task runs with
  the daemon user's full permissions and that the Codex filesystem sandbox is
  always off. codexSandboxPolicyForWindows still keeps workspace-write when a
  user explicitly opts into a native windows.sandbox, so an authoritative
  security page contradicted the code. It now states that Multica makes no
  filesystem-sandbox guarantee, names the Windows opt-in as the one current
  exception, and says which combinations sandbox anything is a compatibility
  detail that moves with tool versions. All four locales updated, plus the
  historical callout which now says Linux matches the macOS/Windows *default*.

Both stale comments from the non-blocking notes:

- prepareCodexHome no longer claims to assume workspace-write + network_access;
  it pins GOOS=linux, which now resolves to danger-full-access.
- ensureCodexSandboxConfig's warn-level logging is no longer described as a
  macOS-only fallback; it fires for every danger-full-access resolution.

Adds TestCodexTaskShellEnvInheritsRealHome at the daemon env-assembly layer:
HOME and the XDG base dirs must reach a Codex task's shell tools from the
inherited daemon environment. Verified it fails when that pass-through breaks.
It guards the pass-through, not runTask's decision not to inject a HOME of its
own — that decision is inline in runTask and has no unit seam.

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-31 15:42:06 +08:00
Multica Eve
cac453fe01 MUL-5505: fix(selfhost): one source of truth for the backend host port (#6168)
* fix(selfhost): read the published host port back from Compose (MUL-5505)

`make selfhost` polled the wrong port when the backend host port was passed
through the environment. Make and Compose disagree on variable precedence: an
assignment in an included makefile (the Makefile `include`s .env) overrides a
real environment variable, while for Compose the environment overrides .env.
Because .env.example ships BACKEND_PORT uncommented, .env always pinned the
recipe's view of the port, so `BACKEND_PORT=9000 make selfhost` published the
stack on 9000 while the health check hammered 8080 for 60s and then reported
"Services are still starting" on a perfectly healthy install. The success
message printed the same wrong backend URL. FRONTEND_PORT had the same
inversion, affecting the printed frontend URL.

Stop re-deriving the host port in the recipe and ask Compose, which is the only
authority on what it published. The wait-and-report block (three port
references per target, duplicated across selfhost and selfhost-build) moves into
scripts/selfhost-wait.sh, which resolves both host ports via `docker compose
port` and falls back to the compose default chain when the stack is not up.

Also fix the input contract: `PORT` was the only port variable documented in
SELF_HOSTING_ADVANCED.md, yet compose read only BACKEND_PORT, so setting the
documented variable was silently ignored. The backend host port mapping now
falls back to `${PORT:-8080}`, matching Makefile and scripts/local-env.sh, and
the docs describe both variables as host ports.

Container-internal ports (8080/3000), the global PORT derivation used by local
dev, and the 127.0.0.1 bindings are unchanged; the default self-host experience
is byte-identical.

Fixes #6145

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

* fix(selfhost): make PORT the real backend port and test the whole chain

Addresses review feedback on #6168.

The first round's root-cause claim was wrong. It said `BACKEND_PORT=9100 make
selfhost` published on 9100 while the health check probed 8080. It does not:
when make drives Compose, Compose inherits make's exported values, so both
sides agree on 8080 and the override is silently dropped instead. Re-measured
through the real recipe, the genuine disagreements were:

  - `.env` setting PORT with no BACKEND_PORT: probe followed PORT, Compose
    published ${BACKEND_PORT:-8080} — 9100 vs 8080.
  - `make selfhost PORT=8080` over a .env with BACKEND_PORT=9100: probe 8080,
    Compose published 9100.

Both are the "wrong port variable" defect the GitHub issue names, and reading
the port back from Compose fixes both. The comments and PR text that blamed
make/Compose precedence are corrected.

The PORT fallback added to docker-compose.selfhost.yml was also unreachable:
.env.example shipped BACKEND_PORT=8080 uncommented, so the fallback never
engaged and `SELF_HOSTING_AI.md`'s "edit PORT and FRONTEND_PORT" instruction did
nothing. Pick one contract and apply it everywhere: PORT is the backend port to
edit, BACKEND_PORT/API_PORT/SERVER_PORT are optional aliases that override it.
That is already what Makefile, scripts/local-env.sh, scripts/install.sh and
install.ps1 do; compose and the web dev fallback were the outliers.

  - .env.example ships BACKEND_PORT commented out, like its sibling aliases, so
    editing PORT works out of the box.
  - apps/web/config/runtime-urls.ts walks the same alias chain instead of
    reading BACKEND_PORT alone, so `pnpm dev` follows an edited PORT.
  - SELF_HOSTING_AI.md and SELF_HOSTING_ADVANCED.md state the contract.

Configuration that cannot take effect is now reported instead of ignored.
scripts/selfhost-preflight.sh warns when an alias in the env file overrides an
edited PORT, and when a port from the shell environment is overridden by the env
file. The Makefile captures the pristine origins before its include, because
afterwards there is no way to tell that `PORT=9000 make selfhost` was asked for.

Tests now cross environment -> make -> compose -> report for real. The docker
stub is no longer told what to publish: on `up` it asks the real
`docker compose config` to interpolate the compose file with the environment the
recipe actually handed it, records that, and answers `port` from the recording.
Verified failing on the old code — with the old recipe and compose file the
suite reports published 8080 against probe 9100, the exact defect.

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

* ci: gate the self-host test on scripts/selfhost-preflight.sh too

The new preflight script was missing from the frontend path filter, so a
change to it alone would skip the test that covers it.

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

* fix(selfhost): honour the full backend port alias chain in Compose

Addresses the second review round on #6168.

The contract this PR documents is

  BACKEND_PORT -> API_PORT -> SERVER_PORT -> PORT -> 8080

and Makefile, scripts/local-env.sh, scripts/install.sh, scripts/install.ps1 and
apps/web/config/runtime-urls.ts all implement it. docker-compose.selfhost.yml
implemented only BACKEND_PORT -> PORT -> 8080, so `API_PORT=9100` or
`SERVER_PORT=9200` in .env still published 8080.

That is not only a direct-Compose concern. scripts/install.sh:407 derives its
health-check port from the full chain and then runs this compose file, so an
alias it honours but Compose ignores puts the probe on 9100 while the stack
listens on 8080 — the original #6145 defect, reached through the installer.

  - docker-compose.selfhost.yml publishes the full nested chain, verified to
    keep the same precedence and the same 8080 default.
  - scripts/selfhost-wait.sh's fallback matches, so the degraded path agrees
    with what Compose would have published.
  - The Makefile captures the pristine origins of API_PORT and SERVER_PORT too,
    and the preflight reports them, so no alias can be silently overridden by
    the env file while the others are reported.

Tests pin the chain on the boundaries a recipe-level test cannot see, because
Makefile normalises all four aliases into PORT before any recipe runs: a matrix
over the direct Compose path asserts each alias moves the published port with
the right precedence, and every case additionally asserts that
scripts/install.sh's resolver returns the same value Compose published. That
invariant is the one that actually protects the installer.

Verified failing without the fix: with the two-level chain restored the suite
reports `expected 9200 / compose published 8080 / installer resolved 9200`, and
with the alias warnings narrowed it reports the missing API_PORT notice.

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

* fix(selfhost): resolve one winner before reporting ignored port config

Addresses the third review round on #6168.

The preflight walked each alias independently, so it made a separate "wins"
claim per alias. With

  PORT=9000
  BACKEND_PORT=8000
  API_PORT=7000
  SERVER_PORT=6000

in .env it announced that BACKEND_PORT, API_PORT and SERVER_PORT each won, when
only BACKEND_PORT=8000 can. Two of the three notices were simply false.

It also compared only same-named shell and file variables, so shadowing across
aliases stayed silent: .env with PORT=8000 and BACKEND_PORT=8000 plus a shell
API_PORT=7000 produced no output at all, even though API_PORT was discarded.

Resolve the winner along the documented chain first — within a variable the env
file beats the environment, then BACKEND_PORT beats API_PORT beats SERVER_PORT
beats PORT — and report only the inputs that winner actually shadows. Output now
names one winning input and lists the rest, whichever variable or source they
came from. An input carrying the winning value is not reported: it is redundant
but the user still gets the port they asked for, so there is nothing to fix.

Tests cover both cases the old logic got wrong: every alias set at once must
yield exactly one winner claim and list the others as unused, and an env-file
alias beating a lower-priority shell alias must be reported. Also asserted that
a redundant same-value alias and a default configuration both stay silent.

Measured against the previous implementation, phrasing aside: with all four
values set it made 3 winner claims where there is now 1, and on the cross-alias
case it printed nothing where API_PORT is now reported.

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

* fix(selfhost): track env-file existence so empty port assignments resolve right

Addresses the fourth review round on #6168.

The Makefile passed only values to the preflight, so the script inferred "the
env file does not set this" from an empty string. An explicit empty assignment is
a different input from an absent one: make lets `BACKEND_PORT=` in the env file
override `BACKEND_PORT=9000` from the environment, and the variable then drops out
of the alias chain instead of setting a port.

With .env holding PORT=8080 and BACKEND_PORT=, invoked as
`BACKEND_PORT=9000 make selfhost`, the stack published 8080 and the health check
probed 8080 — correct — while the preflight announced

  the backend host port resolves to 9000 from BACKEND_PORT (environment)
  Set but unused: PORT=8080 (.env)

naming the ignored value as the winner and the winning value as unused. A report
that contradicts the startup is worse than no report. FRONTEND_PORT= had the
matching hole: it shadowed the environment, Compose fell back to 3000, and the
preflight said nothing.

The Makefile now captures ENV_FILE_<VAR>_IS_SET from $(origin) alongside the
value, for all five port variables via one $(foreach) instead of hand-written
lines. The preflight treats a variable the file defines as taking the file's
value even when empty, so it shadows the environment, and an empty value never
wins — resolution continues down the chain and falls back to 8080. Inputs the
winner shadows are still listed, including ones shadowed by an empty assignment.
The frontend check mirrors this and now names the port that actually resolved.

Recipe-level tests cover an empty alias over a shell value, every link of the
chain emptied at once, and the frontend equivalent — each asserting the reported
winner, the Compose published port and the probed port all agree. Against the
previous implementation the first case fails with `reported: 9000 / actual: 8080`.

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

* fix(selfhost): installers read the published port; drop the second parser

Addresses all four blockers from the consolidated review on #6168.

1. Both installers probed and printed the wrong port. scripts/install.sh:402 and
   scripts/install.ps1 start Compose inheriting the current environment, and
   Compose lets that environment outrank .env — but the installers then derived
   the probe port and the summary URL from .env alone. Measured on this branch
   before the fix, every port variable diverged:

     ambient PORT=9100        -> compose 9100, installer 8080
     ambient BACKEND_PORT=9200 -> compose 9200, installer 8080
     ambient API_PORT=9300     -> compose 9300, installer 8080
     ambient SERVER_PORT=9400  -> compose 9400, installer 8080
     ambient FRONTEND_PORT=3100 -> compose 3100, installer 3000

   Both now read the ports back once with `docker compose port` after `up -d`
   and reuse that single result for the health check and the summary. If the
   query fails they fail loudly instead of claiming success. The .env-derived
   helpers are deleted rather than left beside the new path.

2. The Make preflight is removed entirely, as the review recommended. It
   modelled `origin=environment` and `origin=file` but not `command line`, so
   `make selfhost BACKEND_PORT=9000` over a .env with API_PORT=7000 announced
   7000 while Compose published 9000. That was its fourth wrong report in four
   rounds, because it was a second parser of precedence rules — the very thing
   this PR removes elsewhere. `docker compose port` is the runtime truth, so the
   script, the Makefile captures and the macro are gone.

3. Tests now cover the installer paths that were unguarded. scripts/install.test.sh
   gains a `--with-server` matrix (defaults, .env PORT, all four backend aliases,
   FRONTEND_PORT, ambient overriding .env for all five, and explicit-empty
   fallback) plus a case proving an unresolvable port fails loudly. A new
   scripts/install.ps1.test.ps1 drives the same matrix through Start-LocalInstall.
   Every case asserts Compose's published port == the probed URL == the printed
   URL. Ambient variables are explicitly cleared per case so a runner's own PORT
   cannot leak. The Makefile matrix gains the command-line origin cases. CI runs
   the PowerShell suite on windows-latest in the always-on installer job, and the
   frontend filter now includes both installers.

4. Docs state the real contract: the alias order is shared, but which *source*
   wins is per entry point — Compose prefers the environment, make prefers the
   included env file, and a make command-line assignment outranks both. The
   removed preflight's promise is gone from SELF_HOSTING_AI.md, and the compose
   header no longer claims the installer derives its own health-check port.

Verified failing without the fixes: the Bash matrix reports
`[ambient PORT beats .env] compose published 9500 / probed 9100 / printed 9100`
against the old installer, and the PowerShell matrix reports
`printed backend=8080` against an injected summary divergence.

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

* fix(selfhost): keep web dev proxy off frontend port

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-31 14:46:13 +08:00
yushen
0a54485ab6 chore(llm): use gpt-5.6-luna by default 2026-07-31 12:04:01 +08:00
Jiayuan Zhang
0fdc38704e MUL-5149: add agent-generated Chat quick actions (#5766)
* feat(chat): add agent-generated quick actions

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two re-review blockers on 2dc9404d.

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
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: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
2026-07-30 21:25:03 +08:00
Bohan Jiang
0314df3b8e docs(license): widen the branding condition to all UI code and add NOTICE (MUL-5558) (#6197)
* docs(license): widen the branding condition to all UI code and add NOTICE (MUL-5558)

The branding condition in 1b defined Multica's "frontend" as `apps/web/`
only, which left the code that actually renders the console brand outside
its own scope: the sidebar, invite and new-workspace brand surfaces live in
`packages/views/`, and the Electron and iOS clients mount the whole console
from `@multica/views` without touching `apps/web/`. A rebranded desktop
build could therefore satisfy the condition literally while removing every
Multica mark.

Replace the directory-enumerated "frontend" with a derivation-based
"Multica user interface" covering `apps/web/`, `apps/desktop/`,
`apps/mobile/`, `packages/views/` and `packages/ui/` across source, the
Docker "web" image, and compiled desktop/mobile binaries. Keep the
non-interface exemption so backend-only use stays governed by 1a rather
than by branding, and add 1c so that path still carries attribution.

Add a NOTICE file to give the attribution obligation somewhere to land:
the repository had none, and no per-file copyright headers, so Apache 2.0
section 4(c)/(d) had nothing to reproduce.

Move the "commercial license must be obtained" sentence into 1a, since it
describes only that condition and 1b/1c are cured by written authorization
and attribution respectively, not by purchase.

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

* docs(license): separate the commercial license from the branding waiver (MUL-5558)

Conditions (a) and (b) were both cured by "explicitly authorized by
Multica in writing", so one authorization letter could be read as
releasing both — handing over commercial hosting rights when only a
rebranding permission was intended.

Name the two grants distinctly: (a) is cured only by a commercial license
obtained from the producer, (b) only by a written branding waiver. Add (d)
stating that neither implies the other and that neither can be inferred
from the producer's silence or from acceptance of contributions.

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

* docs(license): name the published frontend image and cover relocated UI code (MUL-5558)

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

* chore(docker): ship LICENSE and NOTICE in the published images (MUL-5558)

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

* docs(readme): add bilingual License sections matching the updated conditions (MUL-5558)

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

* docs(license): reposition as the self-contained, source-available Multica License (MUL-5558)

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

* chore(release): align license metadata and ship NOTICE in release artifacts (MUL-5558)

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

* docs(readme): describe Multica as source-available and add contribution terms (MUL-5558)

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

* fix(landing): describe Multica as source-available instead of open source (MUL-5558)

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

* revert(license): keep the Dify-style open-source framing, scope widening only (MUL-5558)

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-30 21:18:59 +08:00
Jiayuan Zhang
f0110da555 feat(inbox): mark a notification unread from the row context menu (MUL-5496) (#6137)
The inbox auto-marks a notification read the moment it is selected, so
"opened" and "handled" were the same signal — a row you glanced at and
meant to come back to was gone from the unread count with no way back.

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

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

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 19:18:36 +08:00
Jiayuan Zhang
9c90327ce4 fix(ui): replace the text-transparency ladder with solid tones (MUL-5452) (#6152)
* fix(ui): replace the text-transparency ladder with solid tones (MUL-5452)

Hierarchy was being expressed with transparency: 152 call sites of
text-muted-foreground/30..80, 26 of text-foreground/60..90, plus a handful
on destructive and current, and a few written as a standalone opacity-*
utility instead of a slash alpha.

On light surfaces every muted variant failed WCAG AA - /80 reached only
3.78:1 and /40 sat at 1.80:1, below even the 3:1 floor for non-text -
because the palette had no step below --muted-foreground, so transparency
was the only tool for 'quieter than muted'.

The palette now has that step, and it is deliberately non-text:
--faint-foreground clears 3:1 (WCAG 1.4.11) on every surface for icons,
chevrons, separator glyphs and empty-cell em dashes. There is no room for
a third readable text tone - AA caps a lighter text tone 0.018 L away from
muted - so text keeps exactly one floor, --muted-foreground.

Also fixes text-destructive/70 on a cron error message, which was 3.61:1.

This branch changes zero font sizes. The sub-12px half of the issue is
MUL-5451's (#6136); keeping the two apart is what makes this one
reviewable on its own after #6108 was reverted.

apps/web/app/text-contrast.test.ts replaces muted-foreground-contrast.test.ts
rather than sitting beside it. It recomputes the floors from tokens.css
instead of hard-coding ratios, and fails the build on all four ways to spell
the defect: /70, /[0.5], /[50%], and a detached opacity-* in the same class
string. Transparency behind hover/focus/disabled stays allowed - the resting
state carries the contrast obligation and it is solid.

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

* fix(ui): correlate transparency across a whole class expression

Review found two ways past the guard, both real.

A per-literal check cannot see cn("… text-muted-foreground", suppressed &&
"opacity-60") - one element wearing a colour in one argument and a dim in
the next. That split shape is the common one, and it was hiding live
violations: the comment trigger chips dimmed aria-pressed label text to
2.55:1 while the sweep reported clean.

The second was my own exemption. Accepting any state word within 80
characters let "text-muted-foreground hover:text-foreground opacity-50"
through, because the hover: belongs to the colour, not to the opacity.

The detector now correlates across a whole cn() call or template literal,
splits it into segments that each carry their own condition, and exempts
only on the variant prefix the opacity utility itself carries or on the
condition governing its segment. Segment splitting is what keeps
${disabled ? "opacity-60" : ""} exempt while flagging its neighbours.

Fixed what that surfaced: three trigger-chip controls (the suppressed state
is already carried by the avatar's own grayscale, the sentence wording and a
solid muted step), a disabled-skill icon chip, and the diff gutter marker.
tab-bar's isDragging is exempt - a drag ghost is an in-flight gesture, the
same category as :active.

The detector now has its own table of thirteen cases. Every hole so far has
been silent, so the shapes it must and must not catch are pinned next to the
reason each one exists.

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

* test(ui): cover the faint tone in the cn() merge regression test

text-faint-foreground is a new text-<x> class, which is the exact shape
that silently broke the sidebar labels in #6108: tailwind-merge cannot
tell a size from a colour and drops one of them. The token does resolve
correctly today - verified both orders against a size step and against
another colour - but the test that exists to catch this was not covering
it, so the guarantee rested on nothing.

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

---------

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 18:27:38 +08:00
Multica Eve
cbb23598fd docs(changelog): add v0.4.15 release entry (2026-07-30) (MUL-5545) (#6186)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 17:44:02 +08:00
Bohan Jiang
577018649e feat(issues): support human-readable issue URLs using issue keys (MUL-5354) (#6117)
* feat(issues): support human-readable issue URLs using issue keys (MUL-5354)

Closes #5987. `/{ws}/issues/MUL-123` now opens the issue, the copy-link
action shares that form, and a UUID URL rewrites itself to it. Existing
UUID links keep working.

Backend already resolved identifiers on `GET /api/issues/{id}`, but it
compared the number only — every prefix with the right number opened the
same issue, so no identifier URL could be canonical. Resolution now
validates the prefix against the workspace's own (case-insensitively,
matching `lookupIssueByIdentifier`), and the number parser bails on
int32 overflow instead of truncating a digits-only UUID group into a
plausible issue number.

On the client the identifier stays a presentation concern: the route
resolves it to the UUID before rendering, because the realtime updaters
patch `issueKeys.detail(wsId, issue.id)` with the UUID from the
websocket payload. A view keyed on the identifier would sit on a cache
entry no realtime event can reach and silently stop updating. Resolution
reuses the request the detail view would have made anyway and seeds the
UUID-keyed entry, so an identifier URL costs no extra round trip. The
desktop tab title/status glyph hops through the same resolution for the
same reason.

The URL rewrite lives in the new route wrapper rather than IssueDetail:
the inbox renders IssueDetail in a side panel, where replacing the URL
would navigate the user out of the inbox.

No migration — `issue (workspace_id, number)` is already unique/indexed.

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

* refactor(issues): make the single-request guarantee for identifier URLs explicit

Review flagged that opening `/{ws}/issues/MUL-123` fires two detail
requests. It does not, under the app's own QueryClient — but the
guarantee was resting on something implicit, so make it structural.

The old shape seeded the UUID-keyed entry from a `useEffect` after
resolution, while the route enabled the UUID query in the same render.
That held only because the seed effect happened to be declared before
the UUID query's own effect, and because `createQueryClient` sets
`staleTime: Infinity` so a seeded entry is never refetched. Neither is
obvious from the code, and a diagnostic run under a bare `new
QueryClient()` (staleTime 0) does show two calls — the second being a
staleness refetch of an already-seeded entry, i.e. a harness artifact.

`useCanonicalIssueId` becomes `useCanonicalIssue`, which owns both the
resolution query and the canonical detail query and hands the resolution
response to the latter as `initialData`. That is applied while the
observer is created, so the canonical query never observes an empty
cache and never starts a fetch of its own — no dependency on effect
ordering, and no cache write that could race a realtime patch
(`initialData` is ignored once the entry holds data).

Callers collapse to one hook each: the route no longer runs its own
detail query, and the desktop page drops its duplicate.

Tests now build the client with `createQueryClient()` rather than a bare
`new QueryClient()`, so request-count assertions measure production
behavior instead of the harness, plus a direct assertion that an
identifier URL costs exactly one request.

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

* fix(issues): stop the request loop when an identifier names no issue

Opening `/{ws}/issues/ZZZ-134` never reached "not found". It spun an
unbounded request loop and left the UI on the loading skeleton forever.

The route treated a failed resolution as "nothing resolved" and handed
the raw identifier down to IssueDetail. IssueDetail mounted a second
observer on the query that had just failed; `retryOnMount` refetched it,
which flipped the resolve hook back to pending, which unmounted
IssueDetail, which remounted it when the refetch failed — and around
again. Measured with retry disabled to isolate it: 8,192 requests at
300ms, 32,768 at 600ms. Under the app's `retry: 1` the backoff only
paces the loop, it still never converges.

`useCanonicalIssue` now reports a terminal `notFound` read from the
resolution query's own error state, rather than leaving callers to infer
failure from "not resolving and no id" — an inference that cannot
distinguish failed from in-flight. `IssueDetailRoute` renders the
not-found UI itself and never hands an unresolved segment to a view that
would query it again, so no second observer exists to restart the cycle.
Same measurement after the fix: 1 request, settled, "not found" on
screen.

The not-found UI moves out of IssueDetail into a shared `IssueNotFound`
so both render the identical state.

Regression tests at both levels, with retry off so any count above 1 can
only be a remount refetch: the hook settles a failed resolution without
looping, and the real IssueDetailRoute holds at one request across
waits and rerenders. Both fail against the previous code.

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-30 14:22:13 +08:00
Naiyuan Qing
e395fb8744 docs: four-language documentation overhaul with verified facts and product screenshots (MUL-5057) (#5714)
Complete rework of the docs site, in three passes squashed into one change:

1. P0 fact corrections across the core-loop pages (agent access model,
   comment triggers and coalescing, issue lifecycle, offline/queue
   semantics, task states and retries, dispatch via realtime push).
2. Chinese rewrite as the authoring language: final 12-group IA, new
   "Core concepts" and "Put agents to work" pages, curated welcome page,
   permanent-UI quickstart (no onboarding-wizard dependency), operator
   reference restored (CLI command reference, webhook response codes,
   task state/timeout tables, config set keys, upgrade notes).
3. Verification and sync: every load-bearing claim re-checked against
   current main with file:line evidence (migration 103 auto-backfill,
   CLI-only trigger toggling, duplication semantics, COOKIE_DOMAIN,
   GitHub App variables, Qwen Code as the 17th provider); register
   standards applied to all 39 pages; en/ja/ko synced from zh with
   same-language heading anchors (verified, zero dead links);
   17 product screenshots plus 2 diagrams wired in.

Supporting fixes: i18n middleware no longer swallows /images assets,
sidebar folder indent survives custom link padding, unused editorial
components removed.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:09:41 +08:00
Jiayuan Zhang
7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00
Multica Eve
9dde8370a5 docs(changelog): add v0.4.14 release entry (#6120)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 17:58:47 +08:00
Jiayuan Zhang
545afea827 Revert "feat(ui): establish a role-named type scale and migrate ad-hoc font s…" (#6116)
This reverts commit d68d636c91.
2026-07-29 17:10:33 +08:00
Jiayuan Zhang
d68d636c91 feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6108)
tokens.css defined colours, radii and font families but not a single
--text-* step, so font sizes had no baseline to align to and grew wherever
they were needed: 51 distinct sizes across web + desktop, 370 of them written
as arbitrary text-[Npx] values, six at half a pixel (10.5 / 11.5 / 12.5 /
13.5 / 14.5 / 15.5px). text-xs and text-sm carried nearly all UI text while
the range between them — 11, 13, 15px — could only be reached with arbitrary
values. Hierarchy does not come from having more sizes; past a handful, each
extra size makes the hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the 1,900-call-site rename (text-sm -> text-body
and friends) moves nothing on screen. The visible changes are confined to
former arbitrary values snapping to a step:

- 8 / 9 / 10px -> micro (11px): 102 sites, mostly badges and overline
  labels. Deliberate — under 12px is a counter role, not a text role.
- 17px -> title (18px), 22px -> display-sm (24px), 30px (text-3xl) ->
  display (36px): 21 sites, all headings or stat numbers in flexible
  containers.
- Half-pixel steps are gone entirely.

Arbitrary sizes also inherited whatever line-height was above them; the
tokens now pin one, which removes latent overflow risk in the fixed-height
h-4/h-5 badges those sizes were used in.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing-page display type
(rem/clamp, 2.2-6.4rem) is marketing typography on a separate ramp and stays
out of the scale, as do four decorative emoji / serif-hero sizes.

A guard test fails the build on any font size written outside the scale,
reporting file:line — without it nothing in a Tailwind build makes an
off-scale value look wrong, which is how the drift happened.

Verified against a local stack: typecheck, lint and the full TS suite show no
new failures, and before/after screenshots of issues, issue detail, runtimes
(populated), usage, agents, inbox, my-issues and settings differ only in the
intended 10px -> 11px labels, with no row-height, truncation or layout shift.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 16:30:13 +08:00
Jiayuan Zhang
4f70480039 fix(typography): real Inter italic + variable Geist Mono on desktop (MUL-5449) (#6105)
* fix(typography): load real Inter italic on web and desktop (MUL-5449)

Neither platform loaded an Inter italic face: next/font defaults to
`style: ["normal"]`, and desktop imported `@fontsource-variable/inter`
without `wght-italic.css`. Every `italic` in the product was therefore
browser-synthesized oblique — the ~20 semantic UI labels (chat empty
states, model-picker's "Managed by runtime", dashboard/squad
placeholders) plus all markdown <em> and blockquotes, which are user
content.

Load the real face on both, mirroring how Source Serif 4 is already
wired.

Also set `font-synthesis: style` on html, which forbids weight synthesis
while leaving style synthesis on. Weight synthesis is pure loss now that
every loaded face has real weights, and it is actively harmful on the CJK
tail of --font-sans: `font-bold` against PingFang SC (which stops at
Semibold) makes Chromium smear a fake 700 that closes up the counters of
dense Han glyphs.

Style synthesis stays on deliberately. `auto` only synthesizes when no
real italic matches, so the newly loaded Inter Italic already wins for
Latin. The only stacks still synthesizing are the two that ship no italic
at all — CJK fallbacks and Geist Mono — where `font-style: italic` has no
other visual carrier; a blanket `font-synthesis: none` would silently
flatten every Chinese <em>, every editor italic mark and every hljs
comment to upright.

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

* fix(typography): give desktop the variable Geist Mono (MUL-5449)

Web gets a variable Geist Mono from next/font (`font-weight: 100 900`,
verified in the emitted CSS), but desktop loaded only the discrete 400
and 700 cuts. Any weight in between silently snapped to the nearest one
desktop had, so the same shared component rendered at two different
weights on the two platforms:

- `font-mono font-medium` (500) fell back to 400 in chart.tsx,
  webhook-event-filter-section.tsx and keyboard-shortcuts-tab.tsx.
- Inline <code> in rendered markdown has no explicit weight, so inside a
  <strong>, heading or <th> — all `font-semibold` — it inherits 600 and
  resolved up to 700.

Loading `@fontsource/geist-mono/500.css` would have fixed only the first
of those. Switching to `@fontsource-variable/geist-mono` covers 100-900
in one file and closes the whole class, matching how Inter and Source
Serif 4 are already wired on desktop.

The latin subset is also smaller than the two static cuts it replaces:
22.6 KB vs 14.4 + 14.9 KB.

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

* docs(typography): correct stale Geist Mono note in font-synthesis comment

The comment was written when desktop still loaded discrete 400/700 cuts.
The following commit swapped desktop to @fontsource-variable/geist-mono,
so Geist Mono is now variable on both platforms and the "ships discrete
cuts" clause was false. Also name landing's 400-only Instrument Serif so
the "every face we load has real weights" claim is complete.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 16:29:05 +08:00
Jiayuan Zhang
9ff766d700 fix(ui): tabular figures on live task timers (MUL-5448) (#6094)
The 1Hz elapsed counters rendered with proportional figures, so the text
width changed on 9s -> 10s and 1m 9s -> 1m 10s, nudging the neighbouring
elements once a second for the whole run.

Web/desktop take the `tabular-nums` utility. Mobile cannot: Tailwind
compiles that class to `font-variant-numeric`, and react-native-css-interop's
property allow-list only carries `font-variant-caps`, so the declaration is
dropped and the class is a silent no-op on RN. Use RN's `fontVariant` style
prop there instead.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:38:12 +08:00
Jiayuan Zhang
e54296eabb fix(ui): raise light muted-foreground to clear WCAG AA (MUL-5447) (#6095)
In light mode `--muted-foreground` at oklch(0.552 0.016 285.938) missed the
4.5:1 AA floor on every light surface except pure white. The worst pair was
3.98:1 — nav labels on --sidebar-accent, i.e. the primary navigation in its
hover state. One token backs ~2k `text-muted-foreground` call sites, so the
blast radius is most of the product's secondary text.

Drop lightness to 0.505 and leave hue and chroma alone so the neutral ramp
keeps its cast. Worst case is now 4.88:1. Dark mode already passed at
5.2-7.2:1 and is untouched.

The `.landing-light` block in apps/web re-declares the light palette so
token-driven components stay light under next-themes' `.dark` class; it moves
with the source or it silently drifts.

The new test asserts on the token file rather than on components: the token is
the contract every consumer inherits, and a component test could only prove a
class name is present, not that the pixels are legible.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:37:20 +08:00
Jiayuan Zhang
503b09c5de fix(web): give landing CJK headlines a real serif and CJK metrics (MUL-5446) (#6101)
The landing tree set `--font-serif` to Instrument Serif, which ships Latin
only, so zh/ja/ko headlines fell out of the stack onto the browser default
sans while the Latin next to them stayed in a high-contrast display serif.
A `Noto_Serif_SC` next/font entry was loaded as `--font-serif-zh` but no
rule ever referenced it, and it requested `subsets: ["latin"]`, so it
downloaded no CJK glyphs either way — remove it.

Compose `--font-serif` for the landing tree in static CSS instead, with a
per-`<html lang>` CJK tail, mirroring the `--font-sans` chain in globals.css:
Chinese before Korean by default, a Mincho-first chain promoted for `ja` so
Kanji do not get Chinese glyph shapes. Platform Songti/Mincho/Myeongjo faces
rather than a multi-megabyte CJK webfont.

The 16 landing surfaces that inlined `font-[family-name:var(--font-serif)]`
now share a `.landing-serif` hook, which gives the per-locale metric reset a
precise target: CJK headings drop the Latin display tracking (down to
-0.038em) and sub-1 leading (0.93) that crowded ideographs and collided the
two lines of a `<br />`-split headline. Korean additionally gets
`word-break: keep-all` so wrapped Hangul breaks at word boundaries.

Scoped to h1/h2 so the Latin-only footer wordmark keeps its tuned tracking.
The English landing hero renders pixel-identical to before.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:36:42 +08:00
Bohan Jiang
cc5ee169f0 fix(agents): allow agent owners to manage their own agent's env (MUL-5438) (#6080)
* fix(agents): let agent owners manage their own agent's env (MUL-5438)

GET/PUT /api/agents/{id}/env admitted only workspace owner/admin, which
made env the one endpoint in the agent permission model that ignored
agent ownership: canManageAgent already lets the owner update/archive,
and canViewAgentSecrets already lets the owner read mcp_config. The
asymmetry was worst on create — POST /api/agents accepts custom_env from
any member and stores that member as owner_id — so a member could write
secrets into their own agent and then never read or rotate them, with
UpdateAgent rejecting custom_env outright and no workaround left.

authorizeAgentEnv now admits a workspace owner/admin OR the agent's own
human owner. The agent-actor rejection still runs first and unchanged:
an agent process is denied even when its backing human owns the target
agent. The owner comparison uses member.UserID rather than the raw
X-User-ID header because agent.owner_id is nullable and uuidToString
renders NULL as "", and canManageAgentEnv rejects an empty owner from
the other side too.

The web Environment tab is gated on the same rule via the existing
canEdit decision, so it stops offering a "Reveal & edit" action that is
a guaranteed 403. The server remains the boundary.

Closes #6076

Fixes: https://github.com/multica-ai/multica/issues/6076
Co-authored-by: multica-agent <github@multica.ai>

* docs(agents): correct stale "owner/admin only" env permission wording

Follow-up to the MUL-5438 permission change: several comments and the
published docs still described the env endpoints as workspace
owner/admin only, which now contradicts the code.

- router.go / agent.go / types/agent.ts: the three sites flagged in
  review.
- agents-create.mdx (en/zh/ja/ko): the user-facing callout said reading
  values requires a workspace owner or admin. It now names the agent's
  own owner first, and spells out that the agent-actor denial holds even
  for an agent the same human owns.
- daemon.go / middleware/auth.go: these called the env endpoints
  "owner-only" as shorthand for "reject agent actors". That property is
  unchanged, but "human-only" is what they actually mean now.

Comments and docs only — no behavior change.

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-29 14:01:09 +08:00
Bohan Jiang
0abee49005 docs(self-hosting): require COOKIE_DOMAIN for split-domain deployments (MUL-5413) (#6055)
The split-domain reverse-proxy recipe listed FRONTEND_ORIGIN,
CORS_ALLOWED_ORIGINS and NEXT_PUBLIC_API_URL but never COOKIE_DOMAIN.
Without it the session cookies are host-only on the API host, so the
frontend cannot read multica_csrf, never sends X-CSRF-Token, and every
non-GET request is rejected with 403 "CSRF validation failed" while
reads keep working.

Add COOKIE_DOMAIN to the recipe and explain the failure mode, including
the stale-cookie cleanup needed after changing it.

COOKIE_DOMAIN also scopes multica_auth, so the browser sends the session
JWT to every host under that domain — HttpOnly stops page scripts from
reading it, not sibling subdomains from receiving it. Require the
narrowest parent domain covering both hosts, state that every host in
scope must be operated by the same trusted party, and mark the
same-origin layout as recommended since it keeps the cookie host-only.

Also document the requirement and the scope caveat in the environment
variable reference (en/zh/ko/ja).

Refs #6046 (MUL-5413)
2026-07-28 22:11:54 +08:00
Bohan Jiang
0254090853 docs(changelog): file the Claude Code cache fix under fixes and lead with it (MUL-5400) (#6047)
* docs(changelog): lead v0.4.13 with the prompt-cache saving (MUL-5400)

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

* docs(changelog): scope the cache fix to Claude Code and move it to fixes (MUL-5400)

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-28 18:48:09 +08:00
Multica Eve
79cc3abc8d docs(changelog): add v0.4.13 release entry (#6038)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 18:33:42 +08:00
Multica Eve
aa110aac9c MUL-5088: import repositories from GitHub App (#5896)
* feat(github): import repositories from app installations

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

* fix(github): address repository import 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-28 16:19:35 +08:00
Naiyuan Qing
420ffe7dbc feat(diagnostics): capture the JS stack of a hung renderer (MUL-5345) (#6026)
* feat(diagnostics): capture the JS stack of a hung renderer (MUL-5345)

Route attribution shipped in 0.4.12 and did its job: hard hangs are no longer
scattered, they cluster on one page (10 of 12 hangs, 8 distinct users, spread
over 16 hours). It also hit its ceiling there. That page hosts three modes on a
single route and the mode lives in component state, so the route field cannot
say which of them froze — and a page name was never going to name the function
either. Two rounds of code-reading produced two hypotheses and both were wrong,
and one manual repro attempt covered a path the telemetry never pointed at.
Naming the code requires reading it off the stuck thread.

When the renderer hangs, the main process attaches the DevTools protocol and
asks for the stack. The channel is warmed while the renderer is healthy because
a command sent after the thread is stuck is never dispatched — measured on the
pinned Electron 39.8.7, where a post-hang attach returned nothing in 5s while a
pause on a warm channel returned the stack in 2ms with the blocking function on
top. Holding the channel open all session showed no cost beyond run-to-run
noise (A/B/A; the ordering drift between cold phases exceeded the effect).

That channel is the reason this ships behind a fail-closed server flag rather
than on by default. `desktop_hang_stack_capture` rides the existing
`/api/config` feature flags; main starts off, only an explicit `true` enables
it, and revoking it detaches the channels rather than merely skipping the next
capture. Main cannot read config itself, so the renderer forwards the one bit —
which means a config that never arrives also lands on off.

Privacy is unchanged in kind from `$exception`: four scalar fields per frame,
`scopeChain` and `this` dropped so no handle can be dereferenced into user
data, script URLs reduced to their bundle-relative tail, and a four-verb CDP
allowlist that a source-level test pins to a single callsite. Resume is
unconditional — a capture must never turn a recoverable hang into a permanent
one.

Delivery is fixed alongside, because a stack that cannot be sent is not worth
capturing: `freeze:get-last` no longer deletes on read, the report goes out
with `send_instantly`, and the breadcrumb is retired only after a grace window,
so a second hang inside that window leaves the file for the next boot instead
of taking the report with it (the MUL-4115 failure mode).

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

* fix(diagnostics): close the kill switch, egress and multi-window gaps (MUL-5345)

Three review findings on #6026, all in failure paths the tests didn't reach.

The kill switch didn't reliably revoke. `coolDebuggerChannel` only detached
after `Debugger.disable` resolved, so a failed disable left the channel
attached — the exact state the switch exists to exit. Disable is a courtesy to
the renderer; detach is the contract, so it now runs in `finally`. Warming had
the mirror bug: an attach we made and could not enable returned false while
leaving the channel open, stranding a debugger on a renderer nothing tracks.
That attach is rolled back now, and only that one — a channel someone else owns
(DevTools) is left alone.

Stack frames were sanitized at capture and then forwarded verbatim at egress.
Between those two points they cross an on-disk breadcrumb that `readFreezeBreadcrumb`
barely validates, by design: it only has to survive version skew. So "sanitized
once" was not a property the flush side could rely on — an older build, a
corrupt file or a future writer could put a `scopeChain` handle or an absolute
install path in there and it would ship. Both ends now rebuild frames through
one shared whitelist, which also makes them impossible to drift apart. The url
reduction is idempotent so re-running it costs nothing.

The control flag was global, and that does not survive multiple windows. Every
renderer publishes `false` before its own config lands, so a window opened
while capture was on either never warmed (the global value never changed, so
nothing warmed the new webContents) or cooled every other window on its way up.
State is per renderer now; they converge on the same value because they read
the same config, but each on its own schedule.

Regression tests for each: detach after a throwing disable and rollback after a
throwing enable, a frame carrying `scopeChain` / `this` / an absolute path
reaching the flush side, and a second window warming while the first is already
on without revoking it.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:05:32 +08:00
Bohan Jiang
c271f80999 MUL-5370 fix: label stalled skill-bundle downloads, align failure-reason copy with the backend taxonomy (#6001)
* fix(daemon): label stalled skill-bundle downloads and make them retryable

A skill bundle that could not be downloaded during task preparation surfaced
as the bare string "resolve skill bundles: context deadline exceeded".
taskfailure.Classify has no rule for a Go context deadline, so it landed in
agent_error.unknown — a bucket that is NOT on the server's retry allowlist.
A transient stall therefore became a terminal chat failure carrying a label
nobody could act on, and the failure was invisible on the Usage page's Errors
breakdown. (MUL-5370)

- Add the platform-side reason skill_bundle_unavailable and put it on
  retryableReasons. Retrying is cheap and safe: the agent process never
  started, and bundles that did arrive are already cached on disk, so
  successive attempts converge.
- Carry a sentinel error from the resolve loop so the reason is derived
  structurally rather than by matching the wrapped transport error's text,
  and name the skill, its declared size and the elapsed wait in the wrap —
  enough to tell "this bundle is too big for the link" from "the link is
  dead" without reading daemon logs.
- Normalise the wire shape an OLD daemon produces (a non-empty catchall plus
  the previous "resolve skill bundles:" wrapper) on the server side. Installed
  daemons upgrade on their own cadence, and FailTask only classifies when the
  caller supplied nothing, so without this the fix would reach only hosts that
  happened to update — while the un-upgraded hosts most likely to be hitting
  the bug kept failing terminally.
- Teach Classify about "deadline exceeded" and net/http's "Client.Timeout
  exceeded while awaiting" so any other Go-side deadline that reaches it as
  text stops falling into the unknown bucket too.
- Backfill historical rows in both agent_task_queue and chat_message. Scoped
  to agent_error.unknown alone — the old wrapper string postdates the
  in-flight classifier by three weeks, so no row carrying it can hold the
  legacy coarse value — which keeps the down migration an exact inverse.

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

* fix(chat): give chat its own failure copy for the refined reasons

#5991 rebuilt the operator-facing failure labels around an open wire string
with a raw-value fallback, but the chat bubble kept its own exact-key lookup
against the six coarse values from migration 055. So all 14 agent_error.*
values still missed and rendered the generic "Something went wrong and the
agent couldn't finish replying" — the classification the backend had already
computed was discarded at the last step, and that is the message the MUL-5370
reporter saw.

- Add resolveFailureReasonKey in packages/core: exact match, else degrade an
  `agent_error.*` value to its family, else undefined. A reason newer than the
  shipped client now lands on the family line instead of the fallback.
- Rekey the chat copy map by wire value and route it through the helper.
  Chat deliberately degrades to friendly copy rather than adopting the
  operator surfaces' raw-value fallback: it is read by the person who just
  sent a message, and the raw error is one click away under the collapsible.
- Add refined chat copy (en / zh-Hans / ja / ko) only where it can say
  something the family line can't — a different next step: network, auth,
  quota, rate limit, context overflow, missing/outdated CLI, skill download.
- Give skill_bundle_unavailable a label on the web and mobile surfaces and a
  class on the Usage page's Errors breakdown (runtime — the operator response
  is "check the daemon's link to Multica", the provider is not involved).
- Mobile's two label maps were still coarse-only for the same reason; rekey
  them by wire value and fill in the refined taxonomy.

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-28 13:31:29 +08:00
Bohan Jiang
ceba14a227 fix(issues): MUL-5362 return to the source list after deleting an issue (#5997)
* fix(issues): return to the source list after deleting an issue

Deleting an issue from its detail page always pushed the workspace
Issues list, so opening an issue from My Issues (or a project list,
search, a pin, an agent panel) and deleting it dropped the user's
navigation context — GH #5995.

Go back instead. `useBackOrReplace` steps back when the platform
reports in-app history and replaces with a fallback path when there
is none, so a shared link opened cold or a new tab never steps off
the app. Web answers via the Navigation API, falling back to counting
its own pushes; desktop reads the active tab's virtual history.

`replace`, not `push`: the deleted issue's URL must not stay in
history for the back button to land on a 404.

The not-found "Back to Issues" button loses the same context, so it
moves to the same helper and its label becomes a plain "Back".

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

* fix(web): track history position, not push count, for canGoBack

The Navigation API fallback only ever counted pushes, so `pushes > 0`
did not mean the current entry still had an in-app page behind it.
Cold-open an issue, click Issues, press the browser's Back button, then
delete: the tracker still claimed history and `back()` would step off
Multica — the exact case the fallback exists to prevent.

Count depth instead: a push adds one, any traversal takes one away
(clamped at zero). Reaching the document's first entry requires
traversing back at least as many times as we pushed, so a positive
depth can never be claimed while sitting on it. A browser Forward is
now conservative — it reports no history where a step back would have
been fine — which costs a fallback navigation rather than an exit.

Also corrects the useBackOrReplace contract comment: stepping back
leaves the dead URL in forward history, so the guarantee is that it
never lands on the back stack, not that it leaves history entirely.

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

* fix(web): answer canGoBack from the browser alone, never from push counts

Review found the counter still lied, one level deeper: it counted
`router.push` calls, and a call is not a committed history entry. Next
drops a push to `replaceState` when the canonical URL is unchanged
(app-router.js, the `pendingPush && href !== canonicalUrl` branch), so
clicking a self-link — the breadcrumb on an issue detail page, a pin to
the issue you are on — incremented depth with no entry behind it, and a
delete from there could still step off the app.

Fixing the count needs per-entry markers, which means depending on both
React effect ordering (our provider's effect runs before app-router's
history commit) and Next's own preserveCustomHistoryState behaviour.
Two rounds of review have now found holes in hand-rolled history
tracking; a third layer of it is not the way to buy this guarantee.

So stop deriving. `canGoBack` is the Navigation API's answer or `false`.
Browsers without it take the fallback path, which is exactly what they
did before any of this existed — nobody regresses, and the "wrong true
walks the user out of the app" failure is now unreachable by
construction.

Drops the tracker, the popstate wiring and the push wrapper: the
`multica:navigate` bridge returns to its original shape.

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-27 18:39:25 +08:00
Multica Eve
e52b50658a docs(changelog): add v0.4.12 release entry (2026-07-27) (MUL-5364) (#5996)
* docs(changelog): add v0.4.12 release entry (2026-07-27) (MUL-5364)

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

* docs(changelog): move PR-card live status from v0.4.11 to v0.4.12 (never enabled in prod) (MUL-5364)

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-27 17:36:27 +08:00
Naiyuan Qing
38b08acf00 feat(diagnostics): report which page a desktop hang happened on (MUL-5345) (#5989)
* feat(diagnostics): attribute desktop hangs to route, function and stack (MUL-5345)

A desktop hang currently reports "froze for 8s" and nothing else, so MUL-5345
could not be diagnosed at all. Three gaps, all fixed here.

Route attribution was silently dead. The main window's route reporting lived in
the PostHog pageview tracker and was deleted with it (MUL-4127), leaving
`getDiagnosticContext` in main reading a WeakMap nothing ever wrote — every
field report carried only the asar index.html URL. `DiagnosticRouteReporter`
restores the push, and now feeds the in-renderer watchdog too: the renderer runs
a memory router, so `location.pathname` could never identify the page either.
Paths are bucketed to templates (`/:slug/issues/:id`) before publishing.

Function attribution did not exist. The watchdog now prefers
`long-animation-frame` over `longtask` where supported, which carries per-script
`sourceFunctionName` / `sourceURL` / `sourceCharPosition`. That covers hangs the
thread survives. For hangs it does not, main captures the JS call stack over CDP
— which requires the Debugger channel to be warmed at window creation, because a
command sent after the thread is stuck never gets dispatched. Commands go
through a four-verb allowlist, only scalar code locations are copied out of the
paused frames (never `scopeChain`, whose handles dereference into user data),
and resume is unconditional so a capture can never turn a recoverable hang into
a permanent one.

Reports could also be lost before delivery. `freeze:get-last` no longer deletes;
the renderer sends with `send_instantly` and acks by exact timestamp, so a
report killed by a second hang is retried next boot instead of vanishing with
the file (the MUL-4115 failure mode: three deterministic hangs, zero events). A
7-day TTL keeps an undeliverable breadcrumb from becoming permanent boot noise.

Operations name themselves: `parseMarkdownChunked` marks the diagnostic context
before it runs, and the mark travels to main over the async IPC channel that
still lands after the main thread stops responding. The event carries how stale
the mark is, so it reads as context rather than as a cause.

Verified on Electron 39.8.7 / Chromium 142 (throwaway spike, not committed):
Debugger.pause during a 12s synchronous block returned the stack in 2ms with the
blocking function on top; holding the channel open all session showed no cost
beyond run-to-run noise (A/B/A, ordering drift larger than the effect); DevTools
and the channel coexist in both open orders.

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

* fix(diagnostics): close the freeze report ack race and stop shipping raw ids (MUL-5345)

Two review findings on #5989.

Acking on hand-off was not acking on delivery. `onCaptured` fires when
posthog.capture() returns; the request is still in flight, and posthog-js
exposes no delivery callback to wait on (`CaptureOptions` has `send_instantly`
and `transport`, nothing else). Deleting the breadcrumb there loses the report
whenever the app freezes again or is killed in that gap — the same MUL-4115
failure the ack protocol was added to prevent. The flush now waits out a grace
window before acking: if the process dies inside it the timer never fires, the
file survives, and the next boot retries. Duplicates are the accepted trade and
are trivially deduped on `breadcrumb_ts`; a lost report is not.

Raw identifiers were reaching telemetry. The breadcrumb context was spread
wholesale into the event props, so `workspaceSlug`, `tabId` and the absolute
`windowUrl` shipped with every report despite the stated "bucketed path only"
constraint. Fixed at both ends: the slug and tab id are no longer put into the
route context at all (nothing else read them), the sanitizer constructs its
result explicitly so a stale renderer's payload can't reintroduce them,
`windowUrl` is dropped since it is an install path that can carry the OS
username and the bucketed route already says which page it was, and the event
props are now assembled field by field so a future context key cannot ship
itself.

The flush moved into `freeze-flush.ts` to make both behaviours testable:
`onCaptured` does not ack, the grace window does, a cancelled window keeps the
breadcrumb, and props built from a context still carrying slug/tabId/windowUrl
contain none of them.

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

* refactor(diagnostics): reduce MUL-5345 to route attribution only

Scope call from product review: the next hang should answer "which page", and
nothing more. Removes the CDP stack capture, the long-animation-frame observer,
the operation breadcrumb, and the read/ack delivery protocol added earlier on
this branch, along with the spike-derived build note. What stays is the smallest
change that gives the two existing hang events a real route.

The route reporting had been dead since MUL-4127 (#4996) deleted it along with
the PostHog pageview tracker: `getDiagnosticContext` in main kept reading a
WeakMap nothing wrote, so a hang report carried only the asar index.html URL.
`DiagnosticRouteReporter` restores the push to main — the only party alive
during a true hang, which cannot ask a blocked renderer anything — and also
publishes to the in-renderer watchdog, whose `location.pathname` is that same
useless packaged path because the shell runs a memory router.

Paths are bucketed to templates (`/:slug/issues/:id`) before publishing, and
the workspace slug and tab id are not sent at all; nothing outside diagnostics
read them. The sanitizer constructs its result explicitly so a renderer older
than this build cannot reintroduce them, `windowUrl` is dropped because it is an
install path that can carry the OS username, and the breadcrumb event props are
assembled by whitelist rather than by spreading the context.

Both hang events now report `path` under the same name, so they group in one
query.

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

* fix(diagnostics): bucket hang routes by known structure, not id shape (MUL-5345)

The bucketer guessed which segments were ids by looking at them — UUID, issue
key, or all digits. Every id that does not look like one therefore travelled to
telemetry intact, and most of ours do not: project, autopilot, agent, member,
squad, runtime, skill and attachment ids are arbitrary strings from
`paths.ts`. `/acme/projects/p1` bucketed to `/:slug/projects/p1`, and
`/acme/runtimes/machine%2Fruntime/runtime/runtime%20one` came through
completely unchanged.

It now matches structurally against the known route shapes, so a `:param` slot
is whatever occupies that position regardless of how it is spelled or encoded.
Where several patterns fit, the most literal one wins, which keeps `agents/new`
the create page rather than an agent whose id happens to be "new".

An unmatched path is masked (`/:slug/issues/*`, `/:slug/*`) rather than passed
through: a route we do not know is exactly the case where an id cannot be told
from a page name, so nothing from it travels. That makes a route added to
paths.ts without being added here a loss of detail instead of a leak.

To stop the list falling behind quietly, a parity test walks the real path
builders — not a copy of them — and asserts that no builder leaks its slug or
ids and that none falls back to the mask. Removing a single route from the
table fails it with the builder named.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:20:23 +08:00
Bohan Jiang
4581e9ee76 fix(server): surface real reason for failed quick-create (MUL-5268) (#5898)
* fix(server): surface real reason for failed quick-create (MUL-5268, #5885)

When an agent's quick-create run finishes without producing an issue, the
completion path wrote a fixed "agent finished without creating an issue"
inbox, discarding the real reason — most often the active-duplicate guard
rejecting the create. Users saw no actionable detail.

notifyQuickCreateCompleted now:
- distinguishes pgx.ErrNoRows (a confirmed no-issue → real failure) from a
  genuine lookup fault (DB/timeout), so a transient error no longer mislabels
  a run that may actually have created the issue;
- on the real-failure branch, surfaces the agent's final output as the
  failure reason. The quick-create prompt already requires the agent to exit
  with the CLI error as its only output, so this carries the concrete cause
  (e.g. the existing issue's identifier + status), unescaped, bounded, and
  redacted. Empty output falls back to the existing generic message.

No API/CLI contract or migration change.

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

* fix(server): never end quick-create with no notification on lookup fault

Review follow-up. The previous commit returned silently when the completion
lookup failed with a non-ErrNoRows error, to avoid misreporting a failure that
was never observed. But the task is already completed and nothing retries this
reconciliation, so a single transient DB fault permanently stranded the
requester with no inbox result at all.

The indeterminate branch now writes a neutral, terminal notification: it does
not assert failure (the agent may have created the issue), does not reuse the
agent output as if it were the confirmed reason, and points at the one safe
next step — check recent issues before retrying, so a retry cannot silently
produce the duplicate the guard exists to prevent.

notifyQuickCreateFailed / notifyQuickCreateUnconfirmed are now thin wrappers
over a shared writer so both outcomes keep the identical row shape and the
frontend's 'Edit as advanced form' recovery affordance.

Tests:
- TestQuickCreateLookupFault_WritesUnconfirmedInbox: fails against the previous
  commit with 'no rows in result set' (the exact silent-drop), passes now. Uses
  a DBTX wrapper that faults only GetIssueByOrigin so the inbox write still
  reaches the real DB.
- TestQuickCreateFailure_RedactsAgentOutput: locks in that the newly-surfaced
  agent output is scrubbed before storage.

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

* fix(inbox): render unverified quick-create outcome as neutral, not failed

Review round 2. Three fixes.

1. Rebased onto main and updated the three CompleteTask call sites for the
   new sessionRolloutMissing parameter; the branch no longer compiles-fails
   on the merge ref.

2. The unverified outcome reused the quick_create_failed inbox type, so every
   client framed it as a failure regardless of the neutral title/body: web
   list rendered 'Failed: {detail}', web detail showed 'Create with agent
   failed', mobile rendered 'Failed: ...', and getInboxDisplayTitle replaced
   the neutral title with the original prompt. Users saw 'Failed: Couldn't
   confirm...' — asserting a failure never observed. Added a distinct
   quick_create_unconfirmed type end to end: core type union, web list label
   (no failure framing), web detail pane, the original-prompt box and 'Edit as
   advanced form' recovery affordance, mobile label + display title, and en /
   zh-Hans / ja / ko strings. Older clients hit their existing default branch
   and render the already-neutral title.

3. The terminal notification reused the caller's context, so a lookup that
   failed with context.Canceled / DeadlineExceeded failed the write for the
   same reason and still dropped the notification. The write is now detached
   via context.WithoutCancel with a bounded timeout.

Tests (each verified to fail without its fix):
- TestQuickCreateLookupCancelled_StillWritesUnconfirmedInbox: cancels the ctx
  at the lookup; without the detach, 'no rows in result set'.
- inbox-detail-label.test.tsx: resolves accessors against the real en locale;
  pointing the unconfirmed case back at failed_with_detail reproduces
  'Failed: Couldn't confirm...'.
- inbox-display.test.ts: both outcomes stay recoverable rows.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 15:35:09 +08:00
Bohan Jiang
4931ee6f7a docs(chat): correct the chat sandbox description (MUL-5287) (#5956)
The Chat docs claimed agents in a chat are fully sandboxed and cannot
see or change issues (`multica issue list` returns empty, issue API
calls blocked by permission checks). That is not what the server
enforces: a chat run is dispatched with the same owner-scoped,
workspace-bound task token as an issue run, and /api/issues is guarded
only by workspace membership — no chat-origin check. So a chat agent can
run `multica issue create`/`list` and operate on the workspace when
explicitly asked (creator recorded as the agent), matching the report in
GH #5917.

Reframe the section around the real boundary: chat isolation is about
context and privacy, not permissions. The genuinely-true guarantees are
preserved (private transcript, no issue context in the prompt, no
auto-promotion of chat content into issues); the false 'cannot see/change
issues / fully sandboxed' claims are corrected. Updated en/zh/ja/ko.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 01:24:00 +08:00
Multica Eve
b0bae3f95e docs(changelog): add v0.4.11 release entry (2026-07-25) (MUL-5284) (#5916)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-25 03:38:12 +08:00
Bohan Jiang
ecce589867 MUL-5265: GitHub API-snapshot PR cards — CI status + mergeability (#5889)
* feat(github): API-snapshot PR cards — CI status + mergeability (MUL-5265)

Fetch each linked PR's CI checks and mergeability from the GitHub GraphQL
API as the single source of truth (Plan C). Webhooks, page visits and a
bounded TTL sweep are refresh triggers only; nothing is inferred from
webhook payloads anymore.

Backend (server/internal/integrations/ghsnapshot):
- installation-token cache + GraphQL client (private key / tokens never logged)
- one paginated pullRequest query -> normalized per-check snapshot
- outbound queue: (installation,repo,PR) dedup + single in-flight per PR,
  bounded worker pool, Retry-After / rate-limit backoff, jitter
- head-SHA-guarded atomic batch replace (a slow response for an old head
  can never overwrite a newer head's snapshot)
- bounded chase window (30s->5m, stops on terminal/closed) + page-visit +
  TTL refresh; clean degradation when no App private key is configured

Removes the old suite-level webhook aggregation display path (query +
handlers + tests). check_suite / check_run / status are now pure triggers.

Frontend: PR card shows two independent tri-state elements (CI status +
mergeability). "Ready to merge" only when merge state is clean; no-checks
and unknown-mergeable never assert a positive verdict; progress strip
removed; four locales; stale marker.

Docs: github-integration + environment-variables (four languages) — now
required App private key, read-only Checks/Commit-statuses permissions,
new event subscriptions, capability boundaries and troubleshooting.

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

* fix(github): address PR snapshot review blockers

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

* fix(github): bound snapshot refresh scheduling

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

* fix(github): concurrent check-run index migration + singleflight token mint

Address Elon's third-round review on the MUL-5265 PR snapshot pipeline.

Must-fix — migration built a non-concurrent index. The
github_pull_request_check_run table declared PRIMARY KEY (pr_id, ordinal)
inside CREATE TABLE, which builds a unique index synchronously and violates
the repo rule that every migration-created index (including on a new table)
use CREATE UNIQUE INDEX CONCURRENTLY in its own single-statement file. Split:
222 now creates the table without a primary key; new 223 adds the
(pr_id, ordinal) unique index CONCURRENTLY. The atomic delete-all/insert
write path already guarantees ordinal uniqueness, so a plain unique index is
sufficient; the index also serves the pr_id-prefix list aggregation and the
workspace/PR cleanup deletes.

Nit — token mint now singleflights per installation. installationToken
released the lock before minting, so the N workers of one installation could
mint N tokens on a cold cache or a simultaneous renew. Concurrent callers for
the same installation are now collapsed via singleflight into one HTTP mint;
added a -race concurrent-mint test asserting a single mint under 16 callers.

Verified: fresh DB migrates through 223 (table has no PK, concurrent unique
index present); ghsnapshot suite + new test pass under -race; migration lint
and handler github/workspace-delete tests pass; sqlc produced no diff;
go build / vet / gofmt / git diff --check clean.

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-24 18:30:20 +08:00
Multica Eve
afb3def1b7 docs(changelog): add v0.4.10 release entry (2026-07-24) (MUL-5273) (#5894)
* docs(changelog): add v0.5.0 release entry (2026-07-24) (MUL-5273)

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

* docs(changelog): include #5893 board/list load-more fix in v0.5.0 (MUL-5273)

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

* docs(changelog): use version 0.4.10 (patch bump) per review (MUL-5273)

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-24 17:45:56 +08:00
Bohan Jiang
73b0015475 feat(vcs): make self-hosted Git providers self-host-only (MUL-3772, MUL-5138) (#5888)
* feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772)

The Forgejo/Gitea/GitLab integration is intended for self-hosted Multica, where
Multica can reach a Git instance on the operator's own network. On the managed
multi-tenant cloud it adds an SSRF surface (connect validates a user-supplied
instance URL from the server) and would store third-party Git tokens for all
tenants under one key, while only serving the small subset of users whose
instance is publicly reachable. Product decision: offer it on self-host only.

- Add an explicit deployment switch MULTICA_VCS_INTEGRATION_ENABLED (default
  off). Connect, rotate, and webhook now require BOTH the switch on AND a valid
  MULTICA_VCS_SECRET_KEY — the switch is the product boundary, not key presence
  alone. When off, connect/rotate return 404 and the webhook returns a bare 404
  (no config leak), independent of the frontend.
- /api/config exposes vcs_integration_available (mirrors the switch, omitted
  when false) so the Settings UI hides the whole "Git providers" section on
  cloud instead of surfacing an operator-only "missing key" hint.
- docker-compose.selfhost.yml defaults the switch on; .env.example documents it.
- Docs (en/zh) lead with a callout: available on self-hosted Multica only, not
  Multica Cloud, and clarify "self-hosted" means Multica itself, not just Git.

#5006 / #5883 stay in place — the schema and backend capability are retained;
this only gates availability. No cloud VCS connection can exist (connect always
required the key, which the cloud never set), so nothing needs migrating.

Verified: go build/vet + VCS/config handler tests on a fresh migrated DB
(incl. a new disabled-deployment 404 test); pnpm typecheck (core + views) and
the integrations-tab + core schema/config vitest suites pass.

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

* fix(vcs): complete self-host integration gating (MUL-5138)

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-24 16:39:22 +08:00
Naiyuan Qing
7cc763bbfb fix(runtimes): show runtime aliases across user-facing surfaces (MUL-5248) (#5881)
Several UIs rendered the raw daemon name (runtime.name) and dropped the
user's custom_name alias: the Skills "Copy from runtime" selector, the
Skills list/detail source, the agent creation runtime chip, the agent
runtime filter, the agent overview, the skills/MCP runtime hints, the
runtime delete confirmations, the desktop runtime window title, the
onboarding runtime cards/hints, and the task transcript chip.

Route every user-visible runtime label through the shared display contract:
- runtimeDisplayLabel (alias + provider) for standalone labels
- runtimeDisplayName (alias only) where a provider icon/text sits beside it
- machine.title + runtimeRowLabel(runtime, machine.title) inside machine groups

The Skills "Copy from runtime" selector is now machine-grouped via the shared
buildRuntimeMachines/runtimeRowLabel helpers instead of a flat raw-name list;
runtime.name stays the source of identity for hostname parsing, grouping,
search, and protocol payloads only.

Add a conventions rule (en + zh) forbidding raw runtime.name in user-visible
JSX/i18n/Select labels/document titles.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 15:54:48 +08:00
dixonl90
581d9527ba feat(vcs): self-hosted Git providers (Forgejo, Gitea, GitLab) alongside GitHub (MUL-3772) (#5006)
Adds self-hosted Git provider support (Forgejo, Gitea, GitLab) alongside GitHub:
per-workspace token connection, a provider-dispatched webhook, PR/MR and CI
mirroring, and the shared issue auto-link / auto-close machinery. Off until
MULTICA_VCS_SECRET_KEY is set, so existing deployments are unaffected.

Co-authored-by: Bohan <bohan@devv.ai>
2026-07-24 15:01:27 +08:00
苗大
a90aa92d0c fix(web): resolve API and docs upstreams at runtime (#4840)
* fix(web): resolve upstream URLs at runtime

* fix(web): keep unconfigured upstreams same-origin

* fix(web): restore dev-only localhost fallbacks for API and docs upstreams

`next dev` on a developer machine now falls back to the conventional
http://localhost:8080 backend (honoring BACKEND_PORT) and
http://localhost:4000 docs origin when nothing is configured, so a bare
`pnpm dev:web` keeps proxying out of the box. Builds and the runtime
proxy keep the strict resolvers, so prebuilt images still leave unset
upstreams unproxied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:02:46 +08:00
Bohan Jiang
8d18d3a9ec Revert "MUL-5180: fix(github): surface CI status on PR cards (#5811)" (#5855)
This reverts commit 139cc89200.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 02:52:22 +08:00
Bohan Jiang
ffa8e16369 MUL-5228 fix(usage): bill Grok at xAI's reported cost, fix $0 resumed sessions (#5841)
* fix(agent): attribute Grok usage from the turn's own model id

A resumed Grok session with no configured model recorded its entire spend
under the model id "unknown", which matches no pricing row — so the task
reported $0 cost instead of its real spend.

grok.go only learned the model from the session handshake, and ACP's
`session/load` carries no model id (only `session/new` does). When neither
the agent nor MULTICA_GROK_MODEL pins a model, `daemon.go` legitimately
passes an empty model, leaving nothing to attribute the usage to.

Every Grok turn stamps `result._meta.modelId` with what it actually billed
against. Parse it in the shared ACP result parser and use it as the fallback
in grok.go. Other ACP backends are untouched — they keep whatever the
handshake gave them.

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

* fix(metrics): price the Grok catalog in server-side cost metrics

server/internal/metrics/pricing.go carried no Grok rows at all, so
RecordLLMUsage took the unpriced branch for every Grok turn: llm_cost_usd
reported zero Grok spend while the tokens accumulated in
llm_unpriced_tokens. Internal cost monitoring simply could not see Grok.

Add the six SKUs xAI publishes rates for, mirroring the frontend table in
packages/views/runtimes/utils.ts. Aliases are anchored exact matches like
the gpt-5.6 rows, so `grok-composer-*` (in the catalog, absent from the
price sheet) stays unmapped instead of inheriting a guessed rate.

Short-context tier on purpose: xAI bills a request at 2x once its prompt
reaches 200K tokens, but a usage record aggregates every model call in a
turn and cannot say which tier an individual request hit.

A regression test re-derives the cost of a real grok 0.2.106 turn from the
table and checks it against the costUsdTicks xAI returned for that turn.

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

* docs(changelog): scope the Grok cost claim to what was actually fixed

The v0.4.9 entry promised "accurate cost" in all four languages, but the
fix corrected catalog pricing and cached-input double-counting — it did not
implement xAI's 2x long-context tier, so a turn whose requests reach 200K
prompt tokens still under-reports by up to 50%. Say what was fixed instead.

Also correct two stale claims in the pricing comment: the daemon tags usage
rows with the runtime provider `grok`, not `xai` (the bare `grok-*` keys are
what make them resolve), and record why thresholding the long-context tier
on an aggregated row would be worse than not pricing it at all.

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

* feat(usage): carry the provider's own cost through to the usage record

Cost has always been derived client-side as tokens x a static rate, which
cannot express request-level pricing rules. xAI bills a Grok request at 2x
once its prompt reaches 200K tokens, and a task_usage row aggregates every
model call in a turn — so the stored token counts genuinely cannot say which
tier any individual request hit. Thresholding on the aggregate would be worse
than the status quo: it turns a bounded 50% under-estimate into an unbounded
over-estimate for turns made of many short requests.

Grok already reports what it charged, per turn, in `_meta.usage.costUsdTicks`.
Parse it, carry it through agent -> daemon -> API, and store it on task_usage
as a nullable BIGINT of 1e-10 USD ticks (integer, so sub-cent turns stay exact
end to end). NULL means the provider reported no cost — every pre-existing row
and every provider that doesn't return one. No backfill: there is no
authoritative figure to recover for those, and inventing one is the guess this
removes.

A single hourly bucket can mix rows that carry a cost with rows that don't, so
task_usage_hourly gains both halves: `cost_usd_ticks` sums the authoritative
side, and `uncosted_*_tokens` carry exactly the tokens that still need a
rate-table estimate. Consumers report authoritative + estimate(uncosted),
which degrades to today's behaviour when nothing in the bucket is
authoritative. The existing token columns keep covering every row, so token
displays are untouched. The new columns are additive with defaults, so the
unique key, the dirty-queue shape, and migration 102's triggers are unaffected.

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

* feat(usage): prefer the provider's own cost over the rate table

With the authoritative figure now stored, both cost consumers use it: the
usage dashboard (estimateCost / estimateCostBreakdown) and the server-side
llm_cost_usd metric. Each reports `authoritative + estimate(uncosted tokens)`,
so a row or bucket that mixes priced and unpriced sources stays whole.

The static rate tables remain, but for Grok they are now a fallback — they
still price usage recorded by a daemon too old to report cost, and every
provider that reports none. Custom pricing overrides likewise apply only to
the estimated half: they are a user's guess at a rate, and the authoritative
half is not a guess. A model with no rate-table row but a provider-reported
cost now also drops out of the "unmapped models" banner, since asking the user
to supply a rate for it would invite overriding a real bill.

llm_cost_usd is labelled by token_type and the provider reports one number per
turn, so the charge is distributed across the buckets in the rate table's own
proportions. Only the total is authoritative; the split stays an estimate,
which is why this scales the existing buckets rather than inventing a label.
estimateCostBreakdown does the same, keeping the stacked chart summing to the
headline figure instead of silently under-drawing every Grok row.

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

* docs(changelog): say Grok cost now follows xAI's actual charge

The earlier wording scoped the claim down to catalog pricing and cached input
because the long-context tier was still unhandled. It is handled now — the
cost comes from what xAI charged for the turn — so the entry can say so.

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

* fix(usage): keep the provider's cost when the model has no rate row

Both cost consumers bailed out before reading the authoritative figure when
the rate table had no row for the model. A `grok-composer-*` turn — in the
Grok Build catalog, absent from xAI's price sheet — was therefore reported as
$0 spend even though xAI told us exactly what it charged.

Worse on the client: estimateCost returned the real cost while
estimateCostBreakdown returned zeros, so the headline and the stacked chart
disagreed on precisely the rows whose cost is exact — and the unmapped-models
banner was (correctly) hidden, so nothing explained the discrepancy.

Handle the charge before the rate lookup in both places. Without rates there
is nothing to split a total by, so it lands whole in the `input` bucket, the
same fallback distributeAuthoritativeCost already uses when it has no shape to
scale. Tokens with no rate keep going to llm_unpriced_tokens: "unpriced"
describes the rate table, not the money.

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

* perf(usage): drop the historical rewrite from the cost-split migration

Migration 213 rewrote every existing task_usage_hourly row to seed the
uncosted counters. That is a full-table UPDATE inside a schema migration —
lock time, WAL and bloat all scaling with table size — for rows this issue
explicitly does not care about.

Deleting the UPDATE alone would have zeroed historical cost: with
`NOT NULL DEFAULT 0`, an untouched row asserts "nothing here needs
estimating", so every pre-split bucket would report $0 until the rollup
happened to touch it. Make the uncosted columns nullable with no default
instead. NULL means "never recomputed since the split existed", readers
COALESCE it to the row's own token total ("estimate all of it"), and the
pre-split behaviour is preserved exactly — with nothing to seed, so no
rewrite. A bare ADD COLUMN is metadata-only, so this is now fast DDL.

Rows heal into the split naturally as the rollup recomputes their buckets.

Verified on a fresh database: a legacy-shaped row reads back as its full
tokens to estimate, and a group mixing legacy and post-split buckets sums to
the authoritative cost plus both rows' estimable tokens.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 01:42:08 +08:00
Bohan Jiang
139cc89200 MUL-5180: fix(github): surface CI status on PR cards (#5811)
* fix(github): surface CI status on PR cards (MUL-5180)

The CI mirroring pipeline (MUL-2228, MUL-2392) has never received a single
event in production. The GitHub App setup docs only ever asked operators to
grant `pull_requests: read` and subscribe to `pull_request`, so GitHub never
delivered `check_suite` — `handleCheckSuiteEvent` sat dead behind a
subscription nobody was told to enable. Every linked PR reports
checks_passed/failed/pending = 0 and the sidebar row falls through to
"Checks haven't reported yet" forever.

Docs (the root cause), all four locales:
- add `Checks: Read-only` permission + `Check suite` event to the App setup
  table
- drop the stale "CI check states are not modeled" claim, which predates
  MUL-2228 and is what let the setup table stay incomplete
- add a "PR rows show no CI status" troubleshooting entry with the public
  `/apps/<slug>` probe to confirm what an App is actually subscribed to, and
  a warning that existing installations must accept the new permission
  before any `check_suite` is delivered

UI:
- give the actionable status kinds (checks failed/pending/passed, conflicts,
  ready) their own icon + color. CI outcome previously rendered as plain
  muted 11px text, visually identical to the diff stats beside it — a failing
  build read the same as "+437 −6 · 6 files". Terminal and unknown kinds stay
  muted; the row's state icon already carries that meaning.

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

* fix(github): unbreak docs build, stop overclaiming CI completeness (MUL-5180)

Both must-fixes from review.

1. docs production build failed. `<your App>` in prose was parsed as a JSX
   tag, so `pnpm --filter @multica/docs build` died with `Expected a closing
   tag for <your>`. Dropped the angle brackets. Repo CI never caught this
   because no workflow runs the docs production build — only Vercel does,
   which is why the PR's GitHub checks were green while the deployment
   errored.

2. `Checks: Read-only` cannot support the pending status the docs promised.
   GitHub's webhook contract delivers `check_suite.requested` /
   `.rerequested` only to Apps holding Checks *write*; read-level access
   receives `completed` only. Verified against GitHub's published docs.

   Direction chosen: keep read-only, degrade honestly to final-results-only.
   Checks *write* is a repo-write capability (create/update check runs), not
   a wider read — escalating every installation to it just to render an
   in-flight spinner is not a trade to make on the operator's behalf, and it
   contradicts the integration's read-only posture.

   The concrete bug this leaves is premature green: with two reporting apps,
   the first to complete makes total=1/passed=1 and the row claimed "All
   checks passed" while the second was still running and might fail. Copy is
   now "Checks passed" in all four locales — it reports what reported and
   never asserts completeness. `derivePullRequestStatusKind` documents why.

   Docs gain a "what CI status can and cannot tell you" section (all four
   locales) with the read-vs-write delivery table, both consequences stated
   plainly, and the opt-in path for teams that do want in-flight status: set
   Checks to Read and write on their own App and the existing pending code
   lights up with no code change. The pending promise is removed from the
   read-only setup path.

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

* fix(github): ignore non-completed check_suite actions (MUL-5180)

Review was right: the `Read and write` opt-in the previous commit documented
does not produce reliable pending, and following it would break the card.

`check_suite.requested` / `.rerequested` are not observations that some CI
provider started. GitHub sends them only to Apps holding Checks write, and
per the CI-checks App docs they mean "GitHub has created a check suite for
YOUR app on this commit; now add your check runs to it".

Multica observes other apps' results and never creates check runs. Recording
such a suite parks a `queued` row nothing can ever complete, and since
`checks_pending` outranks `checks_passed` in derivePullRequestStatusKind, one
stuck row freezes every PR on that installation at "checks running" and hides
the real pass/fail result. Any self-hoster who already grants Checks write
hits this on every push, so the gate is on the action, not the permission.

- handleCheckSuiteEvent drops every action except `completed`, with the
  reasoning and the "don't resurrect requested as a running signal" warning
  recorded at the gate.
- TestWebhook_CheckSuite_QueuedCountsAsPending encoded the wrong delivery
  semantics (two external apps sending `requested`, which GitHub never does).
  Replaced by TestWebhook_CheckSuite_NonCompletedActionsIgnored, which pins
  the drop and checks a later `completed` suite still lands.
- The two out-of-order stash tests used `requested` payloads to exercise
  paths that are really about completed suites; both now use `completed` and
  assert the same guarantees.
- Docs (four locales): the write opt-in is gone. In-flight CI is documented
  as unsupported at any permission level, with the actual reason and the note
  that real running status needs polling or a check_run model instead.

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

* fix(github): make legacy non-completed check suites inert (MUL-5180)

Review was right again: the previous commit gated the webhook entry point but
left the pre-upgrade state — and the people it was meant to protect (self-
hosters who already granted Checks write) are exactly the ones holding it.

Two leftovers, both now closed:

1. Rows already in github_pull_request_check_suite. The old handler stored
   GitHub's `requested` suites as `queued`; nothing will ever complete them.
   ListPullRequestsByIssue still counted them, so `checks_pending` kept
   outranking `checks_passed` and the PR stayed pinned to "checks running"
   for as long as its head SHA stood. The aggregation now selects only
   `completed` suites.

   Filtering beats deleting here: recovery is automatic on deploy, needs no
   migration over a table that can be large, and holds for any writer that
   misses a gate — not just for today's legacy rows. DISTINCT ON runs after
   the filter, so an app whose newest suite is a stuck `queued` still reports
   its most recent completed verdict instead of disappearing.

2. Rows already in github_pending_check_suite. replayPendingCheckSuitesForPR
   is a second write path into the live table that never passes through
   handleCheckSuiteEvent, so the next `pull_request` event would re-inject a
   permanently-queued suite after the fix shipped. It now skips non-completed
   rows; the drain is DELETE ... RETURNING, so skipping discards them.

Both are covered by regression tests that seed the legacy row directly — the
fixed handler can no longer produce one — and both were confirmed to fail
with their respective fix reverted. The stash test additionally asserts its
fixture landed under the repo address the drain keys on; the first draft used
the wrong owner and passed vacuously.

Also corrects the aggregateChecksConclusion doc comment, which still
described "pending" as a not-yet-completed suite. It is now reachable only
for a completed suite carrying a null conclusion, and is explicitly not a
"CI is running" signal.

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

* test(github): assert the legacy stash row is consumed by the drain (MUL-5180)

Review nits.

The stash test proved its fixture existed before the webhook but never that
the drain consumed it, so a future change to firePullRequestWebhookWithHead's
repo address would make the assertions pass for the wrong reason again — the
same way the first draft of this test did. Asserting the stash is empty
afterwards closes that gap from the other side.

Also fixes two comment typos: `an "CI is running"` -> `a`, and drops the
"merged-but-open PR" state, which cannot exist.

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-23 21:23:25 +08:00
Multica Eve
f51e019788 docs(changelog): add v0.4.9 release entry (2026-07-23) (MUL-5219) (#5832)
* docs(changelog): add v0.4.9 release entry (2026-07-23)

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

* chore(web): bump version to 0.4.9

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-23 17:40:38 +08:00
Bohan Jiang
71fb9c71df docs: document the Windows Defender false positive on the bundled CLI (#5829)
Windows Security flags multica.exe inside app.asar.unpacked as
Trojan:Script/Wacatac.B!ml and quarantines it. The !ml suffix is a
machine-learning heuristic verdict, not a signature match: our Windows
builds are not Authenticode-signed (release.yml sets
CSC_IDENTITY_AUTO_DISCOVERY=false and no certificate is configured), and
an unsigned, low-prevalence Go binary that spawns background processes
and opens sockets is the classic false-positive profile.

Add a Desktop docs section (en/zh/ja/ko) covering how to verify the
binary against checksums.txt, restore it from quarantine, exclude both
the install directory and %APPDATA%\\Multica (Desktop re-downloads the
CLI there, so excluding only the install dir loops), and report the
false positive to Microsoft.

MUL-5216

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 17:15:15 +08:00
Bohan Jiang
1fef98c24f fix(desktop): open in-app links in a tab instead of the browser (MUL-5208) (#5826)
* fix(desktop): open in-app links in a tab instead of the browser (MUL-5208)

A link written as an absolute URL on this deployment's own origin
(`https://<app-host>/acme/issues/1` — what "copy link" produces, and what
agents paste into chat) fell through openLink's external branch to
window.open, which Electron routes to shell.openExternal. Clicking an issue
link in Desktop chat therefore opened a browser window instead of a tab.

openLink now resolves such a URL back to its in-app path and takes the same
route a relative path does. Backend-served prefixes (/api/, /_next/) stay
external so attachment downloads keep working.

Two supporting fixes the change depends on:

- The `multica:navigate` event had no listener on web, so in-app paths in
  content were dead links there; normalizing app URLs would have extended
  that to every pasted app URL. The web platform layer now answers the event
  with a router push.
- The desktop handler opened every path inside the active workspace's tab
  group. A cross-workspace link now goes through switchWorkspace, matching
  what the navigation adapter already does for pushes.

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

* fix(desktop): scope in-app link conversion to workspace pages, answer it in issue windows

Review follow-ups on MUL-5208.

1. The non-page exclusion list only covered /api/ and /_next/, so a
   same-origin /uploads/* link — local-storage attachments, served by the
   backend and proxied by web — was routed as an app page, opening a dead tab
   instead of the file. Replaced the deny-list with the app's own routing
   model: an absolute URL converts to an in-app path only when its first
   segment is a slug a workspace could own, which the existing reserved-slug
   list (shared with the backend) already answers. /api, /uploads, /_next,
   /favicon.ico and the pre-workspace routes all stay external without a
   second list to keep in sync.

2. A dedicated issue window derives the same app origin from its adapter but
   had no multica:navigate listener, so a same-origin link there became a
   silent no-op (it used to reach the browser). The window now answers the
   event: another issue opens in place — matching what its adapter push and
   mention chips already do — and any other app page, which this single-route
   window cannot host, goes to the browser.

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

---------

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 16:50:23 +08:00
Bohan Jiang
f9bc845d11 docs(self-host): add Upgrading section to the self-host quickstart (MUL-5190) (#5814)
* docs(self-host): add Upgrading section to the self-host quickstart (MUL-5190)

The quickstart had no upgrade section — the only mention of upgrades was a
side note that migrations run automatically. Community users had to be walked
through it by hand every time, and the two circulating recipes (`make selfhost`
vs bare `docker compose pull && up -d`) were never documented as equivalent.

Adds an `Upgrading` section between Step 7 and the Kubernetes section covering:

- both command forms, and that `make selfhost` is a wrapper around them
- what `git pull` actually updates (the compose file, not the image version)
- the `MULTICA_IMAGE_TAG` pinning trap — pinned `.env` silently blocks upgrades
- `.env` is only generated when missing, never overwritten
- Postgres backup before upgrading
- migrations run on backend startup, with the log command to watch them
- verify with `/readyz`, not `/health` (liveness passes on failed migrations)
- cross-reference to the existing Kubernetes upgrade path instead of duplicating

Synced to the zh / ja / ko translations.

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

* docs(self-host): fix upgrade guidance for k8s pull policy and pg_dump (MUL-5190)

Review follow-ups on the new Upgrading section.

1. The Kubernetes upgrade path was wrong. Both the new section and the
   pre-existing Kubernetes section presented `kubectl rollout restart` as a way
   to pull new images, but the chart ships `pullPolicy: IfNotPresent`
   (deploy/helm/multica/values.yaml), so a node that already cached the tag
   reuses the old image and the restart silently changes nothing — the same
   failure mode as a pinned MULTICA_IMAGE_TAG. `helm upgrade` with an updated
   `images.*.tag` is now the primary path; the floating-tag route is documented
   only with the `pullPolicy: Always` prerequisite it requires.

2. The Postgres backup masked `pg_dump` failures. `pg_dump … | gzip > out.gz`
   takes the pipeline's exit status from gzip, so a failed dump exits 0 and
   leaves a valid, empty 20-byte archive. Now redirects to a file first so
   pg_dump's own status gates compression via `&&`, with a callout explaining
   why.

3. Dropped the `Makefile:79-107` / `Makefile:80-95` line references. The
   selfhost target actually spans 79-133 and does more than pull + up (creates
   .env when missing, waits on /health, prints a status summary). Reworded to
   "same result on an existing install" and referenced the target by name so
   the claim cannot drift with line numbers.

Applied across all four language versions.

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-23 11:21:19 +08:00
Jiayuan Zhang
257e5e4363 fix(desktop): make dev diagnostics best effort (MUL-5148) (#5794)
* fix(desktop): make dev diagnostics best effort

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

* test(desktop): cover the destroyed dev-log sink guard

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 01:51:45 +08:00
Jiayuan Zhang
e8746c5030 fix(onboarding): eliminate Desktop runtime-step false-negative flash + parallelize daemon version detection (MUL-5119) (#5756)
* perf(daemon): parallelize runtime version detection during registration (MUL-5119)

Registration probed each agent CLI's `--version` serially, so total latency
was the sum of every probe. On an onboarding host with several coding tools
installed that stacked into many seconds before runtimes registered — long
enough that the desktop runtime step timed out into its empty 'no runtime
found' state while the daemon was still working.

Fan the probes out with a bounded errgroup so total latency tracks the slowest
single probe instead of their sum. Each probe still self-heals a vanished
pinned path and re-detects the live version (no cross-registration caching, so
an in-place upgrade is still reported correctly); failures are logged and
skipped as before. Results are sorted by provider for a deterministic payload.

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

* fix(onboarding): stop the runtime step flashing 'no runtime found' while the daemon probes (MUL-5119)

The runtime step flipped from scanning to the empty 'no runtime found' state on
a fixed 5s wall-clock, so a machine that does have coding tools installed saw a
false-negative flash whenever registration outlasted the timeout (cold start,
slow/wedged CLI, many CLIs).

Gate the empty flip on a desktop-only `runtimesPending` signal derived from the
local daemon's live status (booting, or running with agent CLIs detected on the
host): while pending, keep the scanning skeleton past the soft timeout. An
absolute hard-timeout ceiling still guarantees a fallback so a wedged probe
can't pin the step on the skeleton forever. Web omits the signal and keeps the
plain wall-clock timeout.

Also drop the two dead/duplicated affordances on the step: the permanently
disabled 'Start exploring' button now renders only in the found phase, and the
empty state's duplicate footer 'Skip for now' is removed in favour of its own
Skip card.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 01:29:47 +08:00