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>
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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
* 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>
* fix(squads): align parent issue status ownership with agent-managed model
Squad leaders now open assigned parents to in_progress on first dispatch, keep them there while members work, and only move to in_review when overall completion is confirmed—matching ordinary agent status semantics without server auto-flips.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(squads): scope leader parent-status ownership to squad-assigned issues
Review follow-up on the parent-status alignment change. Two boundaries were
left ambiguous, both of which the change's own premise ("don't make the model
resolve a contradiction in the prompt") argues should be closed in-place.
1. Status ownership was granted too widely. The leader briefing is injected on
every leader path, keyed off is_leader_task — including the MUL-3724 case
where an issue is assigned to a plain agent and a squad was merely
@mentioned for help. The unqualified "Own the parent issue status"
responsibility therefore also reached guest leaders, who could push another
assignee's in-flight issue to in_review.
buildSquadLeaderBriefing now takes ownsIssueStatus and selects between two
variants of responsibility 6: the grant only when the issue's assignee is
this squad, otherwise an explicit "do NOT change this issue's status".
Quick-create passes false — no issue exists on that turn. Everything else in
the protocol (roster, delegation, evaluation) is unchanged for both.
2. The comment-triggered path still contradicted itself. The runtime brief says
"do not change status unless the comment explicitly asks", and a member's
delivery comment never asks. Squads that dispatch by @mention create no
child issues, so no child-done system comment exists to carry the explicit
ask either — that parent would sit in in_progress indefinitely.
writeWorkflowComment now names the protocol responsibility as the one
exception for squad leaders. It is safe to state unconditionally because the
grant is only present in the instructions when the server decided this squad
owns the issue; for a guest leader the sentence has nothing to activate.
Tests: two composition tests assemble both halves (server-side briefing +
daemon-side CLAUDE.md) for one real scenario each, since asserting each half
alone is how the original contradiction shipped. Plus execenv coverage that the
carve-out appears only for leaders and the ordinary-agent rule stays absolute.
Docs and the multica-squads skill / source map record the narrower contract.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(agent): inject --yolo in Qwen headless runs so shell/edit/write tools are available (Fixes#5743)
Qwen Code's non-interactive mode (`-p … --output-format stream-json`) uses a
fail-closed approval policy: it silently drops `run_shell_command`, `edit`,
`write_file`, and `monitor` from the tool registry unless bypass mode is
active. Every other Multica-supported coding adapter already injects its
equivalent permission flag as a daemon-owned argument (e.g. Claude uses
`--permission-mode bypassPermissions`, Grok uses `--always-approve`, Qoder
uses `--yolo --acp`). Qwen was the only exception.
Changes:
- `buildQwenArgs`: append `--yolo` after the protocol flags and before any
custom args so headless daemon runs always receive the full tool set.
- `qwenBlockedArgs`: add `--yolo`, `-y`, `--approval-mode`, and
`--allowed-tools` as daemon-owned flags that are stripped from custom_args.
This prevents users from accidentally or intentionally disabling bypass mode
or narrowing the allowed tool set via per-agent settings. `--exclude-tools`
is intentionally left unblocked so users can still hard-deny specific tools.
- `TestBuildQwenArgsKeepsProtocolManaged`: extend with the new blocked flags
and assert daemon-owned `--yolo` appears exactly once.
- `TestBuildQwenArgsYoloAlwaysPresent`: new test asserting `--yolo` is present
even when `ExecOptions` carries no custom args.
* fix(agent): correct Qwen permission args and docs
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(nav): derive route icons from the URL across all nav surfaces (MUL-4370)
The same route rendered different icons in the sidebar and the desktop tab
bar because the mapping was maintained in three places. Projects had no tab
icon at all; autopilots/chat/squads/usage fell back to ListTodo.
Establish one contract instead: `@multica/core/paths` maps a route segment to
a stable icon *name* (React-free), and `@multica/views/layout` maps that name
to a Lucide component. Every nav surface — sidebar, desktop tab bar, and the
search palette — resolves through `routeIconForPath(path)`, so a route cannot
render two different icons.
Crucially the icon is now derived, not stored. `TabSession.icon` is removed,
so persisted tab state can no longer hold a stale icon name: a user who had
an /autopilots tab from an older build gets the correct icon after upgrade
rather than the one that was persisted. Legacy `icon` values in v4 payloads
are ignored on rehydration and dropped on the next write.
Builds on the design in #5204 by LiangliangSui.
Tests: stale/unknown/absent persisted icon on rehydration, derived icon
rendering per route in the tab bar, name→component registry totality, and
nav-route icon coverage.
Co-authored-by: multica-agent <github@multica.ai>
* feat(desktop): tab presentation by object identity, not route segment (MUL-4370)
Replace the "route segment → icon" tab mapping with a semantic Tab
Presentation Contract: a tab's leading visual and title are derived live
from its URL + the query cache, so a tab shows *what it points at*, not the
module it lives under.
- core `parseTabSubject(url)` classifies a URL as page / resource / actor /
container (inbox, chat) / flow / unknown, purely (no React, no Lucide).
- core `resolveTabPresentation(subject, data)` maps that + cached entity data
to a leading visual (issue StatusIcon, ProjectIcon, ActorAvatar, or a type
icon) and a title spec. Exhaustive: a new route forces an explicit choice.
- views `useTabPresentation` reads the cache (enabled:false, no fetch from the
tab bar) and `ResourceLeadingVisual` renders it in a fixed 16×16 slot.
- Containers keep their icon; only the title tracks the selection
(`/inbox?issue=`, `/chat?session=`), so an inbox-opened issue reads
differently from a direct issue tab.
- Titles are plain text; the project 📁 and autopilot ⚡ glyphs are dropped
from document.title.
- Pin no longer replaces the resource visual. Persisted `tab.icon` cleanup
from the prior revision is kept; the active tab persists its resolved title
as a first-frame fallback (the document.title→observer path is removed).
Supersedes the route-segment approach in #5204 per the agreed PRD. No schema,
API, or migration changes; reuses existing queries/caches.
Tests: table-driven parseTabSubject over every desktop route; the
URL/data → visual+title matrix incl. pending/loading, containers, unknown,
runtime custom-name; views cache-integration; tab-bar pin + active-tab
title persistence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): archived inbox title sync + attachment filename in tab (MUL-4370)
Addresses two PRD gaps from review:
1. Archived Inbox selection now syncs the tab title. `parseTabSubject` captures
`?view=archived` on the inbox subject, and the presentation hook resolves the
selection against `archivedInboxListOptions` (its own cache, the one the
InboxPage populates) instead of only the active list. An
`/inbox?view=archived&issue=<id>` tab now shows the archived item's title —
issue (`identifier: title`) or non-issue (display title) — and, being purely
URL+cache derived, restores correctly on refresh. Previously it fell back to
"Inbox" and persisted that wrong title.
2. Attachment tabs use the filename. `parseTabSubject` captures the `?name=`
the preview route already carries; the resolver shows the filename as the
title and picks a file-type icon from its extension (image/video/audio/
archive/code/text), falling back to the generic File glyph + "Attachment"
label only when the name is missing.
Tests: parseTabSubject archived/name cases; the presentation matrix for
attachment filename/extension + missing fallback and iconForAttachment edge
cases; views cache-integration for archived issue/non-issue selection (must
resolve against the archived list, not the active one) and attachment filename.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The local_directory section read as a neutral alternative to github_repo,
so users with ordinary clonable repos were picking it and then asking for
worktree-based parallelism — which contradicts the mode's whole premise.
- Add an up-front warning: this exists for people with no other option
(tens-of-GB game checkouts), and github_repo gives unlimited concurrency.
- Replace the "fine-grained changes, review locally" recommendation with
the real qualifying cases (clone too expensive, not a git repo at all,
machine-local state) plus an explicit list of non-reasons.
- State that serial execution is a premise of the mode, not a limitation
pending a worktree fix.
- Add a pointer at the top of "Attaching a local directory".
Applied to en / zh / ja / ko.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): commit staging env with explicit VITE_APP_URL (MUL-5025)
Staging desktop dev relied on a hand-maintained local .env.staging, where
the web-origin var was misnamed VITE_WEB_URL. Desktop only reads
VITE_APP_URL, so appUrl silently fell back to the API-origin derivation
and copy-link URLs pointed at multica-api.copilothub.ai (404).
Track apps/desktop/.env.staging with the correct names (mirroring the
mobile precedent), and fill in mobile's EXPO_PUBLIC_WEB_URL now that the
staging web host is committed rather than living on a teammate's machine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* chore(desktop): mark staging envs internal, clarify VITE_APP_URL semantics (MUL-5025)
Per review: state in both tracked .env.staging files that the staging
environment is internal (not a public support target), and spell out that
VITE_APP_URL is the web app origin for copy-link/open-in-browser URLs,
never the API host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): add v0.4.4 (2026-07-17) release entry
Adds today's user-facing changelog entry across en/zh/ja/ko: a new autopilot schedule editor, an interactive diagram viewer, plus the day's improvements and fixes.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): use 自动化 for autopilot in zh, matching the standard term
Per Bohan's review: the Chinese standard for autopilot is 自动化 (as used elsewhere in zh.ts, e.g. 定时自动化 / 触发式自动化), not 自动驾驶. Normalizes all 10 occurrences in the changelog, including the new v0.4.4 entry and earlier historical entries, for consistency.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): anchor the working chip on issues the filter actually shows (MUL-4884)
The header chip showed three units at once: the number counted distinct
issues, the avatar stack counted agents (with a rival "+N"), and the hover
card counted tasks — under a label with no noun at all. Each figure was
self-consistent; together they read as a miscount. c4209ec7c flipped the
number from agents to issues but left the other three surfaces on their old
units.
The chip now says one thing — "N issues in progress" — where N is the number
of rows clicking it leaves.
Data layer:
- The count is no longer re-derived from the task snapshot. The surface
exposes `workingScopeIssues`: the render pipeline's own output with
`workingOnly` forced on, so "chip count === row count" holds by
construction instead of by convention. It previously counted running
issue_ids against the PRE-filter set, so any active status/assignee/label
filter — or a sub-issue hidden by the display toggle — made the chip
disagree with the list it was filtering.
- Chat/autopilot tasks carry issue_id "" (not null). They used to collapse
into one bucket and read as a phantom issue, inflating the count by exactly
one whenever any were running. They are now bucketed out and disclosed.
- The list loads one page per status (50), so running work can exist past the
window. The count stays list-anchored — counting rows a click cannot show
would break the chip's whole promise — and the gap is stated in the hover
card rather than dropped silently.
UI:
- Avatar stack is ambience, not a statistic: no "+N"; the tail fades and the
exact roster lives in the hover card.
- Hover card groups rows by issue, names both counted units in its header
("3 issues in progress · 4 tasks"), and footnotes anything excluded — only
when non-zero.
- Colour is two-step: idle activity is a brand tint, the filled state is
reserved for "filter is ON".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): scope the working chip to the rows swimlane and gantt actually draw (MUL-4884)
Review found two modes where `workingScopeIssues` still came from a
different set than the one on screen — the exact drift this change set is
meant to remove.
Swimlane: scoped to the statusless `swimlaneIssues`, but SwimLaneView draws
its cards from `issues` (status filter applied) and only uses the statusless
set for LANE DISCOVERY. A status-filtered swimlane counted rows the canvas
never drew. Swimlane needs no branch at all — it renders the flat filtered
list like board and list do — so the special case is gone rather than
corrected.
Gantt: the canvas applies two rules of its own before drawing — a row needs a
date to be placed, and done/cancelled hide unless `ganttShowCompleted` is on
— so a done issue with a running agent counted toward the chip while the
canvas refused to draw it.
Those rules now live in the surface (`ganttCanvasRows`) instead of privately
inside GanttView, and both `filteredGanttIssues` and the working scope go
through them. Mirroring the rules in a second place would have reproduced the
original bug with extra steps; hoisting them means the chip narrows the same
set the canvas draws, by construction. GanttView goes back to being a
renderer: it orders rows, it does not decide which ones exist.
Regression tests cover both, and each was confirmed to fail against the code
it guards: swimlane asserts the statusless lane source still holds the wider
set, so a revert fails loudly rather than passing by luck; gantt covers the
hidden done row, the undated row, and the show-completed toggle widening both
the canvas and the scope together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): anchor the working chip on agents, and give it real colour tiers (MUL-4884)
Follows the approved final mock. Two changes, one root cause each.
SEMANTICS: the chip now counts agents, not issues.
The original bug was "+1" (agents) sitting next to "3" (issues) — two units
in one control. Anchoring on issues fixed which number was authoritative but
left the units mismatched, so the avatar stack had to lose its +N and the
label had to grow a noun to suppress the contradiction. Counting agents makes
the number and the heads beside it the same list: the mismatch is gone rather
than managed. It also settles the subject — only agents emit a runtime
signal, so "N agents working" cannot be misread as "N issues someone is
working on", which "N issues in progress" invited.
Accepted trade-off: the number no longer predicts the row count of the click.
One agent on two issues reads "1 agent working" and opens two rows. The chip
answers WHO, the list answers WHERE — different questions, so they don't
compete, and the hover card's issue grouping shows the mapping. The
workingScopeIssues pipeline stays: it still decides WHICH agents count.
The hover card drops both "not counted" footnotes. They explained an absence
the user never perceived — chat/autopilot runs leave no row, no head, no
indicator on this page — so they invented a discrepancy instead of resolving
one. The empty-issue_id guard stays: that is data correctness (those agents
aren't on any issue), independent of whether we narrate it.
Avatar overflow returns to the component's standard +N badge; the fade
variant is deleted. With an agent-anchored number, "3 shown + 1 = 4"
corroborates the text instead of competing with it.
COLOUR: three tiers, each a self-contained Button variant.
Layering brand classes over `outline` could never work in dark mode:
`outline` ships dark:bg-input/30 / dark:border-input / dark:hover:bg-input/50,
tailwind-merge keeps them (different modifier group), and they win the
cascade — `dark` compiles to `&:is(.dark *)`, so they outrank a bare
.bg-brand on specificity, not just order. Both brand tiers were dead in dark;
--brand-foreground equals --foreground there, so filter-on and filter-off
rendered pixel-identical.
New `brand` / `brandSubtle` variants own every state instead: hover, pressed,
and aria-expanded pinned to the hover value so opening the popover doesn't
read as a colour change. `brand` needs no dark: at all — the token flips per
theme. `brandSubtle` runs one notch hotter in dark, where the same alpha
reads weaker.
Verified against the compiled stylesheet rather than by asserting class
strings — a string assertion is what let the dark bug through. A cascade
check resolves each tier x {default,hover,pressed,popover-open} x
{light,dark} by specificity and source order; all 16 brand cells land on
their intended notch, identical in both themes for `brand`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): stop the empty-state muted text from overriding the brand tier (MUL-4884)
Review caught the colour rule breaking itself, and the test that was supposed
to guard it being unable to.
`filter ON + 0 agents` is a real state — the filter stays on after the last
agent finishes. There the variant is `brand`, and the chip appended
`text-muted-foreground` for the empty state regardless of the filter.
tailwind-merge keeps the last class in a group and `className` is merged
after the variant, so the muted grey WON: grey text on a brand-blue fill. The
irony is exact — this change set exists because colour classes in `className`
lose to a variant's `dark:` chain, and here one beat the variant instead.
Either way the lesson is the same: a tier's colours only ever come from its
variant. `chipAppearance` now makes that a rule with one gated exception
instead of an inline ternary, and says why.
The variant assertion could not catch this: the variant IS `brand` while the
text is overridden. That is the same shape of false confidence as the class
-string assertion it replaced, so this commit brings the cascade check into
the repo instead of leaving it as something a commit message claimed.
`apps/web/app/brand-variant-cascade.test.ts` compiles the real globals.css —
web's and desktop's, since each defines its own `dark` variant — and resolves
what a browser would paint for the merged class strings: filter declarations
matching the element's classes and state, then take the winner by
specificity, then source order. It asserts both tiers across default / hover
/ pressed / popover-open x light / dark, and pins the two ways the colour has
actually been lost as executable failures: layering brand over `outline`
(dark:bg-input/30 wins by specificity) and appending a colour class (wins by
merge order).
Both new guards were confirmed to fail against the code they guard: reverting
the gate fails the chipAppearance test; giving `brand` a dark: rule fails the
cascade test in both bundles.
Also refreshes the comments still describing the retired issue-anchored
invariant ("chip count === row count"). The scope pipeline they describe is
unchanged and still load-bearing — it decides WHICH agents count — but the
chip's number is agents, so it is no longer this list's length.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Agents were writing runtime-local paths into deliverables as clickable
links (`[screenshot](/Users/agent/work/shot.png)`). Two root causes, both
fixed here.
A. The brief never stated the delivery contract. Add an always-on delivery
invariant (outside writeOutput's kind switch, so no task kind can inherit
none) plus a per-surface file-delivery line for each of the five surfaces.
Chat splits into two: `attachment upload` works only on web/mobile chat,
never on an IM channel, so ChatChannelType is now threaded into
TaskContextForEnv.
The claim path only ever looked up Slack bindings, so a Feishu session
reported as a web chat and got upload guidance for a channel that cannot
carry attachments. Probe every channel type. The chat policy is two
independent layers and stays that way: delivery keys off "is there a
channel at all"; the `chat history` / `chat thread` commands stay
Slack-only because both endpoints are hardwired to h.SlackHistory and
there is no Feishu reader — ChatInThread only selects between those two
commands, so it stays Slack-only too.
Add a CLI hard-fail lint on `issue comment add` / `issue create` /
`issue update` as the enforcement backstop. Scoped narrowly, since a false
positive blocks a real deliverable: agent task context only (a human's PAT
run is untouched), real CommonMark link/image/autolink destinations only
via goldmark (a path in a code span or fence — how an agent quotes a path
it is discussing — is structurally invisible), and three high-confidence
signals only (`file://`, inside the workdir, or an existing local file).
A bare `/foo` is a valid origin-relative URI and is deliberately allowed.
`issue update` has no --attachment flag, so its hint redirects to
`comment add` rather than naming an argument it rejects.
B. Desktop presented the resulting router 404 as an unknown crash. 8 of 18
desktop_route_error reports were users clicking such a link and being told
the app broke and to file a bug. Split the 404 into a first-class Not Found
view: no crash framing, no Report error. Its recovery entry comes from the
tab store's active workspace, never from the failed pathname — deriving a
slug from `/Users/me/shot.png` yields "Users" and a button to `/Users/issues`,
a second 404.
Also add a will-navigate trusted-origin guard via the shared loadRenderer
(main + issue windows). This is origin hardening only, NOT the mechanism for
in-app links: client-side routing never fires will-navigate, so app paths
never reach it. Issue windows need no 404 work — their router only accepts
paths validated by parseIssueWindowPath and they do not listen for
multica:navigate, so a bad path cannot reach them.
Server-side completion observation is metric/log only and never blocks: it
is lexical (`file://` + task work_dir prefix) because the server cannot stat
the daemon's filesystem, and the metric label is a closed enum so no path or
reply text reaches Prometheus.
Verified: pnpm typecheck/lint/test (3582 tests), go vet, full Go suite
including new claim-path integration tests. cmd/multica was verified outside
the daemon workdir — inside one, 93 of its tests fail identically on
origin/main because the suite walks up and finds the runtime's own task marker.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869)
A manual retry (execution-log "retry" button -> POST /api/issues/{id}/rerun)
previously set force_fresh_session=true unconditionally, so the daemon threw
away the failed run's workdir and started from an empty one. For a transient
failure (network blip, provider 5xx / rate-limit, runtime_offline, timeout)
that discards work the agent already did.
New contract: a retry ALWAYS reuses the source task's workdir when it still
exists on disk; only the agent SESSION is gated, on whether the source task's
failure poisoned the conversation.
- RerunIssue derives force_fresh_session from the source task's failure_reason
via resumeUnsafeFailureReason instead of hardcoding true. Transient/cancelled
failures resume the session; conversation-poisoning failures start fresh.
- The daemon claim handler resolves the retry's session/workdir precisely from
rerun_of_task_id, not the most-recent (agent,issue) row that a parallel task
could hijack. PriorWorkDir is returned whenever present; PriorSessionID only
when resume-safe AND on the same runtime.
- force_fresh_session now gates only the session, never the workdir.
- Add agent_error.context_overflow to the resume-unsafe set (Go helper plus the
GetLastTaskSession / GetLastChatTaskSession blacklists): resuming an overflowed
context would immediately overflow again.
Objectively-unreusable cases (workdir GC'd, different runtime, failed before a
workdir was recorded) fall back to a fresh Prepare via the daemon's existing
execenv.Reuse / gateResumeToReusedWorkdir path, so no daemon change is needed.
Tests: RerunIssue force_fresh classification by failure_reason; claim-layer
workdir-always-reused plus session gating (same/different runtime, poisoned);
context_overflow classifier.
Co-authored-by: multica-agent <github@multica.ai>
* fix(task): make manual-retry workdir reuse rollback-safe and error-text aware (MUL-4869)
Addresses code review on #5525.
- Rollback safety: RerunIssue no longer writes force_fresh_session based on the
source failure. Rerun rows are pinned to force_fresh_session=true again, so an
OLD claim handler picked up mid rolling-deploy degrades to a clean start
instead of falling back to the (agent, issue) most-recent lookup and resuming
a *different* execution. The new claim handler ignores the flag for reruns and
computes session reuse from the exact source task (rerun_of_task_id).
- Legacy poison defense: add shared service.ResumeUnsafeFailure(failureReason,
errorText), which combines the failure_reason poison set with the same
400/invalid_request_error raw-error-text guard GetLastTaskSession applies. The
claim handler's exact-source path uses it, so legacy 'agent_error' /
deploy-window rows carrying a 400 marker are no longer resumed.
- CI: TestRerunIssueTargetsSourceTaskAgent passes again (rerun rows stay
force_fresh_session=true). Service test now asserts that rollback-safe
invariant across failure classes; the claim test drives session gating from
the source task and adds a legacy-400 case.
- Docs: tasks.{mdx,zh,ja,ko}.mdx and the GetLastTaskSession SQL comment now
distinguish execution-log per-row retry (task_id: reuse workdir, conditional
session) from CLI/API rerun (no task_id: fresh session + fresh workdir).
- Clarify cross-runtime workdir is best-effort: the daemon offers the source
workdir regardless of runtime (a shared mount may resolve it) and only the
per-cwd session is runtime-gated.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Adds an "Archived" sub-view to the Inbox, reachable from an entry at the
bottom of the main list, with per-row unarchive. Mirrors chat's archived
sub-view so the two surfaces share one mental model.
Backend:
- GET /api/inbox/archived and POST /api/inbox/{id}/unarchive. Kept off the
existing GET /api/inbox so installed clients keep their contract and the
unbounded archive never rides along with the main list.
- The archived query excludes any issue that still has an active row. Archiving
is issue-level, so a new notification on an archived issue leaves old archived
rows beside a fresh active one — without the guard the issue renders in BOTH
lists. The exclusion lives in SQL so neither list depends on the other's cache.
- Unarchive is issue-level (mirroring archive) and leaves `read` untouched, so a
restored unread item raises the unread badge again.
- v1 ships no pagination: LIMIT 200, newest-first, so truncation drops the
oldest rows and never hides a group's newest one.
- inbox:unarchived event, fanned out to the recipient like the other personal
inbox events.
- Two CONCURRENTLY-built indexes; inbox_item previously had none covering
workspace/archived/created_at.
Frontend:
- Separate TanStack cache per list; every inbox event invalidates the workspace
prefix, since any of them can move an item across the boundary.
- View persisted as ?view=archived, so refresh, back/forward, and the mobile
detail-back all return to the list the user was in.
- Batch actions stay main-view only — they archive from the MAIN inbox, so
offering them over the archived list would do the opposite of what it reads.
- Mobile subscribes to inbox:unarchived (its list gains the restored row); its
own archived view remains follow-up.
Known debt: no pagination, so an archive past ~200 rows is truncated silently
in the UI; the entry's count is the deduplicated count of the rows returned.
Verified: pnpm typecheck/test/lint (0 errors), go build/vet, Go inbox suite
against a real Postgres, migrations up+down, and EXPLAIN confirming both new
indexes serve the query.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The tab strip's hairlines used --border, which is authored for near-white
surfaces: its other uses sit on --surface-raised (L=1.0) where it gives a
reasonable dL 0.055, but on --app-shell (L=0.964) it collapses to dL 0.019 --
about a quarter of the 0.068-0.080 the rest of the chrome's 1px keylines run
at, so it read as almost invisible. Move both the tab separator and the
pinned-zone divider to --surface-border, which the surrounding chrome (the
active tab's border, the flare arcs, the content card's ring) already uses,
and which is what the system draws for separators on --sidebar -- a surface
whose lightness is identical to --app-shell.
Also fade a hairline out while either tab it divides is hovered, so it no
longer lingers 2px off the hover pill's rounded edge. The hairline sits on a
tab's own left edge, so the pair's other half belongs to the neighbouring
component instance and no ancestor-based variant can reach it; hence the
adjacent-sibling custom variant.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The Electron desktop app's Linux packages (deb/rpm/AppImage) installed
their launcher binary as `multica`, colliding with the Go CLI binary
of the same name. Whichever won PATH resolution silently shadowed the
other; hitting the Electron binary from a CLI invocation exits 0 with
empty stdout (its own Chromium flags eat args like `--output json`),
which reads as a healthy but empty CLI response instead of "wrong
binary".
Rename the packaged executable to `multica-desktop` so `multica` on
PATH is unambiguously the Go CLI. StartupWMClass is unaffected since
it derives from app.getName(), not executableName.
Fixes#5481
Co-authored-by: Product Engineering Specialist <support@weekome.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): stop routing CodeBuddy skills/memory through Claude's .claude paths
CodeBuddy Code is a Claude Code fork but ships its own native config
directory (~/.codebuddy, .codebuddy/) with its own memory filename
(CODEBUDDY.md). It only reads .claude/skills or CLAUDE.md if a user
manually symlinks/copies them during migration
(https://www.codebuddy.ai/docs/cli/troubleshooting#migrating-from-claude-code).
Multica's daemon/execenv code treated "codebuddy" as an alias for
"claude" in three places, so skills synced by Multica landed in
.claude/skills/ and CLAUDE.md — paths the default CodeBuddy install
never reads — instead of ~/.codebuddy/skills, .codebuddy/skills, and
CODEBUDDY.md as documented at
https://www.codebuddy.ai/docs/cli/codebuddy-dir and
https://www.codebuddy.ai/docs/cli/skills.
Split the "claude", "codebuddy" switch cases in:
- daemon/local_skills.go (user-level local skill discovery/import)
- daemon/execenv/context.go (per-task skill materialization)
- daemon/execenv/runtime_config.go (runtime brief target file)
Added regression tests locking in the new paths and updated the
install-agent-runtime / providers docs (all 4 locales) that had
documented the old .claude/skills behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(daemon): cover CodeBuddy sidecar hygiene and lifecycle
Address PR #5224 review feedback: exclude CODEBUDDY.md/.codebuddy from
repo-cache worktrees, extend sidecar lifecycle matrices to codebuddy, and
make the local-skills CodeBuddy test exercise a true same-key collision.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Eve <eve@multica-ai.local>
* Reapply "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)
This reverts commit c10bfa8f56.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): seed virtualized lists so route-return doesn't flash blank (MUL-4750)
The relanded MUL-4474 virtualization flashed an empty card area (group /
column headers present, rows blank) when returning to /issues or crossing
inbox<->issues. Two stacked blank windows caused it:
1. The scroll element reaches Virtuoso via a callback ref that lands in
state, so the first render after a remount has customScrollParent === null
and the code rendered nothing.
2. Even once mounted, Virtuoso renders 0 rows until its post-paint
ResizeObserver measures the viewport.
Fix both, four surfaces (list / board / swimlane / inbox):
- New shared <VirtuosoSeed> renders a bounded slice of the real rows while the
scroll parent is still null, reusing each caller's own itemContent /
computeItemKey so a seeded row is identical to its virtualized counterpart.
- Pass initialItemCount={Math.min(len, SEED)} so the measurement frame keeps
those rows instead of collapsing to empty.
SEED is capped at 30 and floored by Math.min, so small workspaces
(hasMore=false) and short columns never over-mount — the path that crashed on
real Desktop before. restoreStateFrom (tab-switch Activity restore) is
intentionally out of scope for this round.
Verified: @multica/views tsc --noEmit, eslint, and full vitest (1936 tests)
pass. Real-Desktop route-return / DnD / keyboard / scroll-position regression
pass still owed on device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(issues): defer per-card popup mounting, share one context menu per surface
Tab-switching to a board froze the main thread for seconds: every card
eagerly mounted ~6 popup roots (context menu, pickers, hover cards) plus
per-card query subscriptions, multiplied by seed x columns x remount.
- DeferredPopup: pickers render a pixel-identical static trigger and mount
the real popover on first pointerenter/keydown (Base UI opens on click,
so the warm mount always wins the race)
- AssigneePicker/PriorityPicker/DateOnlyPicker defer when uncontrolled;
AssigneePicker's members/agents/squads/frequency subscriptions now only
start on interaction
- IssueActionsContextMenu: one controlled ContextMenu per surface anchored
at the cursor via a virtual anchor; items delegate (issue, position) up.
Known debt: iOS Safari long-press no longer opens it
- ActorAvatar hover cards warm-mount on pointerenter with a manual
first-dwell timer matching Base UI's OPEN_DELAY
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): stabilize board column scrollbar across mounts
Column scrollbars redrew visibly on every surface mount (route switches,
first open): the seed frame's scroll height covered only the seeded cards,
then Virtuoso spaced out the full count.
- VirtuosoSeed: optional estimatedItemHeight renders a trailing spacer so
the seed frame's scroll height already approximates the full list
- Board columns: seed capped at 10 (one column viewport of ~110px cards,
not the 36px-row-sized generic 30) and the same estimate feeds Virtuoso's
defaultItemHeight so both phases agree until real measurements land
- Columns at <=30 cards skip virtualization entirely and render plainly
(same itemContent), making their scroll height browser-measured truth in
every scenario -- the per-column split Linear ships
(data-virtual-cluster=false for small clusters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): reduce issue detail mount cost
Parse long Markdown in smaller chunks without the duplicate initial sync, defer title and empty composer editors until intent, and keep the description editor eager to avoid layout shifts.
* chore(desktop): add navigation boundary lint rule (MUL-4741 Phase 2 prereq)
The tab Coordinator protocol requires that application code never
navigates directly (invariant 1: a Router location change without a
Coordinator token is a protocol error). Enforce it statically:
- renderer app code may not import useNavigate/Navigate from
react-router-dom nor call router.navigate; src/platform is exempt
- the five known legacy sites (the RFC §8.1 migration checklist,
cross-validated: the rule fires on exactly those and nothing else)
carry inline eslint-disable directives tagged MUL-4741 — the Phase 2
migration removes them one by one, and this rule holding with zero
disables is the machine check that the migration is complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(views): open deferred pickers on click, align triggerRender types
Pointerenter warm-mounting swapped the trigger element mid-gesture: real
browsers re-hit-test so the click lands on the new trigger, but synthetic
pointer sequences (tests, assistive tech) keep dispatching on the detached
node and the first click dies. Upgrade now happens on click/Enter/Space
only — the same timing as Base UI's own trigger — with the in-flight click
stopped so the popup's just-mounted outside-press dismissal doesn't close
it in the same breath.
Also widen triggerRender to ReactElement<Record<string, unknown>> (React
19 defaults ReactElement props to unknown) and mount the
IssueContextMenuProvider in the swimlane test harness like IssueSurface
does in production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(desktop): single-router tab sessions with Coordinator protocol (MUL-4741 Phase 2)
Replace the per-tab-router + <Activity> keep-alive model with the approved
single-router session architecture:
- TabSession: tabs are pure serializable state (url, resourceKey, virtual
history stack, scroll memento). Persist v4 does the one-time legacy
view-state import from v3; mountGeneration is deliberately unpersisted.
- Coordinator (platform/tab-coordinator.ts) is the only router writer: it
reconciles THE app router to the active session URL with navigation
tokens; a location change without a token is a protocol error handled by
bounded recovery (invariant 1). The router history is never used — every
reconcile is a replace; back/forward are session-stack operations.
- ActiveTabHost mounts exactly one tab, keyed on tabId:mountGeneration.
reload() = generation bump + active-scope query invalidation (never
router.revalidate, never a global cache invalidation). Warm switches
restore scroll pre-paint; cold restores pre-size containers from the
memento's saved scrollHeight and settle when data lands.
- resourceKey dedup (pathname only) replaces exact-path dedup: opening
/slug/issues?filter=b focuses the existing issues tab (RFC §8.2,
deliberate semantic change).
- All five §8.1 legacy navigation sites migrated (index <Navigate>, error
page recovery, workspace-layout login bounce, overlay parking, shell
back/forward); their MUL-4741 ratchet eslint-disables are removed, so the
navigation boundary rule now holds with zero exemptions outside
src/platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(issues): register board columns and list scroller for scroll mementos
Per-container scroll registration for the MUL-4741 tab session memento:
board columns key by group id (each column's offset restores
independently, the per-column split Linear ships), the list view keys as
"list". Chat and issue-detail already carry the marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tabs): pull-based scroll restoration fed into virtualized lists (MUL-4741)
Rebuild the restore side of the memento protocol on first principles (the
model Linear ships): a saved offset is an INPUT to the mounting view, not a
post-hoc DOM mutation from outside.
- ScrollRestorationProvider (views/platform): views pull their saved offset
while mounting. Virtualized lists feed it into Virtuoso's initialScrollTop
so the first render already materializes the rows around it — this
replaces the pushed spacer+scrollTop hook, whose foreign spacer deadlocked
against Virtuoso's own height model (restore landed the viewport in
phantom space, Virtuoso rendered nothing, and the spacer's removal
condition could never be met → blank issue detail). Plain containers
assign the offset at ref-attach, pre-paint. Web has no provider and
behaves as before.
- Memento keys gain a route dimension (`${pathname}::${containerKey}`) and
capture now also fires before in-tab navigation, so back/forward restores
each route's own offsets and same-named containers on different routes
no longer collide.
- commitScrollMemento uses REPLACE-per-route semantics: a container
scrolled back to 0 clears its stale offset instead of resurrecting the
old position on the next visit.
- List view gets the same estimate alignment as the board (36px rows into
the seed spacer and defaultItemHeight), which keeps the shared scroller's
height truthful from the first frame so the restored offset sticks.
Known gap: chat's bottom-anchored list captures offsets but has no restore
consumer — intentional, it re-anchors to bottom on mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): make unlabelled code fences plaintext instead of auto-detected
Follow-up to the issue-detail mount work: lowlight's highlightAuto runs
every registered grammar over the full block for code fences without a
language, which dominated mount cost on code-heavy comments. Extract a
shared syntax-highlight module whose auto fallback deterministically
renders plaintext; explicitly labelled languages highlight as before.
Also ignore .gstack/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): lazy-mount column-header popups, right-size swimlane seed
Trace analysis of tab/view switching (30s session): a swimlane mount spent
its largest slice on eagerly-mounted header machinery — ~170 tooltip roots
(one per lane x status cell add-button), 13 column dropdown-menus, and up
to 30 fully-materialized lanes from the generic seed count.
- DeferredTooltip (views/common): renders only the trigger until first
hover, then mounts a controlled Tooltip anchored to the SAME element
(no trigger swap, so mid-gesture events never land on a detached node);
ui TooltipContent grows an `anchor` passthrough for it.
- Board/list/swimlane header add-buttons and hide-column dropdowns now
defer via DeferredTooltip / DeferredPopup (which gains an ariaHasPopup
option for menu triggers).
- Swimlane lane seed drops 30 -> 6 (a lane row is ~300px+; a viewport fits
~3) on both the pre-scroll seed and Virtuoso's initialItemCount.
- openTab gains an `activate` option so "open and focus" paths (pinned-tab
redirect, explicit open-in-new-tab) are ONE store write instead of
openTab + setActiveTab back-to-back — one full subscriber pass per user
action instead of two.
- Dev-only breadcrumb logs IssueSurfaceContent's remount key: the trace
showed the surface mounting twice inside one task, and the my-issues
relation toggle is one confirmed key-flip source; the log ties the next
trace's mounts to exact key transitions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): stop double full-tree render passes on surface interactions
Trace forensics on view switching showed every interaction paying TWO full
surface render passes (React's own Cascading Update marker sits between
them): entering swimlane flips controller-level loading state (loadProjects
enables the projects query), and any such flag flip re-rendered the entire
unmemoized view tree (~600-1000ms dev per pass).
- Memoize BoardView / ListView / SwimLaneView: controller/data outputs are
already useMemo/useCallback-stable, so a controller flag flip now
re-renders the header, not the whole board. The one unstable prop —
BoardView's inline assigneeGroups.flatMap — moves into a useMemo.
- Selection reset on mount swapped the initial empty Set for a NEW empty
Set, buying a guaranteed extra full pass per surface mount; functional
bail keeps the reference when nothing was selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(ui): drive sidebar resize by direct DOM writes, drop motion/react
Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): wire swimlane's outer scroller into tab scroll restoration
Review blocker on #5403: board/list/issue-detail register their scroll
containers with the tab session memento protocol, but the swimlane outer
scroller did not — under the single-router architecture an inactive tab
unmounts, so a deep-scrolled swimlane returned at top after a tab switch
or reload.
Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for
capture, useRestoredScrollRef in the scroller's attach callback for the
pre-paint assignment, and the saved offset into the lane Virtuoso's
initialScrollTop. Regression test asserts both the capture marker and the
restored offset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The four landing "Dashboard" CTAs used href="/" and relied on the proxy
bouncing logged-in visitors from the root path to their workspace. Once #5363
kept "/" public on the official marketing host so logged-in users could browse
the site (#5326), that bounce stopped — and the CTAs began resolving to the
page the visitor was already on, so clicking them did nothing at all.
Resolve the destination in the CTA instead of leaning on a redirect side
effect elsewhere: useDashboardCtaHref() runs the same
resolvePostAuthDestination() that RedirectIfAuthenticated already uses, so
"where the dashboard lives" has one source of truth. This also collapses the
same `user ? "/" : "/login"` line that had been copy-pasted across four files.
proxy.ts and redirect-if-authenticated.tsx are untouched — #5363's behavior is
correct and proxy.test.ts still passes unchanged.
Closes MUL-4794
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Adds `multica workspace create` (--name/--slug/--description/--context/--issue-prefix, JSON/table output). Creation does not switch the current workspace. Both --name and --slug are required to match the server contract, and the slug is immutable after creation. Docs (en/zh/ja/ko) and the CLI reference now show the executable command.
Closes#5055
Add xAI Grok Build (`grok`) as a first-class Multica runtime over ACP
(`grok --no-auto-update agent --always-approve stdio`), reusing
hermesClient like traecli and kimi. Includes daemon discovery,
protocol_family migration 174, model discovery, MCP passthrough,
thinking effort, frontend branding, and product docs.
Follows xAI's documented headless ACP flow: after `initialize`, read the
advertised `authMethods` and send `authenticate` (preferring xai.api_key
when XAI_API_KEY is set, else the cached login token) before any session
operation — a real, logged-in CLI rejects session/new and session/load
without it. Model discovery performs the same handshake so it returns the
live catalog instead of the static fallback.
`--no-auto-update` is passed as a global flag (and kept daemon-owned in
the blocked custom-arg set) so a background update check can't stall an
unattended ACP task. Thinking effort uses the current `--effort` flag,
and the minimum grok version is 0.2.89 (ACP + authenticate + session/load
+ session/set_model + MCP + --effort).
Closes#2895