Commit Graph
690 Commits
Author SHA1 Message Date
Jiayuan Zhangandmultica-agent 8ade96e671 MUL-6584: establish Public API v1 foundation (#7450)
* feat(api): establish public v1 contract foundation

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

* fix(plugins): close public API contract gaps

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

* fix(issues): enforce revision guard during update

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 22:47:37 +08:00
Jiayuan Zhangandmultica-agent 5bf10d485c MUL-6582: add durable scheduled Plugin hooks (#7448)
* MUL-6582 feat(plugins): add durable scheduled hooks

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

* MUL-6582: add schedule-pulse example plugin

Ship a real scheduled plugin that records each unique delivery_id in
workspace storage, so a host retry does not look like a second pulse.
The cmd/server test installs this example manifest and drives two
scheduler replicas through a dropped first attempt.

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

* MUL-6582 fix(migrations): make schedule indexes retry-safe

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 17:37:21 +08:00
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
aa4c504c78 fix(issues): re-rank an issue when its status moves it to a new column (#7422)
position ranks an issue inside its own (workspace, status) column, but a
status change carried the old column's value over unchanged. The value
that put an issue on top of Todo is roughly zero, and lands it below
every hand-dragged issue in Done — so agent-completed work sank into the
middle of a long Done column and read as "the task disappeared".

Re-rank to the top of the destination column on both status-write paths,
reusing the MIN(position) - 1 policy new issues already get. An explicit
position still wins, so cross-column drag-and-drop keeps the slot it
dropped on, and a same-status write leaves position alone.

The re-rank reads MIN(position) for the destination (workspace, status),
which idx_issue_status could only serve with a heap fetch per issue in
the column — paid on every completion against the column that grows
without bound. Add (workspace_id, status, position) to make it an
index-only min.

Closes #7410

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 12:36:10 +08:00
1cc46b2697 MUL-6516 fix(channels): enforce durable context boundaries for /new (#7362)
* fix(channels): enforce new context boundaries

* fix(channels): address context boundary review findings

* fix(channels): enforce causal new context boundaries

* fix(channels): scope outbound provenance to target

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-22 11:27:39 +08:00
13f9f8e5ca MUL-6529: let admins add workspace seats (#7388)
* feat(billing): let admins add workspace seats (MUL-6529)

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

* fix(billing): recover seat purchase retries

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

* fix(billing): reconcile seat purchase feedback

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

* fix(billing): parse processing seat purchases

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 16:16:16 +08:00
f45b591623 MUL-6469: host plugin artifacts, bind installs to immutable versions (#7321)
* 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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 16:03:44 +08:00
003a505b87 MUL-6486: reduce stale dispatch reclaim load (#7375)
* perf(tasks): gate stale dispatch reclaim scans

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

* fix(tasks): harden reclaim scheduling gate

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

* fix(tasks): retry unreclaimed dispatch hints

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 15:36:54 +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
1dc135598e MUL-6343: Enforce recently created issue windows (#7305)
* MUL-6343: enforce recently created issue windows

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

* fix(issues): address creation window review

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

* fix(issues): bound table window ID cache

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 12:55:57 +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
7186cf1b21 MUL-6456: retire the issue last activity index (#7296)
* fix(migrate): retire issue last activity index

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

* test(migrate): cover repeated index retirement

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:16:34 +08:00
fa805238f5 MUL-6456: make comment search indexes mutually exclusive (#7288)
* fix(search): make comment search indexes mutually exclusive

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

* fix(migrate): address comment index review feedback

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 14:44:09 +08:00
a107570e3b MUL-6253: add multica issue timeline CLI command (#7285)
* feat(cli): add `issue timeline` so agents can read issue history

The timeline endpoint (GET /api/issues/{id}/timeline) already merges the
activity log with comments and has been serving Web and Desktop since #1929,
but the CLI never exposed it — so agents, whose only entry point is the CLI,
were the one consumer in the system that could not see issue history.

That gap has a concrete failure mode (#7040): a status change writes an
activity record, never a comment, so a PR merge that flipped an issue to
`done` leaves no trace in the comment stream. A summarising agent reading
comments therefore reports "waiting for merge" indefinitely, and reading
harder does not help — the information is not there.

Adds `multica issue timeline <id>` (alias `history`) over the existing
endpoint. No server change.

Filtering is client-side because the endpoint takes no filter parameters and
returns the whole timeline in one shot; the flags bound what the caller has to
read, not what the server has to do:

  --activity-only  drop comment bodies, keep state transitions
  --action         filter to specific actions (implies --activity-only)
  --since          RFC3339 lower bound
  --tail           N most recent entries, applied last

The request is deliberately sent with no pagination parameter: the handler
only returns the flat oldest-first array when none is present, and sending
`limit=` flips it to a wrapped object that would decode to nothing. A test
pins that.

The help text is written so an agent scanning `multica issue --help` for
"how long has this been stuck" actually matches it — the command is meant to
be discovered on demand rather than pinned into every runtime prompt.

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

* fix(cli): surface timeline truncation and correct the event contract

Addresses review on #7285.

Truncation was silently swallowed. The handler caps each kind and reports
which ones lost rows via X-Timeline-Truncated, but the command used GetJSON
and never saw the header. That is worst precisely where this command is meant
to be trusted: if the transition that set the current status fell off the back
of the window, `--action status_changed` returns a plausible, shorter, WRONG
answer to "how long has this been stuck". Now read via GetJSONWithHeaders and
warned on stderr, naming the affected kinds and stating that durations cannot
be concluded from a truncated read. Stderr, not stdout, so a piped
`--output json` stays clean — pinned by a test.

The event contract was inaccurate. handleTaskActivity records task_completed
and task_failed, which `--activity-only` has always passed through, but the
flag help claimed it returned only field changes and the "Known actions" list
omitted both. (An earlier grep for `Action: "..."` literals missed them: those
two are dispatched with the action as a variable.) Help and action list now
match what the server actually writes, and the "how long" example uses
--action status_changed rather than --activity-only, which is the accurate way
to isolate state transitions.

Also fixed: an activity with actor_type=system and no actor_id rendered a
blank ACTOR column. A GitHub PR merge flipping an issue to done is exactly
that shape — the transition this command exists to explain — so it now falls
back to the bare type.

Adds a help-contract test. The command is deliberately absent from the runtime
brief and is meant to be found by scanning `issue --help`, so the discovery
anchors are load-bearing enough to pin.

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:24:41 +08:00
b4bfe083be MUL-6407: mint UUIDv7 primary keys for append-heavy tables (#7230)
* perf(db): mint UUIDv7 primary keys for the append-heavy tables

Random v4 primary keys scatter every insert across the whole primary-key
B-tree, so each row dirties a different page. On the tables that grow
continuously — the task queue, messages, comments, activity and audit rows —
that is pure write amplification. UUIDv7 puts a big-endian millisecond
timestamp in the leading 48 bits, so consecutive inserts share a page.

The 12 tables converted here are the append-only hot paths:

  agent_task_queue, activity_log, inbox_item, comment, issue, chat_session,
  chat_message, autopilot_run, webhook_delivery, task_token,
  channel_inbound_audit, sys_cron_executions

Ids come from the new pkg/dbid, which returns a pgtype.UUID ready to assign to
a sqlc params struct. Upsert-dominated tables (task_usage, agent_runtime, ...)
are deliberately left alone: v7 only buys locality for rows that are actually
inserted. Lease and claim tokens keep gen_random_uuid() — they must stay
unpredictable and carry no timestamp.

No migration, and no backfill. Every converted INSERT binds its id as
COALESCE(sqlc.narg('id')::uuid, gen_random_uuid()), so the column default stays
reachable: a caller that passes no id inserts exactly as it does today. Tables
will hold a mix of v4 and v7 ids permanently, which the uuid type and every
parser in the repo already accept.

Verification: - pkg/dbid: version/variant, uniqueness, and a non-decreasing timestamp
    prefix; a shape test asserting every converted query keeps the COALESCE
    fallback; and a source scan asserting every production params literal mints
    an id, so a call site added later cannot silently fall back to v4.
  - internal/handler: round-trips issue, comment, activity_log, inbox_item,
    chat_session, chat_message, agent_task_queue, task_token and
    channel_inbound_audit against Postgres and asserts the stored id is v7,
    plus one case proving an unset id still inserts (as v4).
  - internal/scheduler: covers the one hand-written INSERT, asserting the
    execution id is v7 while lease_token stays v4.
Co-authored-by: multica-agent <[email protected]>

* fix(db): address UUIDv7 review feedback

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

* fix(db): cover Telegram audit UUIDv7

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 13:42:44 +08:00
4c007e34ef MUL-6414: bound Redis relay stream retention (#7234)
* fix(realtime): bound Redis relay stream retention

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

* fix(realtime): make stream TTL rollout safe

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

* fix(realtime): lower default relay maxlen

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 13:19:54 +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
4bf3c73a15 feat(plugins): agent integration — skill resources, agent trigger, mcp transport (MUL-6350 PR 4) (#7242)
* feat(plugins): skill resources into the existing skill table (MUL-6350 PR 4, 1/3)

A plugin's skill resource becomes an ordinary row in the skill table. Not a
plugin-owned copy, not a bundle, not an artifact with a digest: the previous
plugin system built all of that across fourteen tables and, at the end of it,
delivered one SKILL.md that this table could already hold.

The only thing the platform has to remember is which installation contributed
which skill, so uninstall removes exactly those and nothing a person wrote.
That is one nullable column.

The upsert is scoped to rows this installation owns, so a plugin claiming a
name somebody already used fails the install instead of silently replacing
their work — and the test asserts the human's content survives. Upgrade prunes
what the new manifest no longer declares, or a renamed skill would leave its
predecessor behind with nothing to attribute it to.

Fresh install is now a transaction too. It was a single INSERT; skills have to
land in the same commit, because an installation whose skills half-arrived is
worse than one that failed — the missing half is invisible.

Skill resources flip on here rather than with the rest of PR 4: nothing
executes, so it does not wait for the daemon-side MCP server that the agent
trigger and mcp transport still need.

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

* feat(plugins): agent trigger — hooks as MCP tools (MUL-6350 PR 4, 2/3)

The fourth call site, and the only one that is not the host deciding to call
something: an agent reads a tool description and chooses. That choice is why
hooks may be reached from an agent at all — the alternative, a hook that must
run before or after every turn, is a third party holding the product's main
loop open, and no such position exists anywhere in this design.

The daemon renders the tools but does not call the plugin. A tool call goes
back to Multica, which makes the signed request. Two reasons, and the second
matters more: the signing secret is derived from the deployment key and putting
it on every laptop running an agent would hand out a credential that can
impersonate the server to every plugin backend; and routing through the server
keeps the rate limit, circuit breaker, `net:` destination check and invocation
record on one code path for all four triggers instead of a daemon-side copy
that would drift.

A failing hook is a TOOL error, not a transport error. The agent reads it,
works around it, and finishes the issue — an unreachable plugin endpoint must
not fail somebody's task. The server returns 200 with a status for the same
reason: a non-2xx would look to the daemon like the broker itself is broken.

TestPluginToolNameSeparatorCannotBeForged caught a real flaw in my first
naming scheme. Sanitizing both halves and joining with an underscore is not
injective — a plugin key uses `.` and `-`, both of which have to become `_`, so
`a.b` and `a-b` collapse together and `a.b_` + `c` collides with `a.b` + `c`.
Two plugins would have been offering the agent one tool name. The prefix now
carries a short digest of the full key rather than a lossy transliteration.

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

* feat(plugins): mcp transport with pinned tool approval (MUL-6350 PR 4, 3/3)

An `mcp` hook adopts tools from an MCP server the plugin author already runs.
The difference from an `http` hook is who decides the shape: an http hook
declares one endpoint in a manifest an administrator read, while an MCP server
decides its own tool list at runtime. So installing the plugin is not the grant
— an administrator discovers the tools and pins a specific set by name and
schema digest, and a tool that appears later is not adopted until approved.

Approved hooks reach the daemon as ordinary remotemcp.Connection values, so the
existing broker proxies them and validatePinnedRemoteMCPTools already refuses to
start when an approved tool went missing or its schema drifted. No new
enforcement path.

Plugin connections carry a `plugin:` contribution-id prefix. They share the
broker and the resolver with a workspace's own Remote MCP connections but keep
their credential in the Plugin's secret storage, which a different route serves,
and the contribution id is all the broker hands back at dial time.

Flips TransportMCP on in HostCapabilities, completing PR 4's capability set.

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

* test(plugins): cover mcp connection startup via the dev-origin seam

The `http` transport has had MULTICA_PLUGIN_DEV_ORIGINS since the hook engine
landed: an operator names exact origins, and endpoints there skip the
public-internet requirement so a plugin author can develop against a server on
their own machine. The `mcp` transport never got it.

Two symptoms, one cause. A plugin author could not point an mcp hook at a local
MCP server, and — for the same reason — the daemon's connection startup could
not be reached by a test, because it validates the endpoint as public HTTPS and
the repo's MCP fixture serves plain HTTP on loopback. That left the layer
carrying the entire approval guarantee at 0% coverage:

  startTaskRemoteMCPBrokers      0.0% -> 59.5%
  validatePinnedRemoteMCPTools   0.0% -> 100.0%

The seam is off unless the variable is set, and a test asserts that: the same
loopback connection is refused when it is empty. It skips only the
public-internet requirement — the net: scope still applies, and the dial-time
host pin still refuses a redirect that changes host.

Tests cover what the approval is for: an approved tool the server dropped, an
approved tool whose schema drifted, and an optional connection that fails
without failing the task. Each was verified by breaking the guard it covers.

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

* feat(plugins): a real example plugin, and the two bugs it found

examples/plugins/deploy-sentinel is an incident-response plugin: it correlates
an issue with recent deploys, files rollback requests under the team's own rules
(refusing deploys past a window, and reasons too short to be evidence), pages
on-call on issue events, and adopts an external metrics MCP server. Four
triggers, both transports, and a skill that tells an agent when to use any of it.

The other examples each demonstrate one mechanism. This one was written to find
out whether the mechanisms compose, and it found two things that no unit test
was ever going to:

1. mcp discovery could not succeed against ANY server. DiscoverMCPHookTools
   passed a nil protocol-version list, and Discover checks the server's answer
   against that same slice — empty means nothing is acceptable. Discover now
   falls back to SupportedProtocolVersions() when the caller passes none.

2. A hook handler had no way to read its own configuration. The manifest's
   config block drives a host-rendered form, the Action API has no config
   endpoint, and the hook body did not carry the values — so a plugin would have
   had to keep a second copy on its own server. The body now carries non-secret
   config. Secret-typed fields are excluded; they live in a separate table and
   are the plugin's credentials for its own services.

Also lets an mcp dev origin trust MULTICA_PLUGIN_DEV_CA, mirroring the http
transport. The manifest validator requires HTTPS, so without this a local MCP
server could not be developed against at all.

plugin_example_test.go installs the real manifest from examples/, not a copy, so
a broken example fails the build rather than rotting quietly.

Migrations renumbered to 368/369 after main advanced. 362 is added to the
legacy-duplicate allowlist: two PRs took that prefix within the same hour and
both merged, and the ledger keys on the full stem, so renaming one would re-run
it everywhere it has already applied. main is red on this today.

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

* fix(plugins): the deploy-sentinel example servers must serve HTTPS

Both .mjs servers used node:http. A hook's transport URL has to be an https://
URL or the manifest fails validation, so the example as shipped could not
actually be installed against a running Multica — the README told you to set
MULTICA_PLUGIN_DEV_ORIGINS to https:// origins that nothing was listening on.

Found by running it: install, config, skill contribution and live MCP tool
discovery all work against the real servers once they speak TLS. They read
TLS_CERT / TLS_KEY and the README carries the openssl one-liner.

MULTICA_PLUGIN_DEV_CA changes which certificate is trusted; it never disables
verification, which is why a self-signed certificate is needed rather than
plain HTTP.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 02:35:29 +08:00
Wu Changming 7fdc854c26 fix(issues): copy durable local path for worktree tasks (MUL-6378) (#7176)
A worktree-mode local_directory task runs in a disposable git worktree that
Finalize removes on every exit path, so the task's `work_dir` pointed at a
directory that no longer existed. "Copy local workdir path" handed the user a
path they could not `cd` into.

The daemon now reports a `durable_work_dir` handoff instead of having clients
infer one:

- `removeLocalWorktreeDir` returns an error and confirms removal with `Lstat`,
  so a broken symlink cannot be mistaken for an absent directory. Finalize
  fails when removal cannot be confirmed.
- `durable_work_dir` is written only after Finalize succeeds and the worktree
  is verifiably gone. A preserved worktree, an in-place run, a running task, a
  squad-leader task, and any older daemon all leave it unset, keeping the real
  `work_dir` authoritative.
- The field travels on the complete, fail, and cancel-ack callbacks and is
  persisted on the task row (migration 362, additive nullable column). Writes
  use COALESCE for replay idempotency; the cancel-ack path CASes on
  `status = 'cancelled'` so a late ack cannot touch a completed or failed row.
- The server derives `relative_durable_work_dir` through the same
  home-stripping helper as `relative_work_dir`; the absolute value is reserved
  for explicit clipboard actions and is never rendered.
- Clients select the durable path only for a terminal task that carries the
  field. The issue actions menu and the transcript dialog share one resolver,
  and the label and toast say the task worktree was reclaimed, naming the
  delivery branch when one exists.

The stored path is a point-in-time delivery snapshot: later renames or
detachments of the project resource do not rewrite historical tasks.
2026-08-20 01:30:58 +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
659bb41abd MUL-6350: rebuild the plugin system (3/4) — hook engine (#7182)
* feat(plugins): hook engine — signed outbound calls, trigger-decided identity (MUL-6350)

The direction reverses here. PR 1 and 2 only ever ran inbound: a sandboxed
surface asked the host, and the host acted on the signed-in user's session, so
nothing left our infrastructure on a plugin's say-so. A hook leaves, and every
new check exists because of that.

Destination: the target host must be inside the granted `net:` set, resolve to
a public address, and be re-checked at dial. Body: HMAC-SHA256 over
timestamp.body, so a receiver can tell our call from anyone else's and a
captured one expires.

Identity follows the trigger, not the plugin. A ui/manual hook runs because a
person pressed something, so its writes stay theirs; an event hook has nobody
behind it and writes as the installation — hence the fourth author_type.

Two credentials, moving opposite ways, which is what decides how each is
stored. The install token goes plugin -> host and is only ever verified, so it
is hashed. The signing secret goes host -> plugin and must be reproduced to
sign with, so it is derived from the deployment key and stored nowhere.

Event hooks never block the host: Bus.Publish runs listeners inline on the
publishing request's goroutine, so the dispatcher hands off to a worker pool
immediately. Three attempts, then a circuit breaker, then silence.

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

* feat(plugins): manual hook actions, ui trigger, and a worked hook example (MUL-6350)

The host owns the manual menu entry rather than letting a surface draw one,
because the trigger decides identity: a manual call acts as the person who
picked it, so the entry has to live where the host can prove somebody did.

The ui trigger goes through the bridge for the same reason a surface cannot
fetch its own backend directly — routing through the host is what makes the
call signed, rate limited, bounded to the granted net: domains and recorded.

buildActorNameResolver gains a plugin branch. Without it an event-written
comment rendered as "System", which is the one attribution that is actively
misleading: it reads as the platform having said something.

examples/plugins/triage-notify carries the half that does NOT run in Multica —
a reference handler showing the four things a receiver must do, including the
replay guard the host cannot do for it.

TestShippedExamplesParseAndInstallOnThisHost closes the gap that let an example
drift: nothing checked that shipped documentation still parses, or that it
declares only what this build can run.

TestCheckCapabilitiesReportsEveryUnavailableContribution rewritten the way the
other two were in PR 2 — it named today's unshipped set, so flipping ui/manual/
event on turned it red with a message about the flip rather than about the gate.
It now derives its expectation from HostCapabilities and survives a flip while
still failing if the gate stops checking a category.

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

* feat(plugins): modal surface, invocation cleanup, honest capability guards (MUL-6350)

TestShippedHostCapabilitiesRunTheSurfacesTheHostMounts caught me flipping the
modal surface on without a renderer — an install that succeeds and then never
appears, which is the exact failure that guard exists to prevent. Built the
mount point rather than weakening the guard, and rewrote it to name the host
code behind each line so the next flip has to change both together.

plugin_invocation was missing from workspace teardown and from uninstall.
TestWorkspaceDeletionManifestCoversPublicSchema caught the first; the second
would have left rows naming an installation that no longer exists. Deleted by
workspace_id rather than through installation ids, so a record whose plugin was
already uninstalled does not outlive the workspace it describes.

A modal opens because a person picked it from the issue menu, never on the
plugin's own initiative — same sandbox as a panel, differing only in where it
appears.

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

* test(plugins): prove the event path never blocks, and contain worker panics (MUL-6350)

Writing the never-blocks test found a real bug rather than confirming one.
Bus.Publish recovers panics in its listeners so one bad handler cannot kill the
request that published; moving delivery onto a worker goroutine stepped outside
that protection, where a panic is not a failed hook but a dead process.
runGuarded restores the guarantee the hand-off gave up.

The bridge now takes an EventSink rather than the concrete dispatcher, so the
vocabulary mapping — which internal event becomes which published one — is
asserted directly instead of through a live endpoint. That mapping is a
published contract; the internal names are not.

The backpressure test stops the workers rather than racing them, so it
reproduces a saturated pool without depending on timing, and asserts the exact
overflow instead of "something was dropped".

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

* fix(plugins): two hook-contract bugs a live run found (MUL-6350)

Both were invisible to the tests because the tests made one call each, and a
real handler does not.

The callback token was single-use. The reference handler reads the issue,
decides, then posts a comment — and the second call died on a spent token.
Read-then-write is the floor for a handler that does anything with what it
read, so a single-use grant made the callback unusable.

The trade that decides it is not one call versus several. It is this token
versus the installation's standing token: a handler that cannot finish with the
callback gets written against the install token instead, which never expires
and is not scoped to an invocation. A control that pushes authors toward the
stronger credential is worse than the looser one they will actually use. The
grant is now invocation-scoped — minutes, this installation's scopes, the actor
fixed at dispatch — and revoked as soon as the call it was issued for returns.

The hook body also omitted the issue. The host resolves and permission-checks
it, then said nothing, so a handler had to read a client-supplied field —
unvalidated for ui/manual, and absent entirely for event, where there is no
client. issue_id now travels in the body.

Also adds MULTICA_PLUGIN_DEV_CA: a hook URL must be HTTPS, so an author testing
against localhost needs its certificate trusted by something. A named CA file
rather than InsecureSkipVerify, which survives into production behind a config
flag nobody remembers and cannot be spotted by reading the code.

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

* fix(plugins): enforce the callback token's issue scope, sweep invocations (MUL-6350)

Addresses Fabel 5's review. The first item is the one that mattered.

The callback grant's issue narrowing existed only in two doc comments and the
PR description — pluginTokenCaller never read grant.IssueID, so a ui/manual
token was worth every issue in the workspace its actor could see, for the whole
five minutes it lived, on read and write alike. Now carried into the caller and
checked in pluginIssueForUser, after resolution so an identifier and a uuid for
the same issue agree. 404 rather than 403: "you are scoped elsewhere" confirms
the id exists.

DeleteExpiredPluginInvocations was generated and never called, while the table
comment and the PR both said TTL-swept. A sweeper now runs on the dispatcher's
lifecycle — same concern, bounded resources for something a third party drives.

Event subscriptions bypassed the read scopes. issue.* pushes the description
and comment.created pushes the body — the same content the Action API refuses
without issues:read / comments:read. Subscribing was a way to receive what
reading was not granted. Enforced at manifest validation so the consent screen
shows the read scope a subscription implies.

Migration 349's ADD CONSTRAINT copied 107's shape onto a table that is no
longer 107's size: it revalidated every comment row under ACCESS EXCLUSIVE.
NOT VALID plus a 352 that VALIDATEs under SHARE UPDATE EXCLUSIVE costs nothing
and stops nothing.

Plus the half-built breaker UI (a plugin could be enabled and silently
delivering nothing), redirect refusal on the dev hook client (a 302 would have
replayed the signed body and the callback token onward), and omitting
callback_url rather than sending a relative one.

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

* fix(plugins): the invocation sweeper must not touch the database at construction (MUL-6350)

CI panicked in cmd/server: the sweeper I added last round fired immediately on
its own goroutine, and that test builds a router over a Queries whose pool was
never opened. pgxpool dereferenced the nil pool and the process died.

Two things were wrong, and only one of them is the obvious one.

The sweep no longer runs at construction. It waited for nothing and gained one
hour of retention on a cold start against a 7-day TTL; the cost was a goroutine
reaching for the database before the process is known to have one.

The nil check was also the wrong instrument. sqlc's Queries wraps an executor,
so a non-nil Queries over an unopened pool passes every comparison available at
that layer and only fails inside pgxpool — no amount of nil-checking here can
see it. The guard is a recover, for the same reason runGuarded exists: on a bare
goroutine a panic is not a failed sweep, it is a dead server. I had already
fixed that class once for delivery and then reintroduced it in the sweeper.

The regression test builds the exact shape CI hit — a dispatcher over db.New(nil)
— and fails if either the construction-time sweep or the recover comes back.
cmd/server cannot reproduce it locally, where DATABASE_URL supplies a real pool.

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

* fix(plugins): gate the event dispatch path behind plugins_v1 (MUL-6350)

Answering "is this still behind the flag" turned up the one place it was not.

Every other plugin surface is refused by a handler that reads the flag off a
request. An event hook has no request: the dispatcher is subscribed to the bus
at startup and delivers on a worker. Nothing in it consulted the flag, so
turning plugins off hid the UI and refused the Action API while the OUTBOUND
calls kept going — fail-open on the one path that reaches a third party.

It only bites after a plugin has been installed, because installation is
flag-gated, so a deployment that never enabled plugins was never exposed. A
workspace that enabled it, installed something, then turned it off was.

The check reads the flag per delivery rather than once at subscription, so the
flip takes effect immediately instead of at the next restart. It also removes
the flag-off cost: every dispatched event used to run a
ListWorkspacePluginInstallations query to discover there was nothing to call.

Nil flags read as disabled — a deployment that never wired the flag service must
not get outbound hooks by omission.

The test lives in internal/handler because it needs a real installed plugin to
mean anything: with the flag on the endpoint is called, with it off nothing
leaves while the same installation stays enabled and still declares the hook.
Removing the check makes it fail on the second half.

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

* perf(plugins): stop parsing event payloads on the publishing goroutine (MUL-6350)

Found while answering whether merging can affect existing users, and the answer
was "in one measurable way it can".

The bus listener passed issueIDFromPayload(e.Payload) as an argument, so a JSON
marshal plus unmarshal of the full event payload — for issue.created that is the
whole issue body, description included — ran inline on the goroutine of whatever
request published. Seven event types, every workspace, and the feature flag was
checked far downstream on the worker, which then threw the result away. A
deployment with plugins switched off paid for all of it.

The listener now hands the payload over unexamined and the id is extracted on
the worker, past the flag check. It also fits the rule this file already
claimed: the publishing request does the least it possibly can.

Not moved: the status_changed lookup, which is a map read, not a parse.

TestPublishingDoesNotParseThePayload counts MarshalJSON calls during Publish and
fails if any happen — restoring the old argument makes it fail with the count.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 19:29:15 +08:00
5d5fe64672 MUL-6343: Add semantic issue activity timestamps (#7193)
* feat: add semantic issue activity timestamps

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

* fix(migrations): register issue activity index cleanup

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

* fix(issues): address last activity review

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

* fix(issues): close last activity review gaps

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

* fix(issues): harden activity rollout safeguards

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:08:40 +08:00
3460f9aeaf MUL-6342: enforce entitlement-backed autopilot quotas (#7194)
* feat(autopilot): enforce entitlement run quotas

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

* chore(deploy): expose entitlement policy config (#7195)

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

* fix(autopilot): address quota review findings

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

* fix(autopilot): finish quota review nits

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

* refactor(autopilot): simplify quota schema

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:51:26 +08:00
eb2a94e860 MUL-6368: Prevent cross-agent task and workdir contamination (#7205)
* fix(daemon): isolate task identity and workdir reuse

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

* test(llm): use validated retry override in contract

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

* fix(daemon): address task isolation review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:30:19 +08:00
aa6d0dadcf MUL-6390: expose Go pprof on a loopback port (#7200)
* feat(server): expose loopback pprof endpoint

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

* test(server): remove redundant profiling tests

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

* feat(server): complete pprof diagnostics support

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

* fix(server): keep pprof fixed and tool-compatible

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:09:47 +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
76a90f602c MUL-6324: Reduce chat resume query load (#7151)
* fix(server): reduce chat resume query load

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

* fix(server): address chat resume review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 12:40:04 +08:00
97195885fa MUL-6350: rebuild the plugin system (2/4) — Action API + Surface + SDK (#7157)
* MUL-6350 feat(plugins): Action API and via-plugin attribution (2/4, part 1)

The server half of PR 2. A surface can now read and write through Multica, but
nothing renders one yet — the iframe host, SDK and example plugin follow.

Every Action API call passes three checks in order:

  1. the installation exists and is enabled — a surface left open in a stale
     tab stops working the moment an admin disables the plugin, rather than
     merely disappearing from the settings page;
  2. the installation holds the scope the endpoint needs, and the refusal names
     the missing scope so an admin can act on it;
  3. the SIGNED-IN USER may touch the resource.

Three is what makes the model safe, and it is deliberately not implemented
here: the workspace comes from the installation row rather than a client
header, membership is checked against that workspace, and the issue lookup is
scoped to it. A plugin therefore inherits exactly the reach of the person using
it, with no second copy of the permission rules to drift.

There is no plugin credential anywhere in this path. The iframe holds no token;
it posts a message to the host page, and the host re-issues the call on the
user's own session with a header naming which installation is speaking.

comment.via_plugin_id is the other half of that identity story: permission-wise
a plugin-mediated write is the user's, so author_type/author_id are unchanged,
but audit-wise it stays attributable to the plugin that produced it. Modeled on
comment.quick_action_id — no request field sets it, so it cannot be forged by a
member posting normally.

Two deliberate narrowings, both additive to widen later:

  - PATCH issues covers title and description only. Status, priority, assignee,
    parent, project and stage carry dispatch, catalog or hierarchy semantics
    that would need a second, drifting copy of those rules here.
  - A plugin-posted comment does not run @mention trigger dispatch. A plugin
    able to post a mention could start agent runs, and spend the workspace's
    budget, from a surface the user only meant to click a button in.

issue_panel and sidebar_panel flip on in HostCapabilities so an install stops
failing on a surface the host is about to be able to render.

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

* MUL-6350 feat(plugins): surface host, bridge and SDK (2/4, part 2)

The half a user can see. An installed plugin's issue_panel now renders on the
issue page and can read, write and store through the Action API landed in the
previous commit.

Isolation reuses the convention already in this repo for untrusted HTML
(packages/views/editor/code-block-iframe.tsx): sandbox="allow-scripts" and NOT
allow-same-origin, which the HTML spec calls out as the pairing that defeats the
sandbox. The frame gets an opaque origin — no cookies, no localStorage, no
access to the embedder, and no storage shared between two plugins. That is why
no separate plugin origin domain is needed.

The host generates the document rather than pointing the frame at the author's
HTML. That is what makes the net: scopes real: CSP is decided by whoever emits
the document, so an author-emitted page would carry its own server's policy and
the scope the admin approved would be a claim nobody checks. Here connect-src is
derived from the granted scopes, and a plugin with no net: scope gets
connect-src 'none' — it cannot phone home at all.

Identity on the bridge is bound by a MessagePort, not by event.origin: every
sandboxed frame reports the opaque origin "null", so comparing origins would
authenticate nothing. The host creates one channel per iframe, transfers one end
in, and only listens on the other.

Also here: @multica/plugin-sdk (zero runtime deps, no @multica/core or
@multica/ui import, since it ships to third parties), the hello-panel reference
plugin, and the bridge's own refusals — a path outside the Action API never
becomes a request, and a resize is clamped.

A local: install cannot render a surface: its files live on the server's
filesystem with no URL a browser could fetch, and the panel says so rather than
showing an empty frame.

Tests cover what a reviewer cannot see by reading the components: the CSP
derived from scopes, entry resolution refusing local and plaintext sources,
attribute escaping, the sandbox attribute never gaining allow-same-origin, the
bridge's path allowlist and status pass-through, and disabled installations not
mounting.

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

* MUL-6350 fix(plugins): give the SDK package a vitest config and protocol tests

A new workspace package inherits the repo's `test` script, so `vitest run` ran
with no config and no test files and exited non-zero — frontend-test went red
on a package that had nothing wrong with it.

Rather than only adding `passWithNoTests`, the guards now have the coverage they
warrant: they are the only thing between a hostile message and the bridge
handlers on both sides, so anything that is not exactly the agreed shape has to
fall through instead of being coerced into something close enough. Includes the
response/event mix-up, which would otherwise resolve a pending call with a theme
payload or apply a response as design tokens.

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

* MUL-6350 fix(plugins): make the surface flow actually work end to end

Running a real plugin against a live stack found five defects that every test in
this PR passed straight through. Each one produced the same symptom — a blank
panel — and none was visible from reading the code.

1. The handshake raced in both directions. The host connected on the iframe's
   load event, which for a srcdoc frame can fire before React attaches the
   handler; and a guest that announced once could announce before the host was
   listening. The surface now announces until it is answered, and the host
   matches event.source against the frame it created. Origin cannot be used for
   this — every sandboxed frame reports "null".

2. The bridge was reused across a document change. close() is terminal, and the
   theme effect changed srcDoc, so the second connect() returned immediately on
   a permanently closed bridge. One bridge per rendered document.

3. The theme was read on first render, when the ref is still null, so every
   surface was built with no tokens at all.

4. X-Multica-Plugin-Installation was missing from corsAllowedHeaders, so the
   preflight failed and every Action API call from a browser client died as
   "Failed to fetch". The comment above that list warns about exactly this.

5. activity_log kept a via_plugin_id column in generated code from an earlier
   draft of migration 348 that no longer creates it — every issue timeline
   returned 500. Regenerated.

Also from seeing it rendered: frame-ancestors is dropped from the meta CSP
(ignored there, and the browser warns), and string config fields can declare
multiline so a value that is a list of lines gets a textarea instead of a
single-line input the user cannot read.

MULTICA_PLUGIN_DEV_ORIGINS lets an operator name an origin a manifest may be
served from while a plugin is being built, and a surface entry may resolve over
plain HTTP from loopback — what browsers already treat as a secure context.
Without both, a surface cannot be developed at all: there is no way to serve one
over public HTTPS from a laptop.

examples/plugins/release-checklist is the plugin this was verified with: a
per-issue definition-of-done checklist with workspace-configured items,
per-issue state in plugin storage, strict-mode gating, and a sign-off comment
that lands as the user with via_plugin_id set.

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

* MUL-6350 fix(plugins): close the bridge hijack, fix the capability gate, sync the comment path

Review fixes for #7157.

The security one first: the SDK accepted a bridge-init from any window and let a
later init replace the port. Sibling surfaces are mutually opaque, but
`parent.frames[i]` is an allowed cross-origin access — so another plugin on the
same issue page could deliver its own MessagePort, become a surface's "host",
feed it fabricated data and read everything it wrote, including whatever it put
in storage:user. The host already made exactly this check on the readiness
signal; the guest half was missing. Both examples carried the same hole, and
examples get copied.

The capability gate had flipped to enabling sidebar_panel with no mount point
anywhere — a plugin declaring one would install cleanly and then never appear,
which is the silent failure the gate exists to prevent. Turned back off, and a
test now asserts the shipped set matches what the host actually mounts, so the
next flip has to come with its renderer.

The two red tests were red for a real reason but were also written to break on
any capability flip. Rewritten to test the thing rather than today's set: the
message is asserted against an explicit empty Capabilities{}, and the live gate
against a kind that genuinely is not shipped (a hook). Added the other half —
a shipped surface must install against the real HostCapabilities.

A plugin-posted comment now publishes comment:created and re-opens a resolved
thread, like every other comment path. Skipping mention dispatch was deliberate;
skipping these two was not, and it is why the demo needed a page reload to see
the comment appear.

Corrected an overclaim rather than pretending to fix it: "no net: scope means it
physically cannot send data out" was wrong, because script-src necessarily
allows the author's own origin. net: bounds THIRD-PARTY destinations. img-src
and font-src are narrowed to inline data, which removes the two side channels
that need no scripting at all; the rest cannot close without re-hosting
third-party code, which this design rules out.

Smaller ones from the same review: the surface-error listener now checks window
identity too (any frame could light every panel's failure banner), the bridge
takes a method allowlist instead of any string, surfaces[].platforms is enforced
rather than decorative, and the iframe is keyed on the issue so a bridge created
for a new issue is not left waiting on a guest that already stopped announcing.

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

* MUL-6350 fix(plugins): drop the SDK's jsdom dev dependency and stub the window

`pnpm add -D jsdom` wrote a partial lockfile update: it resolved against a warm
local store, so `--frozen-lockfile` passed here and failed in CI on a vitest
peer key that was never written.

Rather than re-resolve, the dependency is gone. The SDK touches exactly
`window.addEventListener` and `window.parent`, so the handshake tests run on a
hand-built EventTarget in the node environment. A package that ships to third
parties is better off carrying no test-only dependency at all, and the tests
still fail without either guard they cover.

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

* MUL-6350 fix(plugins): keep a test runner out of the SDK package

A new importer that pulls vitest through the catalog resolved to a peer key CI
could not satisfy on a clean install, so every frontend job died in
`pnpm install --frozen-lockfile` before running anything. Two attempts to
re-resolve it locally produced a lockfile that looked correct here and stayed
broken there — my store is warm, CI's is not.

So the SDK stops carrying a test runner at all. It ships as TypeScript source to
third parties and now declares only typescript plus the two workspace configs;
its handshake and protocol tests move to packages/views, its only consumer in
this repo, where the toolchain already exists. The lockfile delta for this whole
PR is now four workspace links and a typescript pin — nothing that can resolve
differently on a cold install.

The tests themselves are unchanged and still fail without the guards they cover.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 01:06:25 +08:00
6a17c00144 MUL-6350: rebuild the plugin system (1/4) — cleanup and foundations (#7144)
* MUL-6350 refactor(plugins): reset the plugin system to Action / Hook / Resource foundations

The V1 plugin system shipped 14 tables, an append-only revision log per
relationship, a capability-snapshot compiler, a per-task execution manifest
pinned by an INSERT trigger on agent_task_queue, release signing, trust tiers,
and a bundled catalog — and never left its feature flag. It holds no production
data, so this removes it outright instead of migrating it forward.

What replaces it, per the design in MUL-6338:

- A plugin relates to Multica in exactly three ways. Action (plugin calls
  Multica), Hook (Multica calls plugin), Resource (a static contribution with no
  call at all). "Who triggers" and "what capability is called" are orthogonal:
  a hook is declared once and lists which triggers may invoke it.
- We never execute third-party code. A plugin runs only in a sandboxed iframe in
  the user's browser or on the author's own server.
- Distribution is "install by URL, admin reads the scopes". No signature, no
  trust tier, no publisher verification — the scope list IS the trust decision.

This change is the foundation, with no user-visible surface yet:

- Decouples remote MCP first. RemoteMCPConnection/RemoteMCPTool/DigestBytes move
  into pkg/remotemcp, where they belonged; the protocol layer and daemon broker
  survive intact and dormant, ready to be rewired for the agent trigger.
- Drops all 14 plugin_* tables, their triggers, the manifest-composition
  function, and agent_task_queue.plugin_execution_manifest_id. Adds three:
  plugin_installation (one row per workspace+plugin, holding the manifest
  snapshot the admin consented to), plugin_storage, plugin_secret.
- Defines the manifest schema in full, once — surfaces, hooks with their
  triggers and events, resources, and a closed scope list. Parsing stays strict
  (DisallowUnknownFields, no trailing JSON). Contribution kinds this build does
  not ship yet fail installation loudly rather than installing half-working.
- Two-step install: preview parses and returns the scopes without writing, then
  install requires consent matching the manifest exactly. Manifest fetches reuse
  the remote MCP SSRF guard; MULTICA_PLUGIN_DIR adds a local source for
  self-hosting and plugin authors.
- Storage quotas are enforced on write and never by eviction. Secrets live in a
  separate encrypted table with no read path that returns a value.

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

* MUL-6350 fix(migrate): register the plugin index builds for invalid-index cleanup

Every migration that builds an index CONCURRENTLY must be registered in
concurrentIndexCleanups, or an interrupted build leaves a permanently INVALID
index that the `IF NOT EXISTS` retry records as success. TestEveryConcurrentUp\
BuildHasCleanup caught all three new plugin index migrations.

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

* MUL-6350 fix(plugins): address review — byte quotas, version bound, secret pruning, script entry

From the review on #7144, plus one contract change the surface design forced.

- Storage quotas were accounted in characters and enforced in bytes. The usage
  query and the size_bytes it reports now use octet_length, and so do the
  plugin_storage column checks. A UTF-8 value could otherwise spend up to a
  quarter of the budget it appeared to.
- manifest version had no length bound while plugin_installation.version caps
  at 64, so a legal-but-long semver failed at INSERT as an opaque install
  error instead of a parse error naming the field.
- Upgrading pruned config values the new manifest dropped but left the matching
  plugin_secret rows. Ciphertext nothing can reach is exactly the residue
  pruneConfig exists to prevent; upgrade now prunes both in one transaction.
- SetConfig wrote secrets and config across two tables with no transaction, so
  a failed config write could leave a saved form half-applied.
- A duplicate first install mapped a unique-index violation to 502; it is 409.
- net: is now an exact host match. It was a suffix match in the hook transport
  check while the same scope becomes an exact-host CSP connect-src in the
  surface runtime — one scope string cannot mean two things across the two
  places the consent screen claims to describe.
- surfaces[].entry must be a .js/.mjs script, not an HTML document. The host
  generates the surface's document so it can attach the CSP derived from net:;
  a plugin-authored document would carry its own server's policy and net: would
  be a claim rather than a control.

Tests: the three storage limits get a canonical unit matrix plus DB-backed
coverage of the byte counting and the exclude-the-candidate-key replacement
semantics; upgrade-time secret pruning is pinned end to end (verified failing
without the fix); and the admin-only routes now have a real middleware-level
403 check, which every direct-handler test bypassed.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 19:15:56 +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
afd05ca221 MUL-6063: recover terminal delegated task failures (#6844)
* fix: recover terminal delegated task failures

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

* fix(tasks): make delegated failure recovery durable

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

* fix(tasks): bound delegated failure recovery

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

* fix(tasks): address delegated recovery review nits

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
2026-08-18 15:36:10 +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
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
446080bd57 MUL-6243 feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (#7065)
* feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (MUL-6243)

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

* fix(issue-status): address review — normalize all status consumers, close archive race, gate rollout, split PK index

Review round 1 (MUL-6243 / PR #7065):

1. Behavior normalization was incomplete. Adds issue_effective_status() as a
   SQL mirror of issuestatus.Effective so the six SQL consumers the Go resolver
   cannot reach (duplicate guard, open-issue list, sub-issue progress, project
   stats, completed-inbox archive, open_only) resolve categories too. Wires the
   resolver into the task-failure sweeper and the stage-barrier helpers.

2. Archive TOCTOU. Archive now runs its in-use census and the archive itself in
   one transaction under an exclusive catalog lock; writes that target a custom
   status take the shared side. Built-in writes stay lock-free — a built-in can
   never be archived.

3. Rollout safety. Creating a custom status is gated behind the default-off
   CustomIssueStatuses flag, so a value old pods cannot interpret cannot exist
   until the fleet is updated. Readers ship ungated and are safe.

4. Primary key index. Follows the repo convention (327-329): table without a
   PK, unique index built CONCURRENTLY in its own migration, attached via
   PRIMARY KEY USING INDEX, and registered for invalid-index cleanup.

5. Status normalization vs stored value. Write paths now persist the canonical
   key from Resolve, so 'HUMAN_REVIEW' no longer validates and then fails the
   column format constraint as a 500.

Also fixes the two CLI tests failing in CI: they asserted the pre-MUL-6243
contract that an unknown-but-well-formed status is rejected locally.

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

* fix(issue-status): close the archive race in both orderings; resolve categories in batch barrier and CLI progress

Review round 2 (MUL-6243 / PR #7065):

1. The archive race was only half closed. Handlers resolved the status BEFORE
   opening a transaction, so an archive could commit in that window and the
   write would still land on an archived key. assertIssueStatusStillActive now
   takes the shared catalog lock and RE-RESOLVES inside the caller's
   transaction, which is what actually makes the status provably active at
   write time. Applied to create, update, batch, and both description-merge
   branches, which previously bypassed the guard entirely. A losing writer gets
   409 rather than a silent partial update.

2. The batch stage-barrier prefilter still compared literals, so a batch that
   moved the last child onto a custom done status left childDoneCompleted empty
   and skipped the parent notification. It now resolves categories first.

3. multica issue children --output json counted only literal done/cancelled.
   ListChildIssues now emits status_category and the CLI reads it, falling back
   to status for older backends.

TestArchiveCommitsInsideTheWriteRaceWindow interleaves the two operations so the
writer validates against an active status and the archive commits before the
write. Verified it fails without the re-resolve (issue stranded on the archived
status) and passes with it.

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

* refactor(issue-status): address review nits — honest race-test naming, one catalog read per list

Review round 3 nits (MUL-6243 / PR #7065):

1. TestArchiveRaceBothOrderings claimed more than it proved. Its 'archive wins'
   cases archived first and only then sent the request, which the pre-flight
   check alone rejects. Split into TestWritesRejectAnArchivedStatus (the
   sequential case, named for what it is) and TestArchiveRefusesAfterACommitted
   Write, and extended TestArchiveCommitsInsideTheWriteRaceWindow — the only
   test that truly interleaves archive between pre-flight and write — from
   create alone to all five write endpoints. Re-verified by deleting the
   in-transaction re-resolve: 4 of 5 fail with issues stranded on the archived
   status (create is guarded in the service layer and checked separately).

2. ListChildIssues / ListChildrenByParents issued one category lookup per
   custom-status child. issuestatus.Resolver now amortizes that to a single
   catalog read per request, lazily on the first custom key, so built-in
   statuses still cost zero queries however long the list is.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 18:32:02 +08:00
1e1321a9c3 fix(migrate): cover every concurrent index build with retry cleanup (MUL-6288) (#7073)
Migrations 316, 317, 326, 328, 330 and 331 build indexes with CREATE INDEX
CONCURRENTLY but shipped without an invalid-index cleanup hook. An interrupted
build leaves an INVALID index of the same name behind; on retry the IF NOT
EXISTS ones are recorded as applied while the index stays unusable (316's
uniqueness is then never enforced), and the bare CREATE ones fail on "already
exists" - 328 backs a primary key, so 329 cannot attach it and the migrator
stops there.

Registration was opt-in per batch, which is why this recurred.
concurrentIndexCleanups is now total: every up migration that builds an index
concurrently is registered, matching the invariant concurrentDownIndexCleanups
already held for rollbacks, and TestEveryConcurrentUpBuildHasCleanup fails the
build when a new migration arrives without its entry. Historical entries only
ever run on a database where those migrations are still pending - a fresh
self-hosted install, which is exactly where an interrupted build would otherwise
leave a permanently unusable index.

328/330/331 also gain IF NOT EXISTS, so the other retry window (build committed,
version never recorded) is a no-op instead of a wedged release.

Verified against a live Postgres: the new share-link test drives an interrupted
build through the real 327-331 files and the real hook map, and fails without
either half of the fix ("index is not valid" when the hook is missing, "relation
already exists" without IF NOT EXISTS).

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 18:02:22 +08:00
2832f003ae [MUL-6269] Use Redis for channel WebSocket leases (#7055)
* feat: move channel websocket leases to Redis

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

* fix: address Redis lease review feedback

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 14:55:48 +08:00
patrickstar179 8cf2acbaa7 feat(share-link): workspace share-link invite system (#6833)
Adds workspace share-link invites: owner/admin can create a link with a target role (member/admin) and expiry, share it, and revoke it; invitees see the workspace and inviter before joining.

MUL-6070
Co-authored-by: patrickstar179
2026-08-17 14:39:32 +08:00
0a1357498a MUL-6247: rate limit workspace invitations (#7030)
* feat(workspaces): rate limit invitations (MUL-6247)

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

* fix(workspaces): preserve invitation rate budgets

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

* fix(workspaces): avoid partial limiter failures

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

* fix(workspaces): relax recipient invitation limit

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 12:51:39 +08:00
9f85d72a06 MUL-6193 Support Slack message attachments in chat (#6975)
* feat(slack): ingest inbound message file attachments as chat media

Slack messages carrying files now come through to the chat: the inbound
translation captures each file's id, name, mimetype, size, and private
download URL into the raw envelope, and a new engine.MediaResolver fetches
the bytes with the installation's bot token, stores them through the shared
intent-ledger pipeline, and binds them as chat attachments. The download
client only talks to Slack-owned hosts so the bot token cannot leak, and an
HTML login-page response (missing files:read scope) is rejected instead of
stored as the file.

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

* fix(slack): address attachment review findings

* docs(slack): make media comments standalone

* fix(slack): harden inbound attachment ingestion

Address second-round review findings on the Slack media resolver.

- Restore the bot token on permitted redirects. net/http forwards
  Authorization only to the same host or a subdomain, so a hop to a
  sibling such as files-origin.slack.com arrived unauthenticated, Slack
  answered with its HTML login page, and the file was silently dropped.
  The redirect policy has already proved the destination is Slack-owned.
- Drop files the resolver could never fetch at translation time. Remote
  "external" files point url_private at Google Drive or Dropbox, so
  carrying them made HasMedia promise media the pipeline cannot bind:
  the message got a deferred run and an intent-ledger row per doomed
  download.
- Share the Router's media deadline across the files still to fetch.
  Ten files at a flat 30s each ran far past the 45s budget, and the
  Router discards every ref once it expires, so one slow file cost the
  whole message its attachments.
- Refuse an oversized file on its declared size, before it costs an
  intent row and a full transfer. Correct the limit wording to MiB.
- Cover the redirect policy, the size boundary, the spent budget, and
  the off-Slack filter with regression tests, and drive the file_share
  translation test through slackevents.ParseEvent instead of a
  hand-built MessageEvent.Message.
- Document sending files, the per-file and per-message limits, and the
  skip-silently behaviour in all four locales.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 00:08:53 +08:00
4d495056e8 [MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime

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

* chore(plugins): add private Exa example

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

* docs(plugins): remove private plugin guide

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

* fix(plugins): stabilize remote MCP tool digests

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

* fix(plugins): preserve remote MCP notification status

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

* fix(plugins): make Exa example runnable end to end

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

* fix(codex): record MCP tool calls in agent log

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

* chore(plugins): add community skills example

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

* feat(plugins): support multi-file skill bundles

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

* feat(plugins): add Mobbin MCP example

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

* chore(plugins): bundle full Matt Pocock skill set

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

* feat(plugins): add remote MCP OAuth connections

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

* fix(plugins): gate enable on remote MCP readiness

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

* fix(plugin): authenticate remote mcp credential broker

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

* fix(plugins): harden remote mcp oauth flow

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

* refactor(plugins): use a single feature flag

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

* fix(plugins): clean up remote MCP OAuth state

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

* chore(plugins): remove Matt Pocock example

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-16 04:54:32 +08:00
2c0912b6ec MUL-5421: make workspace MCP servers an explicit per-agent assignment (#6976)
* feat: make workspace MCP servers an explicit per-agent assignment

#6961 shipped workspace MCP as a shared document every agent inherited
automatically. That is the wrong shape: a workspace MCP server should behave
like a workspace skill — a library entry that reaches an agent only when
someone gives it to that agent, with a per-agent on/off toggle. Creating one
now gives it to nobody, so nothing propagates implicitly.

Model (mirrors `skill` + `agent_skill`):

  - `workspace_mcp_server` — the library, keyed by id so renaming is safe
  - `agent_mcp_server` — the assignment, carrying its own `enabled` toggle
  - the agent's own `mcp_config` stays what it was: servers private to it

At claim time the effective set is runtime servers < (assigned AND enabled
workspace servers + the agent's own), with the agent's own entry winning a name
collision. 318 drops `workspace.mcp_config`: leaving it would keep a second,
silently inherited source alive. No data migration — converting each workspace
document into library entries would ASSIGN them to every agent, which is the
behaviour being removed, and the column shipped in the same unreleased cycle.

The write-only secret contract, the legacy-container normalization, and the
transport guards from the previous round are all preserved; the resolver is
re-pointed at the assignment list rather than a workspace document.

UI: workspace Settings → MCP is now a library (adding says so, and renaming is
allowed because assignments key off the id); the agent's MCP tab replaces the
auto "Inherited from workspace" list with the servers actually assigned to it,
a picker to add more from the library, and a per-assignment toggle.

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

* fix: fence the MCP writes, stop laundering unknown transports, gate on canEdit

Three findings from review:

1. Neither new table carries a foreign key (repo rule), so nothing in the
   database catches a write landing after the row it depends on is gone. Create
   and assign now run in a transaction that joins the workspace teardown fence
   (#5219), and assign additionally holds the target server FOR SHARE while
   DeleteWorkspaceMcpServer holds it FOR UPDATE — so an assignment either lands
   before the delete and is swept, or waits and finds the server gone. Driven
   by two two-transaction race tests, verified to fail without the locks.

2. `mcpTransportOf` classified `{"type":"websocket","url":"wss://…"}` as http,
   because an unrecognised type fell through to the url check. That told the
   settings UI the guided form could edit the entry, and saving from it rewrites
   the protocol to `type: "http"` — the failure the client-side guard was added
   to prevent, reintroduced from the server. An explicit type is now returned
   verbatim; inference from command / url happens only when none was declared,
   which is lossless.

3. The agent MCP tab showed the picker, toggles, and remove button to members
   who cannot edit the agent, where every click 403s. It now takes `canEdit`
   like SkillsTab and hides the write affordances, keeping the inventory
   readable.

318 keeps dropping `workspace.mcp_config` with no data migration and no
expand-contract window, now recorded as an explicit maintainer decision (Bohan,
2026-08-14): 314 merged in this same cycle and was never deployed, so there is
no stored document to preserve and no old binary reading the column. The
comment spells out what this file would have to become if that ever stops
holding.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 19:49:37 +08:00
736838af86 MUL-5421: workspace-level MCP configuration (resolution contract, write-only API, Settings UI, CLI) (#6961)
* feat(server): resolve a workspace-level MCP config under each agent's

Adds workspace.mcp_config and layers it beneath the agent's own config on
every claim, so a workspace's shared MCP servers no longer have to be
re-entered on each agent (GH #6062).

Resolution contract:
  - agent config absent (NULL/null) -> inherit the workspace document
  - agent declares >=1 server       -> union by server name, agent wins
  - agent declares no server ({})   -> opt out entirely

The last rule is what keeps shared credentials from silently reaching an
agent that was deliberately given none: "managed but empty" already means
"this agent runs with zero MCP" under the three-state contract from #3545,
and it keeps meaning exactly that.

The merged document keeps the AGENT's top-level keys, so a legacy `mcp`
container survives untouched, and a shared server is skipped when the agent
already declares that name under either spelling. On malformed input the
agent's config is returned unchanged: a bad shared document must never take
away servers an agent runs with today.

Read on every claim like agent.mcp_config, so an admin's edit lands on each
agent's next task with nothing to restart.

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

* feat(server): add the workspace MCP configuration API

GET /api/workspaces/{id}/mcp-config is member-visible so an agent's settings
view can tell what it inherits, but the document itself follows the same
visibility rules mcp_config already has on agents: agent actors never read it,
the workspace always-redact setting wins over any role, and otherwise only the
owner/admin who may write it can read it back. The coarse server_count stays
visible either way — it carries no credential material.

PUT is owner/admin and replaces the whole document (so removing a shared
server is expressible), with an explicit null to clear the layer. It is
shape-validated at the edge, unlike the agent column: one bad document here
would otherwise fail-soft on every agent's claim for the whole workspace.
Agent actors are refused even when their PAT satisfies the role gate.

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

* feat(cli): add `multica workspace mcp get|set`

Reuses resolveMcpConfig so the workspace surface agrees with `agent update`
on what a valid document is, on the three mutually exclusive input channels,
and on 'null' meaning clear. Table output prints server names and transports
only — never entry bodies — so a summary stays safe to paste into a ticket;
the JSON default is how an admin reads the document itself.

Documents the new inheritance in the agent-creation built-in skill (an agent
with a private server no longer has to re-list the shared ones, and `{}` is
now the only way to give an agent no MCP at all) and adds the command to the
CLI reference.

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

* fix(server): keep a legacy agent's own MCP servers when the workspace shares some

The resolver wrote shared servers into `mcpServers` and left an agent's legacy
top-level `mcp` entries beside them. The daemon's runtime merge only falls back
to `mcp` when `mcpServers` is ABSENT, so as soon as a workspace shared anything,
an OpenCode agent that stored its servers under the legacy container ran with
the shared set and silently lost every private server of its own.

Fold both containers into the canonical `mcpServers` when merging, and drop the
legacy key so the daemon can never read a second, stale copy. Nothing changes
for an agent whose workspace shares no servers: that path still passes the
agent document through byte-for-byte.

The regression guard runs the real chain — ResolveWorkspaceMcpConfig into the
daemon's mergeRuntimeAndAgentMcpConfig — because the two halves only agree if
the resolver emits the container the merge reads, which is exactly what broke.
Verified it fails against the previous resolver.

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

* feat(server): make the shared MCP configuration write-only

Upstream #6062 asks for shared secrets to be write-only and never echoed back;
the previous read path handed the whole document to owner/admin, which also tied
"audit what is shared" to "read the credentials".

GET now returns only a non-secret inventory — name, transport, enabled — for
every role. Not a redaction: there is no document field and no `_redacted` flag,
because nothing secret is in the payload to gate. A url counts as secret here; a
Composio-style session url is a bearer credential on its own.

That removes the client's ability to read-modify-write, so editing one server
gets its own endpoints: PUT/DELETE /mcp-config/servers/{name} merge server-side
under a row lock (two admins editing different servers can no longer lose one
another's write). Deleting the last server clears the column rather than storing
an empty document, so agents fall back to their own config instead of inheriting
a managed-empty one. The whole-document PUT stays for the declarative CLI path.

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

* feat(cli): add `workspace mcp add|remove`, and stop printing the document

`workspace mcp get` now renders the server inventory in both output formats —
there is no document to print, and the previous JSON default would have written
whatever the endpoint returned straight to a terminal.

Since the document can no longer be read back, `set` (full replace) is not
enough to change one server: `add <name>` and `remove <name>` map to the
per-server endpoints. `add` takes the entry through the same three mutually
exclusive secret-safe channels as `--mcp-config`, generalized here so both
surfaces agree on what a valid payload is; `null` is rejected for a single
entry, where it is far likelier to be a mistake than an intent to clear.

Adds the CLI test coverage this surface was missing: endpoint and body per
command, `null` clear, mutual exclusion, name escaping in the path, and a guard
that a regressed server returning the document cannot get it printed.

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

* docs: correct the workspace MCP contract in the built-in skill and CLI reference

- "`{}` is the only way to opt out" was wrong: any config declaring no servers
  opts out, which is what the implementation and tests do.
- The source map pointed the API row at workspace_mcp.go; the handlers live in
  workspace_mcp_api.go.
- Document the write-only read path, the legacy-container normalization, and the
  new per-server commands.

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

* feat(web): manage workspace MCP servers from Settings

The user journey for this feature is a settings screen, not the CLI. Adds a
workspace Settings → MCP tab that lists the shared servers and adds, replaces,
and removes them one at a time through the per-server endpoints, reusing the
agent tab's McpServerDialog for the form/JSON editor.

Because the stored configuration is write-only, an edit opens an empty form and
REPLACES the entry rather than prefilling it. The screen says so instead of
implying the blank form is the saved state. Members see the inventory (nothing
in it is secret) but no write affordances.

The agent's MCP tab gains the matching "Inherited from workspace" section, with
an Overridden badge on a shared server the agent shadows by name, and an
explicit note when the agent's managed-but-empty config opts it out of the
workspace layer entirely — otherwise the effective server set is impossible to
reason about from the agent's own screen.

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

* fix(client): stop the write-only boundary leaking through the clients

The server never returns the stored document, but neither client enforced that
on its own side, so a regressed (or hostile) backend could still push secrets
into the frontend and onto a terminal:

- the two workspace-MCP zod schemas were `.loose()`, so unknown fields — a
  top-level `mcp_config`, or `url` / `headers` / `env` inside a server entry —
  survived parsing into the app object and the query cache. These are now the
  only schemas in that file that strip: forward-compatibility is the right
  default everywhere else, but here keeping unknown keys is the hole.
- the CLI decoded into `map[string]any` and re-encoded it, so the DEFAULT JSON
  output printed whatever arrived. It now decodes into a named struct, which is
  what makes leaking impossible rather than merely unlikely.

The old "never prints secrets" test forced `--output table` and so missed the
default path entirely. Replaced with a poisoned-response fixture (secrets at the
top level AND inside a server entry) asserted across both output formats and on
the write path; verified it fails against the previous renderer.

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

* fix(web): stop a workspace MCP edit from silently becoming an add

The shared dialog lets the name be edited and this API has no atomic rename, so
renaming `linear` to `linear-v2` upserted a second server, left the original in
place, and still toasted "updated". Editing now pins the name (the dialog gained
a `lockName` prop) and the upsert targets the entry that was opened.

Also seed the edit form's transport from the safe summary: the entry cannot be
read back, and `formFromConfig({})` defaults to HTTP, so editing a known stdio
server opened the wrong form.

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

* fix(web): show the agent's real effective MCP set

Two ways the agent tab was stating things it did not know:

- With `mcp_config_redacted`, the viewer cannot tell an explicit empty document
  (opted out) from one that overrides some names — but the tab listed every
  workspace server as inherited anyway. It now says inheritance cannot be
  determined instead of guessing.
- The runtime list marked a server "overridden" using the agent's names only.
  The real precedence is runtime < workspace < agent, so a shared server hides a
  same-named runtime one too; that was under-reported. Overrides are now computed
  from workspace + agent names, and from the agent's alone when it opted out.

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

* docs: correct the opt-out claim and apply the zh terminology conventions

An agent whose config declares no servers opts out of the WORKSPACE layer; it
does not run with zero MCP. The daemon still merges the runtime's own local
servers under whatever the claim carries — which the daemon test on this branch
already asserts, so only the prose was wrong (skill doc, CLI help, UI copy).

The new zh-Hans strings also used `agent` / `url` / `header` raw; per
conventions.zh.mdx those are 智能体 / URL / 请求头.

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

* fix(i18n): restore the zh-Hans quick-actions strings this branch clobbered

My earlier terminology pass ran a `re.sub` for `description` /
`delete_description` with count=1, which matched the FIRST such keys in the
file — `quick_actions`, not `mcp` — so Quick Actions ended up describing shared
MCP servers and its delete confirmation talked about sharing. A user-visible
regression in an unrelated feature, and one no key/type check can catch because
both values stayed valid strings.

Restored both to their origin/main text. Audited all eight locale files the
branch touches the same way; these two were the only unintended changes.

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

* fix(web): never edit a non-stdio/http MCP server through the rewriting form

The guided form only expresses stdio and http, and saving from it REWRITES the
entry — `configFromForm` emits `type: "http"` for anything non-stdio. The server
classifies and returns `sse`, so editing an SSE server opened the Streamable
HTTP form and saving silently changed its protocol, which can break the server
outright.

Entries the form cannot express now take the JSON path, which round-trips what
is typed, and the Form tab is disabled rather than merely unselected — switching
to it would rewrite them just the same. The rule keys off the saved config when
there is one and off the safe summary's transport when there is not, so it
covers both the workspace tab (write-only, summary only) and the agent tab,
where the same rewrite was already reachable for a saved SSE entry.

Also fixes the garbled `multica workspace mcp get|list-only` in the built-in
skill, which reads as a shell pipe.

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

* fix(web): judge form-editability by the entry's explicit type, not its display bucket

The previous guard asked `mcpTransport(config)` whether the form could express a
saved entry, but that is a lossy DISPLAY classifier: it buckets anything with a
`url` as http regardless of `type`. So `{"type":"websocket","url":"wss://…"}`
still looked form-editable and `configFromForm` rewrote it to `type: "http"` on
save — the very rewrite the guard exists to prevent. SSE happened to be caught
only because the classifier has a dedicated branch for it.

Saved entries are now judged on the explicit `type`, allowlisting exactly what
`configFromForm` writes back unchanged (local / stdio / remote / http /
streamable-http), with the no-type case still inferred from command / url. The
summary path keeps using the transport string, which the server emits from a
closed set.

Tests cover both unknown-type spellings on the agent side and pin the common
shapes to the form — this is a guard against rewriting entries, not a general
retreat to the JSON editor. Verified the websocket case fails against the
previous check while sse passed, which is why the earlier test missed it.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 18:28:26 +08:00
987ac8ec8b fix(cors): allow billing idempotency header (#6964)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 14:47:11 +08:00
b5a48eca13 fix(agent tasks): prevent duplicate self-assignment work (MUL-6168) (#6956)
* fix(agent tasks): avoid duplicate self-assignment runs

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

* fix(agent tasks): address duplicate-run review

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

* fix(agent tasks): resolve sibling-run nits

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 13:57:09 +08:00
ceb6c6845c MUL-6132 fix: stop stranding daemon task markers in user directories (#6917)
* fix(daemon): roll back local_directory sidecars when prepare fails (MUL-6132)

Prepare writes the daemon task marker and the rest of the sidecar tree into
the workdir early, but only persists the manifest that records them at the
very end. Every error return in between left that tree on disk with no record
of what to remove, and since the caller gets no Environment on those paths,
none of the teardown defers that normally undo it were ever registered.

For a local_directory task the workdir is the user's own repository, so the
leftover marker disabled every human-local multica command for that whole
directory tree — silently, and until someone deleted the file by hand. No
crash was needed to reach this state: a failed provider setup was enough, and
writeSidecarManifest failing was only a warning, so a disk or permission
hiccup produced the same permanent residue.

Roll back in place from the in-memory manifest whenever Prepare fails after
writing context files, and treat a manifest write failure as fatal in place so
that rollback runs while the record still exists. Worktree and cloud envs are
untouched: the former discards the whole worktree, the latter is wiped by GC.

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

* fix(cli): retire stale daemon task markers instead of refusing forever (MUL-6132)

A daemon task marker left behind in a user's own directory made every
human-local command (daemon start/stop/restart/logs, login, logout, setup,
workspace switch, runtime profile set-path/unset-path) fail anywhere in that
directory tree, permanently and with a message that named neither the file nor
a way out. The reported instance sat undetected for five weeks.

Retire the marker automatically, but only on positive evidence that nothing is
running: every profile's daemon must answer and report zero active tasks.
Silence is not absence — an unreachable daemon is what a crashed one looks
like, and a task orphaned by that crash keeps running with this marker as its
only remaining guard, so any unreachable or unparseable profile keeps the
command refused. A minimum marker age closes the window where a daemon that
reports zero is claiming a task and about to write this very marker.

Two things stay out of scope by construction: markers without an agent and
issue id, which is the permanent node-wide guard at the workspaces root rather
than a task leftover, and any invocation carrying MULTICA_* task identity,
where the daemon really did launch this process and no file may override that.

When staleness cannot be proven the command still fails closed, but the error
now names the marker path, matching what newAPIClient has reported since
MUL-3922 and replacing a message that sent the user to the source.

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

* fix(daemon): arm the in-place rollback before the first sidecar write (MUL-6132)

The rollback was registered after writeContextFiles returned, but that function
writes the daemon task marker as its very first act and can then fail on any
later step — .agent_context, skill files, project resources. Those failures
returned before the defer existed, so the case the fix was written for still
stranded a marker in the user's repository with nothing beside it.

Arm the defer immediately after the manifest is allocated, before any write.
Rolling back an empty manifest is a no-op, which is what makes arming it that
early safe.

Adds a regression test whose induced failure lands inside writeContextFiles
(a plain file where .agent_context must be a directory), one step after the
marker is written. It fails on the previous commit and passes here.

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

* fix(cli): drop CLI-side marker deletion, keep the actionable message (MUL-6132)

The self-heal could delete the fail-closed guard of a running task. The daemon
increments active_task_count only after a claim returns, so a probe can read
zero for a task that is already claimed and about to write the marker at this
very path; the marker's age says nothing about a file written after the probe.
Serial probing across profiles widened the window further. Separately, the
"every profile answered" proof did not hold: profiles were matched by health
port, and that port is a hash that can collide, so one idle daemon could be
counted as two profiles while a crashed one kept an orphan task running.

Both are properties of doing this from outside the daemon. Removing a guard
needs synchronization with the claim path that only the daemon has, so
retirement moves there and the CLI keeps the half it can do soundly.

What remains is the message, now shared by both refusal paths. Previously only
newAPIClient named the marker file, so whether a user could act on the refusal
depended on which command they happened to run first; requireHumanLocalCommand
sent them to the source instead. Both now resolve the leftover through one
helper and report it identically.

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

* fix(cli): report leftover markers on every refusal path, never the root marker (MUL-6132)

Two gaps from the previous round's review.

requireTaskLocalConfigRoot is the third path a leftover marker can refuse
through — config show, config set and auth status all call it — and it still
returned a message that named no file. Whether the error was actionable
depended on which command the user happened to run first, which is exactly what
unifying the other two paths was meant to end. It now appends the same suffix
when a marker is the only daemon signal.

The leftover helper also could not tell the two markers apart, because
daemonTaskContextMarkerPath matches on managed_by alone. Running from the
daemon workspaces root therefore described the permanent node-wide guard as a
leftover to delete — advice that closes the hole it exists to cover, aimed at
whoever reads the error, including an agent subprocess that lost its
environment. The helper now requires task identity, defined as agent_id OR
issue_id: a chat task has no issue, so requiring both would misfile its marker
as the root marker, while the root marker has neither field.

chdirWithDaemonTaskMarker was writing the bare managed_by form, which under
this rule models the root marker rather than the workdir marker its tests are
about; it now writes a task-scoped one.

Also corrects two comments the earlier commits left stale: cmd_agent.go still
described an automatic retirement that no longer exists, and
writeSidecarManifest still claimed every caller treats a write failure as
non-fatal, which stopped being true when the in-place path was made fatal.

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

* docs(cli): correct the refusal-path count in leftover-marker comments (MUL-6132)

Both comments still said "both refusal paths" from when the suffix was shared by
newAPIClient and requireHumanLocalCommand only. requireTaskLocalConfigRoot
joined them in e02a393, so the count is three; list them by name so the next
addition has somewhere obvious to register.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 02:23:44 +08:00
iiwish 698013cedb MUL-5771 feat(daemon): allow configuring workspaces root (#6830)
* feat(daemon): allow configuring workspaces root

* fix(daemon): align disk usage workspace roots
2026-08-14 01:58:29 +08:00