Commit Graph
1469 Commits
Author SHA1 Message Date
Jiayuan Zhangandmultica-agent a4e1ea7140 MUL-6581: expose globally versioned Plugin Public API (#7446)
* feat(plugins): expose globally versioned public API

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): proxy same-origin public API

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 17:15:46 +08:00
8d2da6b8db MUL-6479 feat(dev): make a local environment a named object with one verb per lifecycle step (#7325)
* MUL-6479 feat(dev): make a local environment a named object with one verb per lifecycle step

Local development had six entry points, thirty-four make targets and no way to
see or delete what they created. `make up` / `status` / `list` / `down` /
`destroy` / `gc` / `env-exec` replace that with one set of verbs, shared by
humans and agents, with `C=api,web,daemon,desktop` selecting components.

Three defects made the old flow untrustworthy rather than merely verbose.

1. The database the tooling created was not the one the application reached.
   `ensure-postgres.sh` creates through `docker exec`; when a native PostgreSQL
   owns 5432 the container never binds the host port, so the create lands in a
   server the backend never talks to. It printed "✓ PostgreSQL ready" and the
   next step died with `SQLSTATE 3D000`. On the machine this was written on the
   two servers had already diverged: 94 same-named databases, 69 more in the
   container with no counterpart. `up` now creates through DATABASE_URL and
   treats a successful `migrate up` — which pings that same string — as the only
   proof; failure diagnoses the port owner instead of reporting a missing
   database.

2. Identity was computed, not allocated. Ports, database names and CLI profiles
   all came from `cksum($PWD) % 1000`, and a collision was silent: the loser
   fails to bind while the winner keeps answering 200. Allocation now happens
   under a lock, probing from the same path hash so a checkout keeps its
   familiar numbers, and requires the slot to be both unregistered and free of a
   live listener. The manifest in ~/.multica/dev/envs/<name>/ is what every
   later command reads.

3. Creating an environment left no record of how to destroy it, so there was no
   destroy — only archaeology. `down` stops processes and keeps the data;
   `destroy` consumes the manifest: processes, database, profile, slot. `list`
   makes leaks visible and `gc` collects environments whose checkout is gone or
   whose TTL passed.

/health now reports pid, commit and started_at. A 200 alone proves something is
listening, which is exactly what let a failed restart keep serving the previous
build; `up` compares started_at against its own launch and refuses an answer
that predates it.

Two constraints are enforced rather than documented: the daemon is started from
a built server/bin/multica, never `go run` (the daemon re-execs its own path for
every task, and `go run` deletes that binary when the launcher exits), and
`C=daemon` under a .multica/daemon_task_context.json marker stops with that
explanation up front instead of spending a login on a refusal.

scripts/dev-env.test.sh covers the registry verbs against a throwaway
MULTICA_DEV_HOME with no services running, and pins the regression that made
`down` exit 1 after reporting success: on bash 3.2 a command substitution whose
function ends in a failing command aborts the script under `set -e`, and "no
process is listening" is that function's normal answer.

`make dev` is unchanged and still runs in the foreground.

Co-authored-by: multica-agent <[email protected]>

* fix(dev): close local environment lifecycle gaps

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 16:43:58 +08:00
bedad9e222 MUL-6485: isolate hosted plugin surfaces (#7329)
* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden surface validation and error reporting

* fix(migrations): renumber plugin package migrations

* fix(plugins): validate classic surfaces and reset failures

* fix(plugins): refresh issue-scoped surface launches

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): refresh issue-scoped surface launches

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make surface termination event-driven

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): refresh issue-scoped surface launches

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make surface termination event-driven

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 13:47:06 +08:00
Martin MontesandMartin Montes 5a80f802d3 MUL-6558 feat(server): make task queue expiry configurable via MULTICA_TASK_QUEUED_TTL (#7418)
Lets self-hosted deployments raise the queued-task expiry window above the built-in 2h default, so low-concurrency runtimes stop losing legitimately-pending work to queued_expired. Default behavior is unchanged. Covers .env.example, Compose, Helm values/ConfigMap, and the behavior docs in all four locales.

Co-authored-by: Martin Montes <[email protected]>
2026-08-23 12:56:06 +08:00
d60775aa93 docs(changelog): add v0.4.32 release entry (2026-08-21) (MUL-6541) (#7402)
* docs(changelog): add v0.4.32 release entry (2026-08-21) (MUL-6541)

Co-authored-by: multica-agent <[email protected]>

* docs(changelog): refine v0.4.32 release copy (MUL-6541)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 17:35:51 +08:00
53e15b35c6 MUL-6502: fix startup recovery during PostgreSQL outages (#7372)
* fix(startup): retry transient database outages

Co-authored-by: multica-agent <[email protected]>

* fix(startup): address database retry review

Co-authored-by: multica-agent <[email protected]>

* test(startup): address retry review nits

Co-authored-by: multica-agent <[email protected]>

* fix(startup): preserve native pgx connect timeout

Co-authored-by: multica-agent <[email protected]>

* fix(startup): apply timeout fallback to services

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 14:33:04 +08:00
YYClaw 5e2d349ec4 MUL-6426: refactor(dingtalk): safely retire group routing and enhance discovery (#7241)
Retire mutable DingTalk group routing: inbound messages always target the installation's configured Agent, and observed groups become read-only inventory in dedicated tables with persisted activity counters.

Migrations 382-389 clean stale cross-Agent bindings, create and backfill dingtalk_group_presence and dingtalk_bot_identity, and install a mirror trigger so writes from an older process during a rollback window are not lost. The legacy dingtalk_group_route table and its indexes are retained for expand/contract; a later release drops the trigger and table together.

Closes #7239
2026-08-21 14:26:48 +08:00
Sebastian San Blas 8febf40970 MUL-6525: docs: document GitHub App Contents permission for PR snapshots (#7369)
Add Contents: Read-only to the self-hosting GitHub App permission table in all four locales, note that it cannot be skipped along with the CI permissions, and list it in the No CI status troubleshooting entry.

Co-authored-by: Sebastian San Blas <[email protected]>
2026-08-21 13:21:07 +08:00
iiwish a3bd38da6a fix(cli): redact autopilot webhook credentials (#7365)
* fix(cli): redact autopilot webhook credentials

* fix(cli): preserve webhook token presence metadata
2026-08-21 12:39:43 +08:00
Eric BerryandClaude Opus 5 ff90875003 fix(web): only let the routed layout own the workspace singleton (#7346)
`app/[workspaceSlug]/layout.tsx` writes the platform workspace singleton from
render. The App Router can keep a previous workspace's layout mounted beside
the incoming one, and both re-render on their own query activity, so the
unguarded write let them alternate — every render flipping the singleton back
to its own slug, indefinitely.

Two things read that singleton and broke with it:

- `packages/core/realtime/provider.tsx` binds the realtime socket to the slug,
  so each flip tore the socket down and rebuilt it against the other
  workspace. Seen on a self-host as a `close 1005` / reconnect pair every
  20-35s alternating workspace, with realtime updates lost for whichever
  workspace the user was actually looking at.
- `packages/views/editor/extensions/mention-suggestion.tsx` reads it
  synchronously when the user types `@`, then looks up the agent and member
  caches by that id. Pointed at the other workspace those caches are cold, so
  the mention list came back empty.

Gate the write on the live pathname. The URL is the source of truth for
workspace identity and `usePathname()` returns one value shared by every
mounted instance, so exactly one layout — the routed one — can define it, with
no ownership handshake. Desktop keeps its own protocol
(`workspace-route-layout.tsx`) because it has no URL bar to appeal to.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-21 00:22:15 +08:00
f737974b74 docs(changelog): add v0.4.31 release entry (2026-08-20) (MUL-6474) (#7314)
* docs(changelog): add v0.5.0 release entry (MUL-6474)

Co-authored-by: multica-agent <[email protected]>

* docs(changelog): reclassify v0.4.31 release (MUL-6474)

Co-authored-by: multica-agent <[email protected]>

* docs(changelog): name what shipped in the v0.4.31 title (MUL-6474)

The v0.4.31 title was three evaluative phrases -- "smoother agent runs",
"safer automation", "reliable Windows builds" -- none of which names a
thing a reader can recognize. House style for changelog titles is 2-3
parallel noun phrases naming the actual feature, page, or surface.

Replace all four locales with the three changes a reader would notice:
the run confirmation before an assigned Issue leaves the backlog, the
Webhook event filters, and the Windows build fix. Terms follow the
existing glossary and each entry body.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 17:42:49 +08:00
814ee0cbf7 docs: remove six finished implementation records (#7258)
* docs: remove the two implementation docs under docs/

The database index audit and the Telegram channel review were one-off
implementation records for MUL-6108 and the Telegram contribution; both
are finished work and neither is referenced by the product docs. Drops
the now-dangling review pointer from the Telegram handoff notes.

docs/assets/ stays — the READMEs load the logos from it.

Co-authored-by: multica-agent <[email protected]>

* docs: remove the Telegram integration handoff notes

HANDOFF-telegram.md was the implementation record for the Telegram
channel contribution and is referenced by nothing. Operator-facing setup
lives in the localized Telegram guides under apps/docs/content/docs/.

Co-authored-by: multica-agent <[email protected]>

* docs: remove three finished implementation leftovers

- apps/mobile/docs/markdown-renderer-research.md: 2026-05 renderer
  selection research and incident log. Its conclusions already live in
  markdown-rendering-adr.md and in the MD_FONT/MD_LINE comments; the HIG
  calibration source is folded into markdown-style.ts so the numbers keep
  their rationale.
- packages/views/locales/glossary.md: a redirect stub. The content moved
  to the docs-site conventions pages, which now say the file is gone
  instead of claiming a stub still points at them.
- scripts/audit-redundant-indexes.sql: the MUL-6108 one-off audit query,
  orphaned once its report was deleted — no caller in Makefile, check.sh
  or CI.

All pointers into the deleted files are updated; nothing dangles.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 17:33:29 +08:00
3268d01172 fix(daemon): move the in_progress start write into workflow step 3 (MUL-6417 follow-up 2) (#7295)
* fix(daemon): move the in_progress start write into workflow step 3; define ancillary by output (MUL-6417 follow-up 2)

Field evidence from MUL-6460: a run that verifiably received the #7283
brief (its workdir CLAUDE.md carries the two-moment rule) walked steps
1→2→3 for over an hour and never issued a single status write. Two
compounding causes, both in the prompt:

- Placement. The start-moment rule lived in the status block BELOW the
  numbered steps. At the moment its condition triggers the model is
  executing the list, and a rule outside the list does not fire — the
  pre-MUL-6417 opening write was reliable precisely because it was an
  explicit step. The conditional write now opens step 3 itself, at the
  read→work boundary.
- The research example. "A todo issue you were asked to research stays
  todo" was written for consult pull-ins, but the MUL-6460 turn's work
  WAS research toward its own issue's ask — the example pattern-matched
  the exact case it was never meant to cover. Ancillary is now defined
  by output (the turn produced none of the issue's own deliverable),
  and research/design explicitly count as work when they are the ask.

Guard tests pin the step-3 placement and the research-counts-as-work
clause, and ban the retired example. Skill, source maps, and user docs
updated to match.

Co-authored-by: multica-agent <[email protected]>

* refactor(daemon): state the status rule as continuous, anchored at the moments that fire (MUL-6417)

Bohan's review of #7295: 'at the end of your work, write the state it
reached' still read as restricting writes to fixed checkpoints. The lead
now states the invariant — keep status true at every point in the turn,
write whenever the work changes it, mid-turn included (a blocker is
recorded when hit) — and the end of the turn becomes a verification
('do not exit with a stale value') instead of the write moment.

The anchors stay. A free-floating 'write when necessary' with no
attachment points is exactly the shape that produced zero writes in the
MUL-6460 incident: permission without a trigger moment does not fire.
Step 3 keeps the start write; the exit check keeps the guarantee that a
turn never ends with a lying board.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): drop activity-word skip-lists; ancillary is decided by output alone (MUL-6417)

Elon's #7295 review: 'review' still sat in step 3's skip clause and the
no-write bullet's example list, replaying the research incident's exact
failure class on an issue whose ask IS reviewing a PR. Activity words
carry no deliverable information in either direction, so both lists are
gone: step 3 names forms of work (code, research, a design, a plan, or a
review when reviewing is the ask), the no-write case is stated purely by
output (the turn produced none of THIS issue's deliverable), and the
rule now says so explicitly — the kind of activity never decides, only
whether the output is part of what the issue asks for.

Guards pin the review-as-work clause and the never-decides sentence, and
ban both retired skip-lists. Skill, source map, and docs synced.

Measured brief delta vs main (claude, production-length UUID): ordinary
13,362 → 13,971; leader 14,467 → 15,076 (+609 each, cached prefix);
per-turn messages unchanged.

Co-authored-by: multica-agent <[email protected]>

* refactor(daemon): adopt J's review — one criterion, anchors in steps, category-scoped skip (MUL-6417)

J's review of #7295, all four points taken:

- The positive form-list that replaced the banned skip-list was the same
  failure shape read as exhaustive (a triage or reproduce-the-bug turn
  finds itself in no list). Both directions of activity-word lists are
  gone; the never-decides sentence moves INTO step 3 — the position that
  fires — instead of the status block, the position this PR itself
  proved dead.
- The exit-side check is anchored inside step 5 ('Before exiting, ...'),
  making the PR's own placement argument self-consistent.
- The step-3 skip is scoped to the in_progress CATEGORY, so a custom
  status like Planning already counts as recorded once MUL-6460 puts the
  status catalog in front of agents — no rewording needed then.
- Tense: 'if any part of what this turn will produce ...' — step 3 is a
  prediction; mispredictions self-heal under the continuous rule.

Plus J's meta-point on guards: presence-pins cannot tell where a
sentence lives, and both field incidents were correct sentences in dead
positions — the anchors are now pinned positionally (must appear inside
the step-3 / step-5 lines), and the retired positive list joins the
banned set. Kept the questions/discussion/acknowledgements clause (J's
conservative option): acknowledgements are not covered by 'answered a
question' and the sentence is the MUL-6300-era invariant other tests
pin.

Measured (claude, production-length UUID): ordinary brief 13,685,
leader 14,790 — +243 vs main, down from +609 at the previous commit.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:09:32 +08:00
cc21d19353 MUL-6463: confirm the promotion that starts an agent run (#7293)
* feat(issues): confirm the promotion that starts an agent run (MUL-6463)

Assigning an agent to an issue opens a confirmation with a handoff note and
"Don't start yet". Promoting a parked issue that ALREADY has an agent owner
starts a run just as directly — the status change alone is what fires
RunSourceStatus — but applied silently from a one-click picker, with no way to
tell the agent anything before it woke up.

The dialog now covers both writes. `RunConfirmModal` gains a `promote` mode
that submits the status change (plus the same optional handoff note, or
suppress_run for "Don't start yet") instead of an assignee change; the owner is
already on the issue, so the promotion payload carries the status and nothing
else.

The gate is the run predicate, not a status allow-list: parked (backlog
CATEGORY) + agent/squad owner + a target outside backlog/done/cancelled. So
built-in `todo` and every custom Todo-category status behave identically, which
is what the report asked for, and so do the other promotion targets that start
a run the same way. Drag-and-drop and the batch toolbar keep applying directly
— the existing split (MUL-4155), not a new one.

Also fixes the assign gate next to it, which still compared the raw status KEY
to "backlog": an issue parked in a CUSTOM backlog-category status popped a
confirm promising a run the server would never start. Both gates now resolve
the category the way the server does (issuestatus.Effective), preferring the
payload's own status_category and falling back to the workspace catalog.

Tests: promotion routing for built-in todo / custom todo / custom backlog
origin / non-todo target, the five no-run status changes that stay direct, the
custom-backlog assign regression, and the modal's promote payloads (note,
suppress_run, no assignee fields) plus its status naming. Verified each fails
without the change.

Docs: the promotion dialog in assigning-issues (en/zh/ja/ko).
Co-authored-by: multica-agent <[email protected]>

* fix(issues): bold the status name in the promotion confirm

"Move this issue to Rework." left the status looking like an ordinary word —
worse for a custom status an admin named something like Later or Blocked-ish.
The headline already had a fence-and-bold mechanism for the assignee name, so
generalize it to bold every fenced span and fence the status too.

CJK copy drops the 「」 it used for the same job: bold is now the one mechanism
across all four locales.

Co-authored-by: multica-agent <[email protected]>

* fix(issues): close the three run-confirm gaps from review (MUL-6463)

1. Server and client disagreed about moving WITHIN the backlog category.
   `WillEnqueueRun`'s status branch only excluded `done`/`cancelled`, so
   `backlog` → a custom Backlog-category status (`Later`) changed the key,
   normalized both sides to `backlog`, and still enqueued a run — while the
   client, which reads categories, applied it silently. Before custom statuses
   the two conditions were the same; now leaving the category is its own
   requirement. Regression test covers both halves: no run inside the category,
   still exactly one on the way out.

2. The table's inline pickers bypassed the gate entirely — `meta.updateIssue`
   went straight to the surface action, so promoting an agent-owned backlog row
   from the table started a run with no confirmation. `meta.updateIssue` now
   takes the issue (the gate needs its category and owner) and routes through
   the same intent.

3. "Unresolvable category" was collapsing into a guess. `catalog.categoryOf`
   answers `todo` for a custom key it has not loaded, which made an unknown
   status read as not-parked and skipped the promote dialog. Category
   resolution is now explicitly three-state — carried, built-in, catalog, else
   null — and both writes that can start a run confirm on null.

The gate itself moves to a pure `runConfirmIntent` next to a canonical matrix
test, so the issue detail, the context menu and the table all route on one
answer instead of three copies; the hook/table suites keep only their wiring.

Docs nit from the same review: the new CJK paragraphs said issue / イシュー /
이슈 where the glossary requires 任务 / タスク / 태스크, and named statuses in
capitalized prose instead of backticked keys.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:03:51 +08:00
popWheatandClaude Opus 5 db0f6c5fe2 fix(web): keep the /favicon.ico redirect relative (MUL-6326) (#7117)
* fix(web): keep the /favicon.ico redirect relative

Response.redirect() only accepts an absolute URL, so the handler resolved
/favicon.svg against request.url. On a self-hosted deployment that carries
the server's own listen address rather than the public origin --
Dockerfile.web sets HOSTNAME=0.0.0.0 / PORT=3000, and a plain `next start`
defaults to the same -- so every client got

    Location: http://0.0.0.0:3000/favicon.svg

which is unreachable, and leaks the internal bind address. The redirects
emitted by proxy.ts are already relative and were never affected.

Emit the Location header directly instead. A relative Location is valid per
RFC 7231 7.1.2 and resolves against whatever origin the client used, so it
needs no trust in Host / X-Forwarded-Host.

Closes #7116

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(web): keep /favicon.ico off the prerender cache path

Review catch on #7117. Reading `request.url` used to mark the route dynamic
implicitly; dropping that argument left nothing dynamic for Next to detect,
so it treated the handler as cacheable and tried to store a zero-byte body.
The prerender LRU rejects that outright, once per request:

    Failed to update prerender cache for /favicon.ico
    Error: LRUCache: calculateSize returned 0, but size must be > 0.

Every browser fetches /favicon.ico unprompted, so that is an error line per
request in every deployment, against a cache write that can never succeed.

`export const dynamic = "force-dynamic"` restores the per-request semantics
the old `request.url` read gave us by accident, without reintroducing the
absolute URL that #7116 is about.

Verified on a production build (`next build` + `next start`), before and
after. Before: `cache-control: s-maxage=31536000`, `x-nextjs-cache: MISS`,
and one calculateSize error per request. After: neither header, zero errors,
and the redirect still resolves to /favicon.svg (200 image/svg+xml).

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-20 15:54:40 +08:00
cb6b0c885f MUL-6396: remove unused task message clipping flag (#7292)
* refactor(realtime): remove unused task message clipping flag

Co-authored-by: multica-agent <[email protected]>

* refactor(realtime): remove task message clipping consumers

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 15:24:15 +08:00
a1d160ee0d MUL-6468: fix(docs): replace dead dimcode.ai link with official Dim CLI docs (#7294)
* fix(docs): replace dead dimcode.ai link with dimagent.cn/docs/cli

The install-agent-runtime guide linked Dim to https://dimcode.ai which
does not resolve. Replace with the official CLI docs at
https://dimagent.cn/docs/cli across all 4 language versions (en/zh/ja/ko).

* fix(docs): use English Dim CLI links for non-Chinese docs

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 15:22:15 +08:00
49ed7f1151 MUL-6459: docs: document custom issue statuses (#7284)
* docs: document custom issue statuses (MUL-6459)

The docs still described the seven statuses as a fixed set. Add a Custom
statuses section to the Issues page in all four locales covering the model
users actually need: the seven categories are fixed and each one IS its
built-in status key, a custom status declares a category at creation and
inherits its platform behavior in full, and that category is immutable
afterwards.

Also state the consequences that are easy to get wrong: board columns are
categories so a new status never adds a column, built-in statuses are
locked, and archiving retires a status for future assignment without
touching the issues already on it.

Two rules elsewhere were written as status rules but are category rules:
the backlog gate on assignment and the auto-archive of run-failure inbox
notifications. Both now say so.

Co-authored-by: multica-agent <[email protected]>

* docs: correct the run-start trigger, category scope, and key wording

Review follow-up on the custom statuses section (MUL-6459).

The run-start trigger was attributed to entering `todo`. The only
status-change trigger is leaving the `backlog` category for anything other
than `done` / `cancelled` (service/issue_trigger.go), so the rule now sits
on the `backlog` row where it belongs.

The claim that every status named in the docs means its category was too
broad, and contradicted the system-changes callout on the same page: the
failed-run rollback writes the literal `todo` key (service/task.go) and a
merged PR writes the literal `done` key (handler/github.go). Scope it to
statuses that TRIGGER behavior, and say the write direction is literal.

Key derivation was described as always coming from the name. The API takes
an explicit key and only derives one when it is omitted
(handler/issue_status.go), and the key never changes on rename.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 14:32:12 +08:00
e10287cf1b MUL-6457 feat(mobile): resolve custom issue statuses by category (#7286)
* feat(mobile): resolve custom issue statuses by category (MUL-6457)

Mobile had no awareness of the workspace status catalog, so the custom
statuses web can now define (MUL-6243) degraded badly on iOS: an issue on a
custom status disappeared from My Issues and the workspace Issues list
entirely, the status picker could not offer one, and filter rows rendered a
raw key or nothing at all. `custom_issue_statuses` is a deployment-level
flag, so creating a status on web hit iOS users of the same deployment
immediately.

The root cause is one mistake repeated: mobile bucketed and labelled issues
by status KEY, while a status key's presentation and behavior come from its
CATEGORY. A `qa` bucket existed and no section ever read it.

- Fetch the catalog once per workspace (`GET /api/issue-statuses`, archived
  included so an issue left on an archived status keeps its real name).
- Group lists by category via `groupIssuesByCategory`, extracted from the two
  screens that held the same fifteen lines — and the same bug — twice.
  Grouping reads the server-resolved `status_category`, so it never waits on
  the catalog and never guesses while one is in flight.
- Offer `statusOptions()` in both the picker and the status filter, so a
  status settable in one can never be missing from the other.
- Resolve name and colour through the catalog wherever a status key is shown
  (detail chip, search, inbox row and detail label, timeline). Built-ins keep
  mobile's own copy and their category token; the glyph is per category, so a
  custom status reads as the same kind of thing.
- Keep `status_category` consistent with an optimistic status write, so the
  detail cache cannot feed a stale category to the recent-issues rows.

A workspace with no custom statuses renders exactly as before: a built-in key
is its own category, so every lookup resolves without the catalog.

Co-authored-by: multica-agent <[email protected]>

* fix(mobile): name custom statuses on rows and judge closed by category

Addresses review on #7286.

A section header names a CATEGORY, so "Code Review" and "QA" both land under
In Review and the row was the only place left to tell them apart — and it said
nothing. `IssueRow` now renders a `<CustomStatusChip>` next to the title,
mirroring web's `list-row.tsx`. It stays silent for a status that already is
its category's built-in, so a workspace with no custom statuses renders exactly
as before. Covering it in `IssueRow` reaches all four surfaces at once: My
Issues, the workspace Issues list, project detail, and pins.

The icon-only entry points had the same identity loss with no room for a chip,
so they get the catalog colour instead — `IssueRow`'s `showStatus` path (pins),
the mention suggestion bar, and the mention picker.

Separately, the mention bar dimmed closed issues with `status === "done"`, so
an issue on a custom status in the done category rendered at full opacity as
though the work were still open. It now asks the question every status-coupled
rule actually asks — does this issue BEHAVE as done or cancelled — through
`issueBehavesAsAny`, mirroring the core helper of the same name.

Also adds the `// @vitest-environment node` pragma the repo asks for on the two
new pure-logic suites. Mobile's vitest config is already node-only, so this is
documentation rather than a behavior change.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 14:23:59 +08:00
4ff37edf91 fix(daemon): record in_progress when work on the issue's ask starts, not only at turn end (MUL-6417 follow-up) (#7283)
Post-merge report from Bohan: a todo issue assigned to an agent sat in
todo for the whole first work turn — half an hour of active work the
board attributed to nobody. The end-of-turn-only rule conflated when the
agent can JUDGE with when the FACT changes: a turn that advances the
issue's own ask makes "being worked" true the moment it begins. The
activity indicator shows the run, but columns, filters, and sorting read
status — the indicator alone is not the surface people watch.

The status rule now has two write moments, same fact anchor:

- start: the moment the turn is known to advance the issue's own ask →
  in_progress (only if it differs — rework on an in_review issue flips
  it back the same way);
- end: the state the work reached (in_review / in_progress-continuing /
  blocked).

An ancillary turn (research, answers, review, discussion) still writes
nothing at either moment, so the research-stays-todo case and the
concurrent-convergence property from the original MUL-6417 change are
unchanged. This is the pre-MUL-6417 timing with the fact anchor's
conditionality — not a revert to the unconditional lifecycle flip.

Docs, skill reference, and source maps updated to the two-moment rule;
guard tests pin the start moment and ban the end-only phrasing.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 14:17:18 +08:00
BuXiuDN 8f48c380d4 fix(desktop): enable the renderer process sandbox (#7225)
All renderer windows ran with `sandbox: false`, so the preload script —
which holds the full IPC surface exposed via contextBridge — executed in
an unsandboxed context. The preload only uses sandbox-safe APIs (the
`electron` module and the polyfilled `process`), so the sandbox can be
turned on without touching it.

The one real dependency was the build: `@electron-toolkit/preload` was
externalized, and a sandboxed preload's `require` can only load
`electron` plus a couple of node builtins. electron.vite.config.ts now
bundles it into the single CJS preload output, which is exactly what the
sandbox requires.

`createRendererWebPreferences` moved out of index.ts into its own module
so the security-relevant flags are pinned by a unit test; index.ts cannot
be imported in tests because it registers app lifecycle handlers on
import. Comments in index.ts and navigation-guard.ts that described the
old `sandbox: false` state are updated.

`webSecurity: false` stays off for now — restoring it requires migrating
the renderer onto a privileged custom protocol so CORS preflights carry
a real Origin, which needs server-side coordination first.
2026-08-20 13:20:21 +08:00
e1be1625cb MUL-5959: feat(agent): add Dim (DimCode) ACP runtime (#6675)
* feat(agent): add Dim (DimCode) ACP runtime

Add Dim (dimcode, the `dim` CLI) as a first-party agent runtime, driven
over the ACP (Agent Client Protocol) transport via `dim acp`.

Key integration points:
- New dim backend (pkg/agent/dim.go): spawns `dim acp`, performs the
  ACP initialize/session/new handshake, and raises the runtime's
  hardcoded read-only permission preset to full-access (plus agent mode)
  via session/set_config_option before the first prompt — without it every
  file write is denied by a capability rule. Model override uses
  session/set_model; the model catalog is read from session/new.
- Session resume is intentionally skipped: Dim binds sessions to the
  creating process, so a later process's session/load is rejected with
  "held by another process" even after a clean session/close. The backend
  always starts a fresh session and reports ResumeRejected so the daemon
  classifies the run correctly. A best-effort session/close keeps Dim's
  own session list free of orphaned entries.
- Registration: SupportedTypes whitelist, New() factory, launch header,
  daemon probe (MULTICA_DIM_PATH / MULTICA_DIM_MODEL), default agent
  command name, protocol_family CHECK migration (255), model discovery,
  MCP config support, metrics label, and runtime display.
- Docs/UI: provider logo, landing i18n (en/zh/ja/ko), providers and
  install-agent-runtime docs, README, CLI_AND_DAEMON, SELF_HOSTING.
- Tests: dim backend unit tests (fresh session + resume behavior), shared
  ACP deliverable case, logo and MCP-support tests.

* feat(views): use official DimCode app icon for Dim provider logo

Replace the placeholder inline SVG with the official DimCode desktop
client app icon (from /opt/DimAgent/resources/build/icon.png, resized
to 128px), imported as a static asset the same way the Qwen Code mark
is handled.

* test(agent): add real dim ACP smoke tests (agentintegration)

Two gated tests behind MULTICA_RUN_REAL_AGENT_SMOKE=1:

- TestDimRealACPSmoke: full end-to-end against real `dim acp` —
  initialize, session/new, set_config_option (permission/mode), prompt,
  and assert completed output.
- TestDimRealResumeRejected: pass a fake ResumeSessionID, assert the
  backend starts a fresh session (no session/load), and reports
  ResumeRejected=true so the daemon classifies the run correctly.

Verified against dimcode 0.3.2 and 0.3.8.

* fix: renumber dim migration 265 → 271 (upstream added 265-270)

* fix: renumber dim migration to 272 (upstream added 271)

* fix(agent): dim cross-run resume via session/load + review fixes

Address review feedback on the Dim ACP runtime:

- #1 Deliver runtime brief to Dim: add dim to the AGENTS.md provider
  list (verified dim 0.3.8+ reads AGENTS.md from the session cwd).
- #2 Cross-run session continuity: dim 0.3.10+ releases its per-process
  session lock ~5s after the owning process exits, so resume now goes
  through the standard ACP session/load (same as traecli/kiro/grok)
  instead of always starting fresh. A loaded session retains its
  permission/mode, so set_config_option runs only on fresh sessions.
  Add a two-run regression (TestDimRealCrossRunResume) proving run B
  recalls context established only in run A.
- #3 Process lifecycle races: the deferred cleanup now cancels the run
  context before cmd.Wait (a child ignoring stdin EOF no longer hangs
  Result delivery) with a bounded force-kill fallback; the success path
  waits for the final prompt notification with a grace window instead
  of a non-blocking read that could miss the last message.
- #4 Isolate model discovery by executable path (discoveryCacheKey).
- #6 Real smoke test now writes a sentinel file to prove full-access is
  effective; a best-effort session/close is sent before teardown.
- #7 gofmt.

* test(agent): add dim process-lifecycle regressions (review #3)

- TestDimCleanupKillsHangingChild: a child that ignores stdin EOF and
  SIGTERM after an early failure is force-killed within
  dimProcessWaitTimeout, so Result still closes.
- TestDimPromptMissingNotificationStillCompletes: when session/prompt
  returns without stopReason (onPromptDone never fires), the bounded
  final-notification wait falls through and Result completes instead of
  hanging.

* fix(migrations): renumber dim runtime_profile migration 272 → 273 (upstream added 272)

* fix(agent): review round 2 — process tree, quiescence, fail-closed, version check

Address all remaining blockers from the second review:

- #1 Require dim >= 0.3.10 (version check at initialize); bounded retry
  on session/load when the lock is not yet released instead of silently
  starting fresh; integration test no longer sleeps before resume.
- #2 Process-tree cleanup: configureProcessGroup + signalProcessGroup +
  waitProcessGroupGone; cmd.Cancel returns nil so we own all signalling;
  regression asserts the descendant PID is gone.
- #3 Notification quiescence: onActivity + waitForACPNotificationQuiescence
  (same as hermes); Dim equivalent of the late-final-notification test.
- #4 Permission fail-closed: set_config_option runs on BOTH fresh and
  resumed sessions; on config failure, session/close is sent before
  returning so a partially configured session is not resumed; regression.
- #5 Migration renumbered 273 → 274 (upstream added 273).
- #6 AGENTS.md mapping regression, two-executable cache isolation test,
  session/close unit assertion, smoke test now executes a command.
- #7 README.md + CLI_AND_DAEMON.md updated with 22 CLIs including Dim.

* chore: remove temporary review actions doc (not for PR)

* fix(migrations): renumber dim migration 274 → 310 (upstream added 274-309)

* review: fix retry break scope, dedup isACPSessionNotFound comment, update stale notes

* fix(agent): pass *exec.Cmd to signalProcessGroup/waitProcessGroupGone (upstream signature change)

* fix(agent): close session on set_model failure + resume regression test

Audit-found gaps from self-review:
- set_model failure now sends session/close before returning (same as
  set_config_option failure) — reviewer #4 said 'permission, mode, or
  model'.
- Add TestDimConfigFailThenResumeReestablishes: run A config fails →
  session/close sent → run B resumes and re-applies set_config_option
  (fail-closed), completing successfully.
- Fix two stale comments that contradicted the code (config block now
  runs on both fresh and resumed sessions).

* fix(agent): add WaitDelay, use labeled break in retry loop

Self-audit improvements:
- Add cmd.WaitDelay=10s for consistency with claude.go (hard backstop
  if a process somehow survives SIGKILL).
- Use labeled break (break loadRetry) so runCtx cancellation during the
  retry delay exits the for loop directly, not just the select.
- Update PR description: migration 273→310, set_config_option now
  re-applied on both fresh and resumed sessions.

* fix(migrations): renumber dim migration 310 → 313 (upstream added 310-312)

* fix(migrations): remove stale 313 dim migration (replaced by 314 after upstream added dsh at 313)

* fix: DSH compatibility + thinking levels + reviewer round 3

Systematic fix of all 39 items from the compatibility gap analysis:

Rebase + migration:
- Rebase to latest upstream/main (resolves all DSH conflicts)
- Migration 314_runtime_profile_add_dim (whitelist includes both dsh + dim)
- Remove stale 313 dim migration

DSH + Dim coexistence (provider lists, counts, docs):
- SupportedTypes, config.go, metrics labels: both dsh + dim
- i18n (4 files): count 23, lists include DSH + Dim + Oh-My-Pi
- README.md/README.zh.md: count 23
- CLI_AND_DAEMON.md: 23-row table
- environment-variables.mdx (4 langs): MULTICA_DIM_PATH/MODEL callout
- display.ts, mcp-support.ts, types/agent.ts, provider-logo.tsx: both dsh+dim

Thinking levels (was MISSING — dim supports thought_level):
- dim.go: call applyACPEffortOption after set_model
- thinking.go: add dim to acpCatalogThinkingProviders
- dim.go: retain sessionResult for effort option

Reviewer round 3:
- Version check fail-closed: empty/malformed → reject (not allow)
- Cache test through ListModels call site with fake executables
- set_model failure regression test
- Test companions: sidecar_manifest_test, runtime_config_test add dim

Code quality:
- agent.go: fix unreachable duplicate return (rebase artifact)
- dim.go: labeled break in retry loop, WaitDelay, session/close on set_model fail

* fix: deliverable test fake version + provider-logo rebase artifact

* fix(migrations): renumber dim migration 314 → 315 (upstream added 314_workspace_mcp_config)

* fix(migrations): renumber dim migration 315 → 319 (upstream added 315-318)

* fix: Windows process tree, MinVersions, retry tests, migration 327

Address all 5 remaining blockers from review round 4:

1. Windows process-tree ownership: use startOwnedProcessTree instead of
   cmd.Start(), releaseProcessGroup in cleanup defer. This attaches the
   child to a Job Object on Windows (no-op on Unix), ensuring descendants
   are captured and terminated.

2. Migration renumbered 319 → 327 (upstream added 319-326).

3. Add dim: 0.3.10 to MinVersions in version.go so the daemon registers
   old Dim binaries as offline and refuses triggers (defense in depth on
   top of the ACP agentInfo.version check in dim.go).

4. Session-lock retry contract tests: success after bounded retries,
   exhaustion without falling through to session/new. Fake script
   supports DIM_LOAD_HELD_N (held for N calls then succeed) and
   DIM_LOAD_HELD_ALWAYS (always held).

5. README.md:206 20→23 runtimes. PR description updated.

* test: add missing reviewer-requested regressions (round 4)

- Windows Job Object regression: dim_windows_test.go proves the Dim
  backend captures and terminates descendants via startOwnedProcessTree
  (Windows-only build tag; companion to TestStartOwnedProcessTree).
- MinVersions registration: add dim test cases to TestCheckMinVersion
  (0.3.10 ok, 0.3.9 rejected, invalid rejected).
- Retry cancellation: TestDimSessionLoadRetryCancelled cancels the
  context during the retry delay, asserts no session/new fallback.

* fix: sync upstream Command signature changes + restore dim additions

Upstream changed ListModels/discoverXxxModels/detectCLIVersion to accept
Command (struct with Path+Prefix) instead of string. The previous rebase
kept our old-signature versions, causing widespread compile failures.

Restored all affected files from upstream, then re-applied dim-specific
additions:
- agent.go: dim in SupportedTypes, New(), launchHeaders
- hermes.go: isACPHeldByProcess + -32002 in isACPSessionNotFound
- thinking.go: dim in acpCatalogThinkingProviders
- models.go: dim case in ListModels + discoverDimModels
- version.go: dim in MinVersions
- All dim test files preserved (dim_test.go, dim_integration_test.go,
  dim_windows_test.go, models_test.go dim cache test)

* fix: rebase to latest main, migration 327→341, resolve DetectVersion conflict

* fix: remove unused os/exec import in dim_windows_test.go

* fix(migrations): renumber dim migration 341 → 342 (upstream added 341)

* fix: use Command.exec instead of exec.CommandContext (upstream GH #7046)

Upstream's TestOnlyLaunchGoSpawnsRuntimeProcesses requires all backends
to build processes through Command.exec (launch.go), so a custom runtime's
fixed_args are carried into the subprocess. Dim was the only backend still
using exec.CommandContext directly.

* fix: launch prefix policy + remove empty test files + deterministic retry tests

Address all 4 findings from review round 5:

1. Add "dim": dimBlockedArgs to launchPrefixBlockedArgs so protocol-breaking
   flags (--help/--auth-setup/--remote) are filtered from fixed_args.
2. Remove 77 zero-byte *_test.go files accidentally added at repo root.
3. Make retry tests deterministic: TestDimSessionLoadRetryCancelled waits
   for the first session/load request before cancelling (no fixed sleep);
   TestDimSessionLoadRetryExhausted asserts exactly 4 load attempts.
4. PR description will be updated separately.

* fix: rebase to latest main, migration 342→343 (upstream added mcode at 342)

* chore: trigger CI

* chore: refresh PR

* fix: restore mcode that was lost during merge conflict resolution

Merge took 'ours' side which predated mcode. Restore mcode in:
- SupportedTypes, New() factory, launchHeaders
- agent_supported_types_test.go want map
- metrics/labels.go
- config.go defaultAgentCommandNames
- migration 343 up/down whitelists

* fix: restore mcode probe in agents_probe.go + agent-cli-command-names.txt

* fix: restore all remaining mcode references lost during merge

Files fixed:
- README.md: mcode row in runtimes table
- mcp-support.test.ts: mcode assertion
- config.go: mcode in Agents comment + error message
- runtime_config.go: mcode in AGENTS.md case
- version.go: mcode in MinVersions
- version_test.go: mcode test cases

Verified: no file has fewer mcode references than upstream.

* fix: restore mcode in environment-variables docs + CLI_AND_DAEMON ACP list

- environment-variables.{mdx,ja,ko,zh}: restored 'MiniMax Code' in the
  QwenPaw model-variable sentence
- CLI_AND_DAEMON.md: added MiniMax Code to ACP-family list + loadSession
  fallback description

Verified: every changed file now has >= mcode references vs upstream.

* fix(migrations): renumber dim migration 343 → 344 (upstream added 343)

* fix(migrations): renumber dim migration 344 → 348 (upstream added 344-347 plugin migrations)

* fix(migrations): update down migration comment 344 → 348

* chore: retrigger CI (flaky TestOpenclawDiscoveryCacheConcurrentPreparations — upstream test, passes locally x5)

* fix(migrations): renumber dim migration 348 → 352 (upstream added 348-351)

* fix: Dim launch-prefix regression test + version.go comment repair (review #6)

- Add TestDimLaunchPrefixFiltersBlockedFlags: proves allowed prefix
  reaches command before acp, and --help/--auth-setup/--remote/-h are
  stripped from Dim's launch prefix.
- Add dim to TestLaunchPrefixReachesACPFamilies family list.
- Repair version.go: dim's cross-run session/load comment was appended
  to mcode entry during a rebase conflict; restore two independent
  accurate comments.

* fix(migrations): update down migration comment 348 → 352

* chore: retrigger CI (backend-tests stuck 50+ min)

* fix(migrations): renumber dim migration 352 → 362 (upstream added 352-361)

* fix: use logAgentCommand for dim argv logging (upstream redaction requirement)

Upstream's TestOnlyLaunchGoLogsAgentCommandArgs enforces that all
runtime argv logging goes through Config.logAgentCommand so sensitive
args are redacted. dim.go was still using Logger.Info directly.

* fix(migrations): renumber dim migration to 370

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 12:54:46 +08:00
658b0b7d9f MUL-6406: fix(auth): fail fast in production on insecure JWT secret defaults (#7212)
* fix(auth): fail fast in production on insecure JWT secret defaults

* chore(deploy): require a strong JWT_SECRET in compose and docs

* fix(ci): make selfhost config test pass with required JWT_SECRET

The compose file's JWT_SECRET:? error message contained a colon-space
sequence that YAML parsed as a mapping indicator, breaking docker compose
config. Quote the openssl hint instead.

.env.example now ships an empty JWT_SECRET, so the test's docker compose
config calls fail interpolation; supply a throwaway secret from the
environment, which Compose lets outrank the env file.

* fix(ci): seed JWT_SECRET into the recipe .env in selfhost config test

The Makefile includes .env and bare-exports every variable to the recipe
environment, so the make-driven recipe cases clobbered the test's
JWT_SECRET export with the empty value .env.example ships. The stub's
`docker compose config` then failed interpolation with stderr discarded,
produced empty JSON, and crashed the node parser. Seed the throwaway
secret into the recipe .env, which both make and Compose load.

* docs(env): use a CSPRNG for the Windows JWT_SECRET generation hint

Get-Random is not cryptographically secure, so the Windows hint could
leave self-hosters with a predictable signing key. Point at
RandomNumberGenerator instead, per review.

* docs(selfhost): fix stale JWT secret comments

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 12:54:19 +08:00
4f56785b02 refactor(daemon): merge Reply/Ownership turn modes into one fact-driven workflow (MUL-6417) (#7245)
* refactor(daemon): merge Reply/Ownership turn modes into one fact-driven workflow (MUL-6417)

The Reply/Ownership split dated from PR #205 and had already been merged
in substance by MUL-5377 (one brief section + mode router) and MUL-6300
(reply turns own the same status arc). What remained was a per-turn
marker plus two mode blocks restating information the per-turn message
already carries as data. This finishes the merge:

- Status is judged once, at the END of the turn, from what the work
  actually changed about the issue — not from the trigger type, not from
  the run lifecycle, and not gated on being the assignee. No opening
  in_progress write: the board's in-flight signal is the issue activity
  indicator, and a todo issue the agent was only asked to research now
  correctly stays todo (the old unconditional arc got that wrong twice).
  Lifecycle writes oscillate under concurrent runs; fact writes converge,
  which is what the retired assignee gate existed to protect.
- Delivery routes on data: a triggering comment in the per-turn message
  (reply in that thread with THIS turn's --parent, one reply per thread)
  or none (post a new top-level comment).
- The blocked rule and the squad-leader dispatch case become end states
  of the same judgment instead of mode-block special cases; the leader
  keeps one bullet (dispatching members is not delivery). The reply-mode
  mention pointer is gone — the discipline lives in ## Mentions.
- The Turn mode markers are removed from the per-turn prompts, deleting
  the marker/router-drift failure class (1ea3cf1d7) outright.

squad_briefing.go's Squad Operating Protocol text is deliberately
untouched: it does not reference the retired modes, and its guest-leader
prohibition is a squad-protocol product decision, not workflow routing.

Guard tests now pin the fact anchor: conversational turns write nothing,
research on a todo issue leaves it todo, assigned deliverables land in
in_review (stage barriers and parent notifications key off that write),
and concurrent agents converge instead of flapping the board.

Co-authored-by: multica-agent <[email protected]>

* test(handler): align squad parent-status contract test with the fact-judgment brief (MUL-6417)

Co-authored-by: multica-agent <[email protected]>

* refactor(daemon): replace the prescriptive mention default with the facts behind it (MUL-6417)

"Default: NO mention" told agents what to do without why. The mention
syntax hides its semantics — it reads like a free social gesture but is
a spawn/notify operation — and every real incident was an agent acting
on a false need: "they should know" (comments already reach followers;
completion wakes are platform-owned) or courtesy (a thank-you run whose
only reply is another thank-you run). The section now states those facts
plus the cost asymmetry (a missed mention is one follow-up ask; a stray
one is a paid run), from which the old default is derivable.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): scope the mention notify-fact to followers; sync docs to the new status contract; compress (MUL-6417)

Review fixes from #7245 (Elon):

- "A mention routes work — it is never how someone finds out" was false
  for non-followers — a mention is exactly how a human who does not
  follow the issue finds out, and the absolute wording could suppress
  necessary escalation. The notify-fact is now scoped to followers, and
  a guard pin bans the overreach alongside the old prescription.
- Every surface that still taught the old status contract is synced to
  end-of-turn fact judgment: issues.mdx/zh (agent flips in_progress on
  start), assigning-issues.mdx/zh (todo → in_progress → in_review as the
  default arc), and working-on-issues-source-map.md:177 (reply turns
  gated on assignee — the exact gate this PR removes).

Also a compression pass over the PR-touched brief text (−474 bytes):
drop the intro signpost sentence (steps and status block are
self-contained), tighten the delivery routing and delivery bullet, and
compress the concurrency rationale to one clause. Ordinary brief is now
13,181 bytes vs 13,345 on main (−164), leader 14,286 vs 15,131 (−845);
with the per-turn marker deletion (−139/−142 per turn) every prompt
surface is a net reduction from the first turn.

Co-authored-by: multica-agent <[email protected]>

* docs(squads): retire the grant-routing ledger and status-first dispatch ordering (MUL-6417)

Review nit from #7245 (Elon): squad_briefing.go's design comment still
described the reply-mode grant routing and assignee-scoped arc this PR
removed. It now records the current mechanics — the workflow judges
status at end of turn, the leader's brief-side special case is dispatch
leaves the parent in_progress, and this section's job is the
owning/guest permission boundary at the Agent Identity layer. squads.mdx
(en/zh) stops describing a status-write-then-dispatch ordering: the
status point is now an end-of-dispatch-turn fact, consistent with the
assigning-issues docs.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 11:32:17 +08:00
Bohan Jiangandmultica-agent f7abcdded3 docs: publish the community-maintained areas and their maintainers (#7249)
Until now, "community-maintained" appeared in the docs without ever
naming who maintains what, and the actual maintainers were recorded only
in Go package comments and PR threads. Users filing an issue had no way
to learn any of it.

Add a Community-maintained areas page in all four locales listing each
area, its code path, its maintainers, and the retirement rule, and make
that page the single source of truth: channels, the DingTalk and
Telegram guides, the README, and the three package doc comments now
point at it instead of restating it. Those package comments no longer
carry maintainer handles or a second copy of the policy, so ownership
changes are recorded in exactly one place.

The page is deliberately a credit-and-expectations page, not a support
directory — it asks readers to file a GitHub issue rather than page a
volunteer directly, since what we promised these maintainers is that we
would route work to them, not that they are on the hook for it. It also
avoids the term "code owner": we are not adding a GitHub CODEOWNERS
file, and that term carries a specific automated meaning there.

Both issue forms gain a required Area field so a report can be routed
without asking the reporter to read source, with the community-maintained
areas marked as such in the option list.

Co-authored-by: multica-agent <[email protected]>
2026-08-20 00:22:04 +08:00
67c6aa05c4 MUL-6166: feat(telegram): add Telegram channel integration (#6944)
* feat(telegram): add Telegram channel integration

Co-authored-by: multica-agent <[email protected]>

* chore(telegram): rebase onto main and renumber migrations to 366-367

349/350 collided with main's agent_task_queue_chat_terminal_resume_index
and agent_task_queue_chat_retired_session_index. Renumber the Telegram
origin pair to the next free prefixes, update the cross-references inside
the SQL comments, and point telegram_origin_migration_test.go at the new
filenames. Contributor-facing behavior is unchanged.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-08-19 23:04:26 +08:00
801c671973 chore(assets): convert bitmaps to WebP, dedupe, and gate image size (MUL-6352) (#7149)
* chore(assets): convert web and docs bitmaps to WebP

The repo carried 21.7MB of PNG/JPG and zero modern formats. next/image
already serves visitors AVIF/WebP, but the raw masters are still paid for
on every clone, every Docker build context, and every deploy upload.

Re-encode with cwebp and cap the source width at the largest size any
layout can request: 1920px for the decorative landing backgrounds (they
render behind content under object-cover), 2640px for the landing hero
(2x its 1320px slot), 1600px for screenshots (2x the ~720px docs and
use-case column). EXIF goes with it — the four feature backgrounds were
144dpi print exports.

Encoder choice is per image rather than uniform. Most compress best as
lossy; two of the docs screenshots are flat palettized PNGs that encode
smaller with `-lossless -z 9`, so they use that instead. Screenshots are
q80 to keep UI text crisp, decorative art q72.

  apps/web/public    10.70MB -> 1.57MB
  apps/docs/public    7.47MB -> 2.44MB

Verified: 1:1 crops against losslessly-downscaled references show no
visible difference; all 266 image references in the tree resolve; docs
prerenders all 177 pages and the landing/use-case pages render with no
broken images.

Co-authored-by: multica-agent <[email protected]>

* chore(assets): drop the duplicated README hero image

docs/assets/hero-board.png was byte-for-byte identical to the docs site's
workspace-overview screenshot — 633KB stored twice. Only the two READMEs
referenced the copy, so point them at the canonical file instead.

Co-authored-by: multica-agent <[email protected]>

* chore(assets): losslessly recompress app icons

oxipng -o max --strip safe over the app icon PNGs: -640KB with pixels
untouched (verified by comparing lossless-WebP encodes of the before and
after of every file — all 16 identical).

Deliberately not switched to WebP and not regenerated: the prerendered
sizes under apps/desktop/build/icons/ exist because electron-builder's
auto-derivation silently shipped only the 1024x1024 source in the v0.2.31
.deb (#2515), and the PWA manifest and .icns/.ico containers want PNG.
Filenames, dimensions, and the electron-builder config are unchanged, so
packaging behaves exactly as before.

Co-authored-by: multica-agent <[email protected]>

* ci: gate pull requests on a 300KB image budget

Keeps the previous three commits from being undone one screenshot at a
time. A bitmap added — or grown — past 300KB fails the check until the PR
description carries an `Oversized image exemption:` line saying why.
Shrinking an oversized file always passes: the check compares against the
size at the base ref, not just the absolute size.

Path-filtered to PRs that touch a bitmap, so the full-history checkout it
needs to diff against the base is only paid for when it is relevant.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 21:27:07 +08:00
0c0baf0505 perf(realtime): stop the task:message fanout from flooding every client (MUL-6396) (#7227)
* perf(realtime): stop the task:message fanout from flooding every client (MUL-6396)

`task:message` is broadcast to every client in the workspace for every run,
and the client cached all of it: the handler's `(old = [])` default built a
timeline entry on first sight, so each client accumulated the full transcript
of runs its user would never open, held for the life of the run. Tool input is
unbounded on the wire, so a single large Write shipped its whole body to every
open client and was retained there.

Three changes, each useful on its own:

- Client drops frames for tasks no mounted view is rendering. Presence in the
  query cache is the signal, which registers when a view mounts and before its
  fetch resolves, so a frame landing mid-backfill is still kept. Rows are
  persisted before they are broadcast, so a dropped frame costs nothing —
  whoever opens the task next fetches it.

- Server clips oversized `input` / `output` out of the broadcast copy and marks
  it `truncated`. Persisted rows and the REST list responses are untouched. A
  client seeing the flag repairs that task's timeline from the DB, once per
  flush window rather than once per frame.

- Client coalesces surviving frames into one cache write per 100ms window,
  turning a burst of streamed messages into one merge and one render. The
  window is fixed, not a resetting debounce, so a continuous stream still
  flushes instead of being starved until it pauses.

Mobile keeps its own per-session updater and was already scoped to mounted
sessions, but shares the wire format, so it refetches on `truncated` too.

The remaining half of the batching idea — one WS frame per reported batch
instead of one per message — is deliberately not here. The client identifies
itself on the upgrade URL but the server does not retain that version, so a
shape change would silently break every older client. It belongs with the
client scope-subscription work that `listeners.go` is already waiting on.

Co-authored-by: multica-agent <[email protected]>

* fix(realtime): keep broadcast clipping off the wire, and stop a late fetch dropping live seqs

Addresses both must-fix findings from review.

1. Clipping the broadcast copy is not something an existing client can ignore:
   it changes what `input` / `output` mean, and every deployed client already
   consumes them. A build without the `truncated` reader caches the clipped
   copy under staleTime:Infinity and never refetches, so its execution log
   would stay incomplete until the window is reloaded — the PR even proposed
   shipping the server half first, which is exactly backwards.

   Clipping now sits behind MULTICA_CLIP_TASK_MESSAGE_BROADCAST, off by
   default, so the reader ships first and the flip is a later, revertible ops
   step. Capability routing would be the principled fix, but the hub does not
   retain the client_version it is handed at upgrade and frames cross nodes
   through the Redis relay as serialized bytes — the same protocol work
   per-task scope routing is already waiting on.

2. Not caching unopened tasks made first open of a live task fetch where it
   previously found a pre-seeded cache. That fetch replaced the array
   wholesale, so any frame written while it was in flight was dropped — and
   with staleTime:Infinity nothing would ever fetch it again.

   Every write to the timeline cache now folds through one rule, installed as
   the query's own structuralSharing: union by seq, server data winning on
   conflict, rows the response never mentioned kept rather than deleted. That
   subsumes the backfill's hand-rolled eviction, so the three write paths
   (fetch, backfill, realtime batch) can no longer disagree.

Also documents why mobile's truncated branch is unreachable today: its
task:message handler gates on chat_session_id, which the server's payload does
not carry, so mobile appends nothing at all. Pre-existing, reported separately,
and the branch stays so whichever side is fixed first is not left caching
clipped rows.

Co-authored-by: multica-agent <[email protected]>

* fix(realtime): re-check the timeline is still held before writing a batch

Holding was verified when a frame entered the batch, but the write happens up
to a window later — and `setQueryData` does not postpone garbage collection.
query-core arms that timer when the last observer leaves and never re-arms it
on write, so closing a transcript while its run keeps streaming has the entry
collected mid-window with a batch already in flight.

Writing then rebuilt the entry holding only that batch. Under the app-wide
`staleTime: Infinity` the next open reads the stub as fresh and never fetches,
so everything before it is missing until the window is reloaded — silent,
permanent, and not self-healing. Agent runs routinely outlive the 10 minute
gcTime, so "opened a running task, closed it, it kept going" reaches this on
an ordinary day rather than in a race.

The flush now re-checks immediately before each write and drops the batch if
the entry is gone, dropping its repair fetch with it. `backfillTaskMessages`
guards its own post-await write for the same reason. Dropping costs nothing:
the rows are persisted, so the next open fetches the whole timeline — which
the regression test asserts rather than just checking the entry stays absent.

Also corrects the doc comment on isTaskMessageTimelineHeld, which described
the entry as a mounted view. It means "opened at some point and not yet
collected", and the bounded tail is one gcTime.

Co-authored-by: multica-agent <[email protected]>

* docs(realtime): describe the task:message gate as it actually behaves

The overview still said frames are dropped unless a mounted view holds the
task, and that the old behaviour retained a transcript for the life of the
run. Neither matches the code: the gate is timeline-entry presence (opened at
some point, not yet collected), and writes do not postpone GC, so retention is
bounded by gcTime rather than the run.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 19:25:05 +08:00
9bd556785f fix(inbox): give the desktop shell navigation feedback when switching issues (#7223)
Switching rows in the desktop inbox had no loading affordance at all: the
click froze the window until the new issue detail committed, then everything
appeared at once. Web looked smooth for the same work because it shows a
progress bar over the swap.

Two reasons, both fixed here:

- `NavigationProgress` only ever shipped inside web's `DashboardLayout`.
  Desktop builds its shell from `desktop-layout.tsx` and never mounted the
  component, so the bar simply did not exist there. It now sits at the top of
  `MainCanvas`, desktop's equivalent of web's `SidebarInset`.
- `useIsNavigating()` tracked only the adapter's push/replace transition. On
  desktop those are synchronous store writes, so the flag settles before the
  destination content has rendered — the bar would have flashed and gone.
  Views can now report an in-page swap through `useReportNavigating()`, which
  feeds the same signal.

The inbox uses both. Its detail pane's selection is deferred, so mounting
`IssueDetail` — the expensive half of a switch — runs at transition priority
instead of blocking the click: the row highlight and the URL commit
immediately, the previous issue stays painted, and the gap is reported to the
progress bar. `resolveDetailItem` holds that stale row only while it is still
in the list; archive drops the actioned row in the same commit that advances
the selection, and holding the miss would blink the empty state between the
two issues.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 18:04:26 +08:00
d563bfbc08 docs(changelog): add v0.4.30 release entry (2026-08-19) (MUL-6403) (#7221)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 17:54:31 +08:00
c2474e2fe1 fix(agents): align private-agent invoke surfaces with the owner-only contract (MUL-6380) (#7202)
* fix(mobile): gate the chat agent picker on the real invoke rule (MUL-6380)

apps/mobile/lib/can-assign-agent.ts was a hand-copied mirror of the
pre-MUL-3963 permission model and had drifted from the server in both
directions:

- it granted workspace owner/admin a bypass on private agents, so an admin
  could pick a teammate's personal agent in the chat picker and only learn
  it was refused when the send hit canInvokeAgent (403);
- it judged on the derived `visibility` field, which collapses "public_to
  scoped to specific people" into "private" — so a member the owner had
  explicitly granted access to never saw that agent on mobile at all.

Delete the mirror and call `canAssignAgentToIssue` from
@multica/core/permissions directly, the same rule the issue mention bar on
this app already uses (mention-suggestion-bar.tsx). The root CLAUDE.md
sharing rule allows mobile to import pure functions from @multica/core, so
the mirror bought nothing but a second copy to drift.

No new tests: the access matrix stays canonical in
packages/core/permissions/rules.test.ts, which already covers the admin,
owner, plain-member and member-target cases.

Co-authored-by: multica-agent <[email protected]>

* fix(agents): stop telling users workspace admins can run a personal agent (MUL-6380)

Since MUL-3963 the invoke gate is owner-only for a private agent — admins
keep management and view access, not the ability to trigger a run. Several
strings still describe the old model, and they sit next to controls that
enforce the new one: the "Personal" badge tooltip on the agent detail page,
overview summary and hover card says "only you and workspace admins can use
this agent", while the assignee picker beside it is disabled and reads "only
the owner can assign work".

- packages/core/agents/visibility-label.ts: fix VISIBILITY_DESCRIPTION and
  VISIBILITY_TOOLTIP (shown in the create-agent dialog, the visibility picker
  and the agents-list lock tooltip). The description covers a CHOICE, which
  submits permission_mode "private" — owner-only, so "Only you can assign".
  The tooltip covers an EXISTING agent, where `visibility: "private"` is the
  lossy projection that also covers "shared with specific people" — so it
  names the owner's grants instead of promising either extreme. The doc
  comments asserted the opposite contract and cited a long-stale line ref.
- packages/views/locales/{en,zh-Hans,ja,ko}/agents.json: same fix for the
  VisibilityBadge tooltip, which is also its aria-label.

Co-authored-by: multica-agent <[email protected]>

* fix(issues): stop naming the target's permission mode when an assign is denied (MUL-6380)

validateAssigneePair answered a refused assignment with "cannot assign to
private agent" / "cannot assign to squad with private leader". Both are
wrong twice over:

- they disclose the target's permission mode to a caller who was just told
  they may not use it, which is exactly what ReasonInvocationNotAllowed
  (dispatch/reason.go) deliberately avoids — it does not distinguish "not
  permitted" from "does not exist";
- "private agent" is simply inaccurate for a `public_to` agent scoped to
  specific people, which is refused through the same branch.

Report the missing permission instead of the target's configuration.

Co-authored-by: multica-agent <[email protected]>

* fix(issues): correct the authorization contract stated at the assignment gate (MUL-6380)

Review found the drift this PR set out to fix still present in the comments and
test docs closest to the gate itself:

- validateAssigneePair's doc comment claimed assignment runs
  canAccessPrivateAgent and that agent callers bypass the gate. It runs
  canInvokeAgent, and an agent caller is judged by the top-of-chain human
  originator like anyone else — the code twenty lines below has said so since
  MUL-3963.
- TestShouldEnqueueOnComment_PrivateAgentGate's doc listed "allow workspace
  owners/admins" and "allow agent-to-agent traffic regardless of visibility";
  its own cases assert both are DENIED.
- TestCreateIssue_SquadPrivateLeader_OwnerAllowed's doc said a workspace owner
  can assign; the body uses the leader agent's owner, and an inline comment
  already corrected the mismatch.
- The comment added earlier in this PR overclaimed: it read as if the 403 were
  enumeration-safe, but the not-in-workspace branch still answers 400. Narrowed
  to the guarantee actually made — the reason no longer names the permission
  mode.

Also pins the denial BODY, which no test covered: status-only assertions let
the reason sentence drift back into describing the target. New cases cover the
workspace owner, a plain member, an agent actor with no originator, a
`public_to` agent scoped to someone else (the case the old wording called
"private", which was simply false), and a private-leader squad.

Co-authored-by: multica-agent <[email protected]>

* fix(chat): make a session read-only when its agent can no longer be run (MUL-6380)

Filtering the agent PICKER was only half the path. A chat session outlives the
permission that created it: the agent can be flipped to personal, change owner,
or drop the member from its allow-list. The server already treats that as a
defined state — the transcript stays readable through the view gate while every
send is refused by the invoke gate (MUL-4525,
admission_security_mul4525_test.go) — but the clients kept resolving the open
session's agent from the full list and never re-checked whether it could still
be run. The composer looked live, and the user found out from a 403.

Judge the SESSION's agent, not just the picker list, on every surface:

- packages/views: `isAgentAccessRevoked` in use-chat-controller.ts and
  chat-window.tsx, wired into the composer disable, upload affordance, quick
  actions, queued send-now, and a new AgentAccessRevokedBanner. handleSend keeps
  a belt-and-braces guard so it never spends a roundtrip on a certain refusal.
- apps/mobile: same derivation in the chat tab, plus the failure path — a
  thrown send only restored the draft, so the 403 read as a silent no-op. It now
  names the cause, via a mobile-local `dispatchReasonCode` (core's narrows on
  core's ApiError class, which mobile never throws).

Two reason-precedence fixes fall out of this. "No agents in this workspace" was
shown when the workspace's ONLY agent was the revoked one — an agent IS bound,
it just cannot be run — so revoked access now wins in the placeholder, the
banner and mobile's disabled reason. And the queue's Steer button said "Steer is
available after the current reply starts", telling the user to wait for
something waiting cannot fix; it now states the permission reason.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 16:33:48 +08:00
3c3e77b00c MUL-6362: redact secrets from provider command logs (#7206)
* fix(agent): redact provider command values

Co-authored-by: multica-agent <[email protected]>

* fix(agent): preserve safe command diagnostics

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 16:07:53 +08:00
24d8c521a6 MUL-6364: make the LLM client retry budget explicit, wired, and validated (#7201)
* feat(llm): make the LLM client retry budget explicit, wired, and validated

Config.MaxRetries was a bare int that nothing outside tests ever set. The
production wiring in handler.New never passed it and no MULTICA_LLM_* env var
fed it, so the field was configurable in name only and every deployment ran on
the SDK default.

Its semantics were also inverted. Because the zero value and an explicit 0 are
the same int, "0" fell into the unset branch and silently produced 2 retries,
while a negative — clamped to 0 to dodge the panic in option.WithMaxRetries —
was the only value that actually disabled retries.

- MaxRetries becomes *int so unset, 0 (disabled) and N (exact) stay distinct.
  New always passes the budget to the SDK explicitly, including the default, so
  the enforced policy and the reported one cannot drift apart.
- Add MULTICA_LLM_MAX_RETRIES as the single configuration source. Non-numeric,
  negative and above-ceiling values fail the boot instead of being corrected
  into something that looks configured. The ceiling of 5 is a latency budget:
  backoff reaches ~21s at 6 retries while the internal callers of this layer
  time out after 8s and 20s.
- Report the effective policy at startup via Client.RetryBudget, whose fields
  are counts and a fixed enum so the line cannot carry a key or gateway URL.
- Document the four states in .env.example and the environment-variable docs,
  and record what the budget does not cover: the GenerateJSON parameter
  negotiation and a stream that breaks after ChatStream returns.

Tests pin every state by upstream request count, so the unset-versus-zero
collapse cannot come back unnoticed.

Refs MUL-6364, closes #7154

Co-authored-by: multica-agent <[email protected]>

* fix(llm): reject invalid retry budgets at the client boundary and wire self-host

Review found the retry budget still reachable in two invalid shapes, and never
reachable at all on the main self-hosted deployment path.

- docker-compose.selfhost.yml declares the backend environment as an explicit
  allowlist and mapped none of the MULTICA_LLM_* variables, so every value
  .env.example and the docs told a self-hoster to set was dropped before the
  container: the LLM layer read as unconfigured no matter what they wrote. Map
  all four, and assert propagation in scripts/selfhost-config.test.sh, plus a
  drift guard so a future MULTICA_LLM_* documented without a mapping fails the
  script instead of silently going nowhere.
- Config.MaxRetries took a *int, so a negative still reached llm.New, which
  warned and used the default instead. Warning is not silence, but substituting
  a different budget is still the implicit correction #7154 asked to remove.
  It now takes *RetryOverride, whose field is unexported and whose only
  constructor rejects negatives — the invalid state is unrepresentable, so New
  has no correction branch left to take.
- The budget is a ceiling, not a quota: only retryable failures consume it, and
  a success or the caller's deadline can end a call sooner. Say "at most N"
  rather than "exactly N" in the field docs, .env.example and all four
  environment-variable docs.

The negative-value test now asserts rejection instead of pinning the fallback
as contract.

Refs MUL-6364, closes #7154

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:00:56 +08:00
bb8a04f3a1 docs(llm): disclose what the server-side LLM layer sends, and test the zero-request opt-out (MUL-6369) (#7199)
* docs(llm): spell out what the server-side LLM layer sends upstream (MUL-6369)

The two MULTICA_LLM_* variables are the only switch governing whether chat
content reaches an external model, but nothing said so: the .env.example
comment named chat titles as an example consumer, the docs page said only
that callers 'use local fallback logic', and neither said what is sent.
An operator whose policy forbids chat content leaving the deployment had
no way to confirm that leaving the variables empty is sufficient, which is
what GitHub issue #7162 reports.

Document the layer where it is configured: both consumers (chat
auto-titling, follow-up questions) and exactly what each sends, plus the
fact that leaving the key and base URL empty is a supported configuration
that costs nothing but the LLM-written title and the suggestion buttons.
The package doc carries the same inventory next to the code, so a new
consumer is added in the file that documents them.

Co-authored-by: multica-agent <[email protected]>

* test(llm): pin the zero-upstream-request and single-entry-point contracts (MUL-6369)

The documentation added in the previous commit makes two promises that were
true only by accident. An unconfigured client returning ErrNotConfigured was
never asserted to do so without dialing, and 'every server-side LLM call goes
through pkg/llm' held only because no one had imported the SDK elsewhere yet.

TestUnconfiguredClientMakesZeroUpstreamRequests drives every exported call
path through a transport that counts and refuses, then proves the counter can
move for a configured client so the zero-count assertions cannot pass
vacuously if the HTTPClient seam is ever dropped.
TestOutboundLLMTrafficGoesThroughThisPackage parses the server module and
fails on an OpenAI SDK import outside this package, because the consumer
inventory is only auditable while this package is the only door.

Co-authored-by: multica-agent <[email protected]>

* fix(llm): scope the privacy claim, and guard the consumer inventory (MUL-6369)

Addresses both must-fixes from review. They land together because they share
the same surfaces: the package doc is the inventory the operator copy is
written from, and it names the tests that hold each half.

1. The disclosure claimed more than the code guarantees. The copy said these
   variables are the deployment's only switch for sending chat content to a
   model, and that emptying them means the server sends nothing to any
   provider. What is actually guaranteed is narrower: server/pkg/llm makes no
   request. Running an agent reaches a model by an entirely separate path —
   the daemon executes an AI coding tool under that tool's own credentials,
   with MULTICA_* stripped from its environment. For a privacy document that
   gap matters more than it would anywhere else: an admin who reads the old
   wording concludes emptying two variables stops all model traffic, which is
   the kind of wrong belief #7162 is about. Every surface now says "this layer
   sends nothing" and names the agent path explicitly — .env.example, the four
   docs locales, the package doc, the test comments.

2. The inventory had no guard. The import test proved the OpenAI SDK has one
   door; it said nothing about how many callers walk through it, so a third
   feature calling GenerateText would ship undisclosed with every test green
   while the docs kept naming two consumers.
   TestDocumentedConsumersAreTheOnlyCallers scans production call sites and
   requires the set to match the published list in both directions: an
   undocumented caller fails, and so does a documented one that stopped
   calling, since an inventory that overstates what is sent misleads too. The
   import test is renamed TestOpenAISDKIsImportedOnlyByThisPackage to claim
   only what it checks.

Also drops the package doc's stale "drafting a quick-create issue" example,
which named a consumer that does not exist and contradicted the inventory two
paragraphs below it.

Co-authored-by: multica-agent <[email protected]>

* fix(llm): correct the MULTICA_* claim about agent environments (MUL-6369)

Review caught a factual overreach in the previous fix. The copy said MULTICA_*
variables are dropped from the agent's environment rather than passed to it,
which is not what mergeEnv does: it drops the namespace the daemon INHERITED,
then appends the task-scoped values daemon.go assembled — MULTICA_SERVER_URL
and MULTICA_TOKEN reach the child by design, and claude_test.go asserts it.

The guarantee an operator actually needs is narrower and still sufficient: the
daemon does not forward these MULTICA_LLM_* settings to the AI coding tool, and
agent runs use that tool's own credentials. Say that instead, and name the
task-scoped injection so the sentence does not contradict the subprocess env
contract. Applied to .env.example, the four docs locales and the package doc.

Also splits methodNameCollisions out of documentedConsumers, per the same
review's non-blocking note. A method-name collision quieted by adding a file to
documentedConsumers would have corrupted a list operators read as 'what sends
my chat content somewhere' in order to silence a test — a bad trade the guard
should not invite. Empty today; the repository has no collisions.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 14:59:24 +08:00
YYClaw 4b8873df3a feat(issues): add revision-aware concurrency guards (#7038)
* feat(issues): add revision-aware concurrency guards

* fix(concurrency): decouple text edits from aggregate revisions
2026-08-19 14:06:59 +08:00
c5f00bbf5c docs(mobile): document EXPO_APPLE_TEAM_ID for multi-team signing (MUL-6375) (#7196)
#7173 added the ios.appleTeamId mapping but left it undiscoverable: the
people it helps are stuck on a signing error, not reading app.config.ts.
Document it the same way its sibling EXPO_BUNDLE_IDENTIFIER_* overrides
are — README, .env.example, and a why-comment at the call site.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 13:58:46 +08:00
Robert 44ba031eeb fix(mobile): configure Apple team from environment (MUL-6375) (#7173)
Map EXPO_APPLE_TEAM_ID to Expo's ios.appleTeamId so self-builders with
multiple Apple Developer teams can pin the signing team. Unset keeps Expo's
existing auto-detection behaviour.

Closes #7172
2026-08-19 13:55:00 +08:00
41ddf6c0a1 refactor(frontend): drop dead UI components and phantom deps, add knip (MUL-6353) (#7147)
* refactor(frontend): drop dead UI components and phantom deps, add knip

`.npmrc` sets shamefully-hoist=true, which hides both halves of dependency
drift: an undeclared import still resolves, and an obsolete declaration still
installs. Neither build, typecheck, nor lint reads the import graph, so
package.json stopped being the source of truth for what is actually used.

Removed 16 zero-reference components from packages/ui (1,907 lines, all
`pnpm ui:add` output that never got wired up) plus mention-hover-card.tsx,
which was written but never imported — member and agent mentions render as a
plain `.mention` span. The comment in issue-hover-card.tsx claiming otherwise
was stale; corrected to match rich-content.tsx.

Deleting pagination.tsx orphaned its two i18n keys, dropped from all four
locales and from the `ui` namespace type.

Dependency declarations removed where nothing imports them:
- vaul, embla-carousel-react — only the deleted drawer/carousel used them
- date-fns, @tanstack/react-query-devtools — zero imports repo-wide
- motion, tw-animate-css from packages/ui — real users are views/desktop/web
- 44 of apps/web's 58 declarations, including all 15 @tiptap/* entries.
  One had already drifted: apps/web pinned tailwind-merge ^3.5.0 against the
  catalog's ^3.4.0, a second source of truth for the same package.

Added @types/hast to packages/views, which imports it directly.

knip enforces this going forward, scoped to files/dependencies/unlisted —
`exports` is unusable here because ui and views are consumed through per-file
exports maps. The CI step runs in warn mode (continue-on-error) and reports 9
pre-existing findings left out of this cleanup; clear those before promoting
it to blocking.

Verified: build, typecheck, lint green across web/desktop/ui/core/views.
6,503 tests pass; the 9 failures are pre-existing on a clean tree (a local
jsdom localStorage issue). Web dev server boots and serves /login 200.

Co-authored-by: multica-agent <[email protected]>

* fix(ci): cover the packages/ui blind spot knip cannot see

Review caught that knip provided no regression protection for the exact
thing the preceding cleanup was about. knip derives entry points from a
workspace's package.json `exports` — graph/build.js calls
`getEntrySpecifiersFromManifest` unconditionally, with no config flag to opt
out — and packages/ui exports four wildcards. Each expands to a glob over its
whole directory, so every file under components/ui, components/common,
markdown, and hooks registers as an entry point and can never be reported
unused. A zero-reference probe component confirmed it: knip's output was
byte-identical with and without it.

`entry: []`, negated `entry` patterns, `--production`, and `--strict` were all
tried. Manifest entries come from a separate code path that none of them
subtract from. packages/views is unaffected because 44 of its 45 exports name
a specific file, which is why knip does flag dead files there.

Added scripts/check-ui-wildcard-exports.mjs to cover those four directories,
blocking from the start since it reports nothing once the one file it found is
gone. It reads the wildcards from the manifest, so narrowing an export hands
that directory back to knip without the two overlapping. Its test asserts all
three states: clean tree passes, a stranded component fails and is named, and
importing that component clears it again.

The check found packages/ui/hooks/use-auto-scroll.ts — 80 lines whose only
occurrence repo-wide was its own definition. Same category as the 16
components already removed, and invisible to knip for the same reason.

Also from review:
- The path filter missed knip.jsonc and apps/docs/**, so config-only and
  docs-only PRs skipped knip even though it analyzes docs.
- The knip step paired `continue-on-error` with `|| true`, which zeroed the
  exit code before Actions saw it — no warning surfaced, only log text.
  Dropping `|| true` makes the warn phase visible and the later promotion to
  blocking a one-line change.
- Corrected the knip.jsonc comment that claimed apps were the only entry
  points for all three shared packages. True for views and core, never true
  for packages/ui.

Verified: build, typecheck, lint green (12/12, 0 errors). 6,503 tests pass;
the same 9 pre-existing failures as before, reproduced on a clean tree.

Co-authored-by: multica-agent <[email protected]>

* fix(ci): resolve import paths in the ui export check instead of matching names

Review found the check passed a dead component whenever any file elsewhere in
the repo imported a same-named module. The relative-import branch was a
whole-repo text regex on the basename, so `../components/option-card` in
packages/views vouched for an untouched packages/ui/components/ui/option-card.

That is not a corner case: 17 of the 59 guarded files already share a basename
with another file in the repo — `button`, `card`, `input`, `label`, `avatar`,
`tabs`, `switch`, `skeleton`, `separator`, `theme-provider` among them. shadcn
names are generic by construction, so the hole covered the most likely names a
future `pnpm ui:add` would produce.

Specifiers are now resolved against each importing file's own directory and
compared against real paths, with `@multica/ui/...` mapped through the same
manifest wildcards the candidate list is built from.

Liveness is now reachability from outside the guarded directories rather than
a reference count, so two dead components importing only each other no longer
vouch for one another.

Also implements the exemption the failure message promises: a file named by a
non-wildcard export is an entry point in its own right and is skipped. The
candidate loop previously read the wildcard directories unconditionally, so
following that advice would not have cleared the check.

Tests cover all five states: clean tree, stranded component named, cleared by a
real import, the option-card basename collision, and a mutually-importing dead
pair reported as two files.

Verified: check and its tests pass, knip unchanged at 8 files + 1 dependency,
build/typecheck/lint 12/12 green.

Co-authored-by: multica-agent <[email protected]>

* fix(ci): parse imports with the TypeScript parser, not a source regex

Review found the scanner counted commented-out imports as real edges. It ran
a regex over raw source, so a component kept its liveness from a line that
does not execute — and "delete the last real import, leave the comment
behind" is a normal step in a refactor, which makes the false edge appear at
exactly the moment the component stops being used.

Edges now come from real syntax nodes: static import/export declarations,
`import()`, `require()`, and `import("x")` type nodes. `vi.mock("...")` is
excluded on purpose — it names a module without importing it, so a component
whose only remaining mention is a test mock is dead, and counting it would let
one outlive its last real use.

Scope, measured rather than assumed: 52 specifiers in this repo appear only in
comments or prose, spread over 43 files, so the mechanism is live. None of
them currently resolve into packages/ui, so no dead component is being missed
today — this closes a latent hole, not an active miss. (An earlier count of 3
was wrong: those were `lazy(() => import(...))` calls that my throwaway
measurement script failed to treat as real.)

Test gains a sixth state: a component referenced only by a commented-out
import and a string containing one must still be reported.

Verified: six-state test passes, clean tree passes in 0.9s over 2,089 files,
knip unchanged at 8 files + 1 dependency, build/typecheck/lint 12/12 green.
`typescript` is already a root devDependency, so no new declaration.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 22:29:33 +08:00
e3a40b0b8d feat(MUL-6243): custom issue status UI — picker, board, filters, settings (#7103)
* refactor(views): widen IssueStatus to open key, resolve category at render sites

Board columns, swimlane columns and hidden-column panels are CATEGORIES,
not status keys, so adding a custom status never adds a column. Retyped
those to IssueStatusCategory and made every presentation lookup
(STATUS_CONFIG, i18n labels, status icons) resolve the key to its
category first. Status filters stay in concrete keys and map back to
columns via the catalog.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* feat(views): catalog-driven status picker and per-status labels

The picker now offers the workspace catalog's active statuses grouped by
category, instead of the 7 hardcoded built-ins, and falls back to the
built-ins until the catalog lands so a cold render is unchanged.

Built-in names always resolve through i18n, never through the catalog's
server-seeded English name — otherwise every non-English workspace would
see 'In Progress' where it used to see '进行中'. Custom names come from
the catalog. That rule lives in one place: useStatusLabel.

Applied it to every surface that shows an issue's OWN status (detail
activity feed, table group headers and CSV export, hover card, inbox),
which previously showed the category label and so could not tell two
statuses in the same category apart.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* feat(views): show custom statuses on cards and in filters

Board columns and list sections are categories, so two issues in the
same column can sit on different statuses with nothing to tell them
apart. CustomStatusChip is that signal, and it renders nothing for a
built-in — a workspace with no custom statuses looks unchanged.

The status filter now lists the workspace catalog instead of the 7
hardcoded built-ins. Without this an issue moved onto a custom status
could not be narrowed to at all. Picker and filter share one
useStatusOptions hook so the two lists cannot drift apart.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* feat(settings): issue status catalog management with drag reorder

Owner/admin CRUD for custom statuses, organised by CATEGORY rather than
as one flat list. Category is not decoration here — it is the behavior a
status inherits, and picking it is the one moment an admin can, since it
is immutable afterwards. Built-ins are shown but locked: each is its
category's canonical definition.

Drag reorder writes intra-category positions starting at 1, because the
built-in is seeded at 0 and PATCH on it is a 403, so it always heads its
category. The optimistic cache write re-SORTS rather than only writing
positions, or the drag would visually undo itself until the refetch.

custom_issue_statuses is now published to the frontend, so the tab can
hide creation when the deployment has the rollout gate off instead of
offering a button that 403s.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): close the category contract end to end

Elon's review found the board only half-converted: the UI targeted
categories while the server still paged by concrete status key, so a
card on a custom status was dropped twice — it never came back from the
server, and the row filter would have discarded it if it had.

1. New `status_category` group kind (and compound secondary axis).
   Grouping rewrites the column with a static CASE built from the
   catalog; paging expands the category to concrete keys and stays on
   `status = ANY(...)`, so (workspace_id, status) is still usable. A
   workspace with no custom statuses gets exactly `i.status` back.
   Row filter, facet totals and swimlane cells all fold by category now.

2. Column visibility split from the status filter. Hiding a column used
   to write the other six built-in keys into statusFilters, so hiding
   Backlog silently dropped every CUSTOM status in other categories.
   Display state is now its own field. The saved-view baseline also
   stopped deleting custom status filters on reopen.

3. The board/list right-click menu reads the shared catalog options, so
   every entry point that can set a status offers the same set.

4. Remaining presentation consumers resolve a category instead of
   guessing: gantt bars were keyed by IssueStatus and went colorless on
   any custom key; hover card, sidebar, search, tab strip, inbox row,
   create toast and the activity feed all resolve explicitly now.

5. Reorder is one atomic statement behind a new endpoint, and the client
   submits ACTIVE rows only. The per-row PATCH loop half-committed when
   an archived row rejected mid-sequence.

Also: one catalog observer per card instead of three, and the stale
tasks/todo.md draft is gone.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): atomic reorder, category behavior sweep, rollout-safe grouping

Second review round.

1. Reorder is now one transaction holding the catalog's SHARED lock. The
   old shape validated ids, THEN ran the UPDATE — an archive committing
   in that window left the UPDATE silently skipping the archived row
   while the earlier rows were rewritten, and still returned 200. The
   authoritative active set is read under the lock and the payload must
   match it EXACTLY: a partial order would assign positions from the
   array index and collide with the rows it left out. Affected rows are
   checked against the payload before commit.

2. Swept the Web behavior consumers that still compared raw keys, so a
   custom category actually inherits behavior: gantt hide-completed,
   batch backlog parking, done/cancelled strikethrough, the mark-done
   button, sub-issue progress, search cancelled-demotion and mention
   demotion/dimming. issueBehavesAs is the one predicate they share, and
   it fails toward showing more and asking more. Two glyph sites (gantt
   row, swimlane lane) were still painting custom in_review as Todo.

3. Rollout safety. status_category is a contract this PR introduces, and
   the default-off creation flag does not guard the request path, so a
   new Web build would have 400'd against any un-upgraded pod — for every
   workspace. The client now sends it only once the catalog confirms the
   workspace HAS a custom status, which can only be true if the fleet
   already serves this version. Everyone else keeps the exact request
   they made before.

   That also uncovered a real defect: plain status grouping 500'd on a
   workspace holding custom statuses, because the descriptor rejected any
   non-built-in value and failed the whole response.

4. One catalog read per resolve instead of three, and the common path
   reads none at all. Pinned with observable scan-count tests rather than
   an assertion that the SQL string matches.

5. A custom-key filter no longer routes on a guess. categoryOf answers
   todo for anything unloaded, so a saved qa filter paged the todo column
   while the query still restricted status=qa — empty on every cold load,
   permanent if the catalog fetch failed. Unresolved custom keys now
   contribute no column until the catalog is authoritative; facet counts
   drop unknown keys rather than inflating todo.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): swimlane category axis, catalog tri-state, tests with teeth

Third review round.

1. Swimlane was only half-converted. The compound spec sent
   secondary=status_category unconditionally — the same rolling-deploy
   hole the board/list branches had — and the client row matcher then
   compared the raw key, so a qa row the server correctly folded into the
   in_review cell was thrown away again. Both now follow the same
   hasCustomStatuses switch, and the matcher knows which axis it is on.

2. The cold-load fix did not actually make the surface wait. Returning an
   empty column set is indistinguishable from "narrow to nothing", so the
   surface fetched nothing and rendered an empty board with no spinner;
   on catalog failure that was permanent, with no retry. Resolution is
   now pending / resolved / error: pending and error suspend fetching AND
   hold loading, error surfaces isStatusCatalogError. Fetching is
   suspended rather than left to fall back to all columns, which would
   briefly show the UNFILTERED board to someone opening a saved qa view.
   The group contract also joins the branch cursor identity, so a status
   cursor cannot ride into a status_category request.

3. Two tests were false-green and are replaced:
   - The reorder race archived BEFORE calling reorder, so the active-set
     check caught it even with the lock deleted. It now reproduces the
     real interleaving (reorder parked on the lock, archive commits, then
     reorder proceeds) and fails without the shared lock — with a 200,
     which is the actual hole.
   - The catalog read count read pg_stat_user_tables, whose counters flush
     asynchronously, and asserted delta <= 1 — vacuous at zero. It now
     counts through an injected querier and asserts exactly 1 read and 0
     category-expansion reads.

Also: plain status grouping 500'd on a workspace holding custom statuses
(the descriptor rejected non-built-in values and failed the whole
response) — that is the path the Table view and every client fallback
use. And the chat mention items dropped status_category, so a custom done
status in Current/Recent rendered as an active Todo.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): give the status catalog failure a real user path

Fourth review round. One group, both halves of it.

A failed status catalog suspends row fetching, because a custom status
filter cannot be routed to a column without it. That left isLoading and
isEmpty both false, so IssueSurface fell straight through to the content
branch and rendered an empty view with no explanation and no way out —
the controller's isStatusCatalogError had no consumer.

The catalog now carries its own `retry` (React Query's refetch) rather
than just a failure flag, the controller re-exposes it, and IssueSurface
renders an explicit error state ahead of loading/empty/content. Component
test covers the full loop: catalog rejects, error appears with no row
requests issued, clicking retry succeeds, fetching resumes.

The blocking condition is also narrowed. A BACKGROUND refetch can fail
while the last successful catalog is still cached, and error-before-loaded
meant that perfectly serviceable snapshot got a retry screen in front of
it. Only a failure with no data behind it blocks now; otherwise the last
successful catalog keeps resolving.

That rule lives in status-behavior.test.ts, where it can be asserted
directly. The component test asserts the wiring — it was originally
written to claim the rule too, but reverting the narrowing did not fail
it, so the claim moved to the layer that actually holds it.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): accept custom status keys in table filters.statuses

Filtering a board, list or table by a custom status 400'd the whole
request: `filters.statuses` carries the exact keys the user picked, and
the table query still validated that list against the 7 built-ins. Every
surface errored out instead of narrowing — the one thing the filter work
in this PR exists to do.

The predicate is a parameterized `status = ANY(...)`, so an unknown key
simply matches nothing; the check is now non-empty and length-bounded.

Found while writing the post-deploy verification steps, which is where
the gap became obvious: "filter by QA" was step one and it 400s.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:52:11 +08:00
Bohan Jiang 4d5a679b9b fix(openclaw): unblock the OpenClaw runtime on slow hosts (MUL-6321) (#7142)
Fixes #7112.

Task preparation ran `openclaw config file` and `config get agents.list --json` with a hardcoded 5s deadline; on a 2020 Intel MacBook Pro those take 8.4-10.9s and 4.3s every time, so the OpenClaw runtime was unusable there with no way to raise the limit.

- Deadline 5s -> 30s, overridable via MULTICA_OPENCLAW_CLI_TIMEOUT / backends.openclaw.cli_timeout, clamped to [1s, 60s] so the worst case (4 serial calls) stays inside the 5-minute preparation budget.
- Config discovery cached per daemon profile (on disk, since preparation runs in a one-shot helper process), invalidated by binary identity, OpenClaw path env, active config stat, and a 60s TTL. Only complete successes are cached; any cache fault degrades to a live CLI run.
- The timeout is classified structurally into a new platform-side, non-retryable reason (runtime_cli_timeout) instead of being text-matched into agent_error.provider_network, which showed "check your network" copy and auto-retried a deterministic local failure. Copy added for web/desktop/mobile; a NormalizeDaemonReason shim upgrades reports from older daemons.
2026-08-18 18:33:23 +08:00
c670e05490 docs(changelog): add v0.4.29 release entry (2026-08-18) (MUL-6355) (#7145)
* docs(changelog): add v0.4.29 release entry (2026-08-18) (MUL-6355)

Co-authored-by: multica-agent <[email protected]>

* docs(changelog): refine v0.4.29 release title

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 17:26:19 +08:00
1b6d42864f feat(desktop): reland open Settings with Cmd/Ctrl+, (MUL-6233) (#7076)
Reverts the revert (#7079) of #7022, on top of the tab-remount fix in the
previous commit — the bug that made the feature look broken (MUL-6293)
lived in the tab host's workspace-context lifecycle, not in the chord.
Reverting the chord only stopped the fastest way to reach it; switching
tabs took the same path.

Feature is unchanged from #7022: Cmd+, (Ctrl+, on Windows/Linux) matched
in the main process alongside the other app-owned accelerators so it
fires from any window and any focus context, routed to the tabbed main
window through the pending-message queue, opening-or-activating a
Settings tab rather than navigating in place. Mod+, joins the reserved
keys in the shared shortcut registry.

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 16:53:02 +08:00
22db0b0a2c MUL-5651: preserve tasks through runtime network partitions (#7129)
* fix(runtime): preserve tasks through network partitions

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): bound reconnect retry waiting

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 15:30:48 +08:00
f78c867168 docs(tasks): describe the execution log where it actually lives (#7120)
The 'Viewing run history' section read as a two-step navigation ('go to the
Execution log' then 'open a run record'), which does not match the product:
ExecutionLogSection renders inline in the issue's right sidebar, expanded by
default, with a one-click View transcript on each row.

An external report (#7107) asked for an inline collapsible execution view on
the issue page based on this wording, and separately asked whether issue
pages auto-refresh. Both already exist; the docs described neither.

- Say the log lives in the issue's right sidebar, not a separate page/tab
- Note the default-expanded state, pinned active runs, Show past runs
- Point at the sidebar panel button for when the section is not visible
- State that the log updates live over the realtime connection
- Mention the header live chip and the transcript's Newest first toggle
- Add the sidebar screenshot ahead of the existing transcript screenshot

Applied to en/zh/ja/ko.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 12:50:20 +08:00
de902a4aab MUL-6273: feat(agent): add MiniMax Code ACP runtime (#7035)
* feat(agent): add MiniMax Code ACP runtime

* fix(agent): terminate mcode process trees

* fix(agent): address mcode review blockers

* test(agent): keep mcode model contract API-stable

* fix(migrations): resolve mcode prefix collision

* fix(agent): launch mcode through runtime command

Assisted-by: MiniMax Code

* fix(migrations): renumber mcode migration

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 12:44:27 +08:00
51ae68d8b9 fix(web): show the newest fully-uploaded release on /download (MUL-6313) (#7102)
The /download page rendered every Windows and Linux button as disabled.
v0.4.28's release run had its Windows desktop packaging job fail and its
Linux job never finish, so the release carried Mac builds only — and the
page reads whatever the newest release happens to be.

Release selection guarded against this with a time proxy: fall back to
the previous release for the first hour after publish, on the assumption
that a gap means uploads are still in flight. That only covers the
mid-flight case. A release whose packaging job failed outright never
completes, so once the hour elapsed the page pinned itself to a release
it could not fully serve.

Select on the actual signal instead: parse each of the recent releases
and show the newest whose desktop asset set is complete, so every button
on the page resolves to a real file. Widen the candidate window from 2 to
5 and log when the newest release is skipped, since a release that never
completes is otherwise invisible until someone reports dead buttons.

Completeness now has one definition, `hasCompleteAssetSet`, shared with
the "view all releases" fallback link that had counted assets inline.

Verified against live GitHub data: v0.4.28 is skipped and v0.4.27 serves
all twelve artifacts.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 02:38:42 +08:00
38c992ad0a docs(changelog): add v0.4.28 release entry (2026-08-17) (MUL-6305) (#7093)
Covers the 7 PRs merged onto main after the v0.4.27 tag: member actor /
multi_actor custom property types (#7075), bulk skill update from source
(#6930), the desktop changelog link on the update prompt (#7088), and the
same-workspace tab swap fix (#7092).

Bumps apps/web to 0.4.28. Patch bump: per-workspace custom issue statuses
(#7065) is the largest change in the window but ships behind the default-off
custom_issue_statuses rollout flag, so nothing user-facing today is
announcement-grade.

Test-infrastructure work (#7081, #7072) is intentionally absent from the
changelog: it changes no user-visible behavior.

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 22:12:53 +08:00
Bohan JiangandMultica J f66b0ba631 fix(desktop): keep the workspace singleton across a same-workspace tab swap (MUL-6303) (#7092)
Opening or switching a tab inside one workspace left the desktop client with
no active workspace: the sidebar vanished, every workspace-scoped request went
out without `X-Workspace-Slug` and came back 400, and nothing recovered it
short of relaunching the app (#7086).

Desktop mounts exactly one tab at a time and keys the host on the active tab
id, so a tab swap unmounts the whole router subtree and builds a new one
beside it. React renders the incoming tree before running the outgoing tree's
effect cleanups, and the release path added in #7064 guarded only on slug
equality. That guard sees a workspace SWITCH, but not a swap within one
workspace: the "+" button opens /<slug>/issues in the active workspace, so the
incoming layout carries the same slug, its render write dedupes to a no-op
inside setCurrentWorkspace, and the outgoing cleanup then finds
getCurrentSlug() still equal to its own slug and clears it. The incoming tab's
queries fire from effects, which run after that cleanup, so its very first
requests are the ones that go out unscoped.

Give each mounted layout an instance identity and have it claim the singleton
in the same render pass that writes it. The unmount release now bails when a
successor already owns the singleton. The slug check stays: it still covers
the same instance re-running for a new slug, where the singleton has moved on.

Tests mount the real keyed-host swap rather than calling unmount() by hand,
since the bug is entirely about React's commit ordering, and assert on the
slug a child query observes — that value is what became the 400s. The platform
mock now mirrors the real slug-equality early return, without which the
incoming layout's write would look like it could have saved the singleton.

Co-authored-by: Multica J <[email protected]>
2026-08-17 21:36:57 +08:00
21e77f3625 fix(desktop): link update prompt to changelog (#7088)
Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 20:18:04 +08:00
9a30c51dac MUL-6286: feat(properties): actor / multi_actor custom property types (members) (#7075)
* feat(properties): add actor / multi_actor custom property types (MUL-6286)

Custom issue properties gain their first reference type: `actor` (single) and
`multi_actor` (multiple), holding workspace members and agents.

Value shape is a prefixed string — "member:<user_id>" / "agent:<agent_id>",
multi being an array of them. Keeping it a plain string rather than an object
means the existing @> containment filter, the jsonb_path_ops GIN index and the
client value schema all keep working unchanged; an object-shaped value would
have been silently dropped by the client preprocess that guards against
unknown value shapes.

Scope decisions:

- Squads are out. The assignee pair accepts member/agent/squad, but a squad is
  a routing target rather than a person. The stored form carries the kind, so
  widening later needs no migration and no new type.
- Referencing an agent is NOT an assignment: nothing is enqueued, routed or
  notified. The picker therefore does not gate agents on runtime binding or
  invoke permission the way the assignee picker does — it gates on visibility,
  matching the server.
- Agent identity still needs protecting, so writes and facet reads both run
  through accessibleAgentIDs: a private agent never becomes referenceable or
  discoverable by id.
- multi_actor dedupes but preserves insertion order instead of canonicalizing
  like multi_select. There is no config order to sort against, and sorting by
  id would reshuffle the avatar row on every edit. @> containment is
  order-insensitive, so filtering is unaffected.
- Actor properties are filterable (including "= me", with the signed-in member
  sorted first) but not sortable or groupable — the same position multi_select
  already holds.

Both types ship together because a property's type is immutable: shipping
single-only would have forced anyone wanting multi to recreate the property
and re-enter its values.

Co-authored-by: multica-agent <[email protected]>

* refactor(properties): narrow actor properties to members only (MUL-6286)

Scope change after review: `actor` / `multi_actor` hold workspace members
only. Agents are out — a passive reference had to answer agent-visibility
questions (private / non-allow-listed agents must not become discoverable by
id) and carry a "referencing is not assigning" caveat everywhere, all for a
use case nobody has asked for. The original request was for members.

The envelope is unchanged on purpose, so this stays a one-line widening later
rather than a migration: the types are still named `actor` / `multi_actor`,
the stored value is still the kind-prefixed "member:<user_id>", and the
migration still allows both type names. Adding "agent" back means appending to
`actorPropertyKinds` — existing definitions would gain the kind in place, with
no new property type and no value rewrite.

What this removes:

- The agent branch of `resolveActorRefs`, including the accessibleAgentIDs
  visibility gate and the archived-agent check.
- `filterAccessibleActorFacetValues` entirely. Facet keys are now all
  `member:<uuid>`, and workspace membership is already visible to every
  member, so there is nothing left to gate.
- The agent section of the actor picker, and agents from the filter dropdown's
  candidate list.
- The "reference, not an assignment" framing from the skill and product docs —
  that distinction only existed because agents were allowed, and keeping it
  would now confuse rather than clarify.

`actorPropertyKinds` carries the note that any future kind which is not
universally visible to every member must restore the visibility gate on both
the write path and the facet read path.

Tests updated to match: agent refs are now rejected at the kind check, and the
private-agent visibility test is replaced by TestIssueActorPropertyFacets,
which covers the facet path that backs the "= me" filter.

Co-authored-by: multica-agent <[email protected]>

* fix(properties): address actor-property review findings (MUL-6286)

Four defects from review, plus one pre-existing bug found next to them.

1. Editing a multi_actor value silently deleted kinds the client did not
   recognise. The picker rebuilt the array from `actorRefsFromValue`, which
   filters unknown kinds — so once the backend widens actorPropertyKinds, an
   installed desktop build would drop every agent reference the moment the
   user toggled a member. The edit path now round-trips the RAW string list
   via the new `actorRefValuesFromValue`, and the toggle is extracted to
   `toggleActorRefValue` so the guarantee is directly testable.

2. Actor ids were stored exactly as received. `uuid.Parse` also accepts
   uppercase, braces and the urn: form, and every consumer (member directory
   lookup, "= me" filter, @> containment) compares reference strings exactly —
   so `member:<UPPERCASE-UUID>` passed validation and then rendered as Unknown
   and matched no filter. `parseActorRef` now stores the canonical form.

3. The CLI, docs and skill all promised member email as a way to address a
   value, but `resolveAssignee` only matched id and name. Email is now an
   exact-match alias ranked with id matches (it is as unambiguous as an id),
   so the documented behaviour is real. This also makes `--assignee` accept an
   email, which is a strict improvement.

4. An active actor-property filter rendered as a bare count. The chips bar
   loads each directory lazily and gated the member directory on
   assignee/creator filters only, then looked names up in `config.options`,
   which actor properties do not have. It now detects actor-typed filters,
   loads the directory, and renders names plus an avatar stack.

Found while fixing 4: the chips bar keyed its actor-name map by the
membership row id (`m.id`) while every actor filter value carries `user_id`,
so member names never resolved in the assignee and creator chips either. One
token, same expression, fixed here rather than left next to the new code.

Also drops two comments still describing the member/agent scope.

Co-authored-by: multica-agent <[email protected]>

* fix(properties): stop a single actor value overwriting an unreadable ref (MUL-6286)

Round-two review finding. The previous fix only protected `multi_actor`:
its toggle round-trips raw strings, so a reference kind this build cannot
parse survives an edit. Single `actor` had no such protection.

Once the backend widens actorPropertyKinds, an older client reading
`agent:<id>` in a single actor property would filter it out, render the field
as empty, and then — when the user "filled in the empty field" — replace it
outright. The user sees themselves setting a blank field; what actually
happens is a value they were never shown gets deleted.

The editor now degrades to read-only when a single actor value holds a
reference it cannot parse, and the display says so instead of showing the
empty-state label. Clear is still offered, so a genuinely stale value can be
removed deliberately. `multi_actor` stays editable — its toggle is already
non-destructive.

The read-only decision moved into an exported `isCustomPropertyReadOnly`, so
all three reasons it can fire (archived, unknown type, unreadable actor ref)
are covered by tests rather than being inline in the component.

Also closes the two non-blocking gaps from the same review:

- The "email outranks a name substring match" CLI test asserted a ranking its
  fixture could not actually exercise — no candidate's name contained the
  email. Added an agent named "Mailer for [email protected]", so the test now
  fails if email stops being ranked with id matches.
- Extracted `buildChipActorNames` and asserted a member resolves by `user_id`
  and NOT by the membership row id — the pre-existing bug fixed in the last
  commit now has a test that would catch its return.

Co-authored-by: multica-agent <[email protected]>

* chore(migrations): renumber actor-types migration to 341 (MUL-6286)

332 was taken by 332_issue_status on main while this PR was open; the
migration lint test caught the collision in CI.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 19:30:03 +08:00