Adds self-hosted Git provider support (Forgejo, Gitea, GitLab) alongside GitHub:
per-workspace token connection, a provider-dispatched webhook, PR/MR and CI
mirroring, and the shared issue auto-link / auto-close machinery. Off until
MULTICA_VCS_SECRET_KEY is set, so existing deployments are unaffected.
Co-authored-by: Bohan <bohan@devv.ai>
* fix(web): resolve upstream URLs at runtime
* fix(web): keep unconfigured upstreams same-origin
* fix(web): restore dev-only localhost fallbacks for API and docs upstreams
`next dev` on a developer machine now falls back to the conventional
http://localhost:8080 backend (honoring BACKEND_PORT) and
http://localhost:4000 docs origin when nothing is configured, so a bare
`pnpm dev:web` keeps proxying out of the box. Builds and the runtime
proxy keep the strict resolvers, so prebuilt images still leave unset
upstreams unproxied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(github): surface CI status on PR cards (MUL-5180)
The CI mirroring pipeline (MUL-2228, MUL-2392) has never received a single
event in production. The GitHub App setup docs only ever asked operators to
grant `pull_requests: read` and subscribe to `pull_request`, so GitHub never
delivered `check_suite` — `handleCheckSuiteEvent` sat dead behind a
subscription nobody was told to enable. Every linked PR reports
checks_passed/failed/pending = 0 and the sidebar row falls through to
"Checks haven't reported yet" forever.
Docs (the root cause), all four locales:
- add `Checks: Read-only` permission + `Check suite` event to the App setup
table
- drop the stale "CI check states are not modeled" claim, which predates
MUL-2228 and is what let the setup table stay incomplete
- add a "PR rows show no CI status" troubleshooting entry with the public
`/apps/<slug>` probe to confirm what an App is actually subscribed to, and
a warning that existing installations must accept the new permission
before any `check_suite` is delivered
UI:
- give the actionable status kinds (checks failed/pending/passed, conflicts,
ready) their own icon + color. CI outcome previously rendered as plain
muted 11px text, visually identical to the diff stats beside it — a failing
build read the same as "+437 −6 · 6 files". Terminal and unknown kinds stay
muted; the row's state icon already carries that meaning.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): unbreak docs build, stop overclaiming CI completeness (MUL-5180)
Both must-fixes from review.
1. docs production build failed. `<your App>` in prose was parsed as a JSX
tag, so `pnpm --filter @multica/docs build` died with `Expected a closing
tag for <your>`. Dropped the angle brackets. Repo CI never caught this
because no workflow runs the docs production build — only Vercel does,
which is why the PR's GitHub checks were green while the deployment
errored.
2. `Checks: Read-only` cannot support the pending status the docs promised.
GitHub's webhook contract delivers `check_suite.requested` /
`.rerequested` only to Apps holding Checks *write*; read-level access
receives `completed` only. Verified against GitHub's published docs.
Direction chosen: keep read-only, degrade honestly to final-results-only.
Checks *write* is a repo-write capability (create/update check runs), not
a wider read — escalating every installation to it just to render an
in-flight spinner is not a trade to make on the operator's behalf, and it
contradicts the integration's read-only posture.
The concrete bug this leaves is premature green: with two reporting apps,
the first to complete makes total=1/passed=1 and the row claimed "All
checks passed" while the second was still running and might fail. Copy is
now "Checks passed" in all four locales — it reports what reported and
never asserts completeness. `derivePullRequestStatusKind` documents why.
Docs gain a "what CI status can and cannot tell you" section (all four
locales) with the read-vs-write delivery table, both consequences stated
plainly, and the opt-in path for teams that do want in-flight status: set
Checks to Read and write on their own App and the existing pending code
lights up with no code change. The pending promise is removed from the
read-only setup path.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): ignore non-completed check_suite actions (MUL-5180)
Review was right: the `Read and write` opt-in the previous commit documented
does not produce reliable pending, and following it would break the card.
`check_suite.requested` / `.rerequested` are not observations that some CI
provider started. GitHub sends them only to Apps holding Checks write, and
per the CI-checks App docs they mean "GitHub has created a check suite for
YOUR app on this commit; now add your check runs to it".
Multica observes other apps' results and never creates check runs. Recording
such a suite parks a `queued` row nothing can ever complete, and since
`checks_pending` outranks `checks_passed` in derivePullRequestStatusKind, one
stuck row freezes every PR on that installation at "checks running" and hides
the real pass/fail result. Any self-hoster who already grants Checks write
hits this on every push, so the gate is on the action, not the permission.
- handleCheckSuiteEvent drops every action except `completed`, with the
reasoning and the "don't resurrect requested as a running signal" warning
recorded at the gate.
- TestWebhook_CheckSuite_QueuedCountsAsPending encoded the wrong delivery
semantics (two external apps sending `requested`, which GitHub never does).
Replaced by TestWebhook_CheckSuite_NonCompletedActionsIgnored, which pins
the drop and checks a later `completed` suite still lands.
- The two out-of-order stash tests used `requested` payloads to exercise
paths that are really about completed suites; both now use `completed` and
assert the same guarantees.
- Docs (four locales): the write opt-in is gone. In-flight CI is documented
as unsupported at any permission level, with the actual reason and the note
that real running status needs polling or a check_run model instead.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): make legacy non-completed check suites inert (MUL-5180)
Review was right again: the previous commit gated the webhook entry point but
left the pre-upgrade state — and the people it was meant to protect (self-
hosters who already granted Checks write) are exactly the ones holding it.
Two leftovers, both now closed:
1. Rows already in github_pull_request_check_suite. The old handler stored
GitHub's `requested` suites as `queued`; nothing will ever complete them.
ListPullRequestsByIssue still counted them, so `checks_pending` kept
outranking `checks_passed` and the PR stayed pinned to "checks running"
for as long as its head SHA stood. The aggregation now selects only
`completed` suites.
Filtering beats deleting here: recovery is automatic on deploy, needs no
migration over a table that can be large, and holds for any writer that
misses a gate — not just for today's legacy rows. DISTINCT ON runs after
the filter, so an app whose newest suite is a stuck `queued` still reports
its most recent completed verdict instead of disappearing.
2. Rows already in github_pending_check_suite. replayPendingCheckSuitesForPR
is a second write path into the live table that never passes through
handleCheckSuiteEvent, so the next `pull_request` event would re-inject a
permanently-queued suite after the fix shipped. It now skips non-completed
rows; the drain is DELETE ... RETURNING, so skipping discards them.
Both are covered by regression tests that seed the legacy row directly — the
fixed handler can no longer produce one — and both were confirmed to fail
with their respective fix reverted. The stash test additionally asserts its
fixture landed under the repo address the drain keys on; the first draft used
the wrong owner and passed vacuously.
Also corrects the aggregateChecksConclusion doc comment, which still
described "pending" as a not-yet-completed suite. It is now reachable only
for a completed suite carrying a null conclusion, and is explicitly not a
"CI is running" signal.
Co-authored-by: multica-agent <github@multica.ai>
* test(github): assert the legacy stash row is consumed by the drain (MUL-5180)
Review nits.
The stash test proved its fixture existed before the webhook but never that
the drain consumed it, so a future change to firePullRequestWebhookWithHead's
repo address would make the assertions pass for the wrong reason again — the
same way the first draft of this test did. Asserting the stash is empty
afterwards closes that gap from the other side.
Also fixes two comment typos: `an "CI is running"` -> `a`, and drops the
"merged-but-open PR" state, which cannot exist.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Windows Security flags multica.exe inside app.asar.unpacked as
Trojan:Script/Wacatac.B!ml and quarantines it. The !ml suffix is a
machine-learning heuristic verdict, not a signature match: our Windows
builds are not Authenticode-signed (release.yml sets
CSC_IDENTITY_AUTO_DISCOVERY=false and no certificate is configured), and
an unsigned, low-prevalence Go binary that spawns background processes
and opens sockets is the classic false-positive profile.
Add a Desktop docs section (en/zh/ja/ko) covering how to verify the
binary against checksums.txt, restore it from quarantine, exclude both
the install directory and %APPDATA%\\Multica (Desktop re-downloads the
CLI there, so excluding only the install dir loops), and report the
false positive to Microsoft.
MUL-5216
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* docs(self-host): add Upgrading section to the self-host quickstart (MUL-5190)
The quickstart had no upgrade section — the only mention of upgrades was a
side note that migrations run automatically. Community users had to be walked
through it by hand every time, and the two circulating recipes (`make selfhost`
vs bare `docker compose pull && up -d`) were never documented as equivalent.
Adds an `Upgrading` section between Step 7 and the Kubernetes section covering:
- both command forms, and that `make selfhost` is a wrapper around them
- what `git pull` actually updates (the compose file, not the image version)
- the `MULTICA_IMAGE_TAG` pinning trap — pinned `.env` silently blocks upgrades
- `.env` is only generated when missing, never overwritten
- Postgres backup before upgrading
- migrations run on backend startup, with the log command to watch them
- verify with `/readyz`, not `/health` (liveness passes on failed migrations)
- cross-reference to the existing Kubernetes upgrade path instead of duplicating
Synced to the zh / ja / ko translations.
Co-authored-by: multica-agent <github@multica.ai>
* docs(self-host): fix upgrade guidance for k8s pull policy and pg_dump (MUL-5190)
Review follow-ups on the new Upgrading section.
1. The Kubernetes upgrade path was wrong. Both the new section and the
pre-existing Kubernetes section presented `kubectl rollout restart` as a way
to pull new images, but the chart ships `pullPolicy: IfNotPresent`
(deploy/helm/multica/values.yaml), so a node that already cached the tag
reuses the old image and the restart silently changes nothing — the same
failure mode as a pinned MULTICA_IMAGE_TAG. `helm upgrade` with an updated
`images.*.tag` is now the primary path; the floating-tag route is documented
only with the `pullPolicy: Always` prerequisite it requires.
2. The Postgres backup masked `pg_dump` failures. `pg_dump … | gzip > out.gz`
takes the pipeline's exit status from gzip, so a failed dump exits 0 and
leaves a valid, empty 20-byte archive. Now redirects to a file first so
pg_dump's own status gates compression via `&&`, with a callout explaining
why.
3. Dropped the `Makefile:79-107` / `Makefile:80-95` line references. The
selfhost target actually spans 79-133 and does more than pull + up (creates
.env when missing, waits on /health, prints a status summary). Reworded to
"same result on an existing install" and referenced the target by name so
the claim cannot drift with line numbers.
Applied across all four language versions.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(squads): align parent issue status ownership with agent-managed model
Squad leaders now open assigned parents to in_progress on first dispatch, keep them there while members work, and only move to in_review when overall completion is confirmed—matching ordinary agent status semantics without server auto-flips.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(squads): scope leader parent-status ownership to squad-assigned issues
Review follow-up on the parent-status alignment change. Two boundaries were
left ambiguous, both of which the change's own premise ("don't make the model
resolve a contradiction in the prompt") argues should be closed in-place.
1. Status ownership was granted too widely. The leader briefing is injected on
every leader path, keyed off is_leader_task — including the MUL-3724 case
where an issue is assigned to a plain agent and a squad was merely
@mentioned for help. The unqualified "Own the parent issue status"
responsibility therefore also reached guest leaders, who could push another
assignee's in-flight issue to in_review.
buildSquadLeaderBriefing now takes ownsIssueStatus and selects between two
variants of responsibility 6: the grant only when the issue's assignee is
this squad, otherwise an explicit "do NOT change this issue's status".
Quick-create passes false — no issue exists on that turn. Everything else in
the protocol (roster, delegation, evaluation) is unchanged for both.
2. The comment-triggered path still contradicted itself. The runtime brief says
"do not change status unless the comment explicitly asks", and a member's
delivery comment never asks. Squads that dispatch by @mention create no
child issues, so no child-done system comment exists to carry the explicit
ask either — that parent would sit in in_progress indefinitely.
writeWorkflowComment now names the protocol responsibility as the one
exception for squad leaders. It is safe to state unconditionally because the
grant is only present in the instructions when the server decided this squad
owns the issue; for a guest leader the sentence has nothing to activate.
Tests: two composition tests assemble both halves (server-side briefing +
daemon-side CLAUDE.md) for one real scenario each, since asserting each half
alone is how the original contradiction shipped. Plus execenv coverage that the
carve-out appears only for leaders and the ordinary-agent rule stays absolute.
Docs and the multica-squads skill / source map record the narrower contract.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(agent): inject --yolo in Qwen headless runs so shell/edit/write tools are available (Fixes#5743)
Qwen Code's non-interactive mode (`-p … --output-format stream-json`) uses a
fail-closed approval policy: it silently drops `run_shell_command`, `edit`,
`write_file`, and `monitor` from the tool registry unless bypass mode is
active. Every other Multica-supported coding adapter already injects its
equivalent permission flag as a daemon-owned argument (e.g. Claude uses
`--permission-mode bypassPermissions`, Grok uses `--always-approve`, Qoder
uses `--yolo --acp`). Qwen was the only exception.
Changes:
- `buildQwenArgs`: append `--yolo` after the protocol flags and before any
custom args so headless daemon runs always receive the full tool set.
- `qwenBlockedArgs`: add `--yolo`, `-y`, `--approval-mode`, and
`--allowed-tools` as daemon-owned flags that are stripped from custom_args.
This prevents users from accidentally or intentionally disabling bypass mode
or narrowing the allowed tool set via per-agent settings. `--exclude-tools`
is intentionally left unblocked so users can still hard-deny specific tools.
- `TestBuildQwenArgsKeepsProtocolManaged`: extend with the new blocked flags
and assert daemon-owned `--yolo` appears exactly once.
- `TestBuildQwenArgsYoloAlwaysPresent`: new test asserting `--yolo` is present
even when `ExecOptions` carries no custom args.
* fix(agent): correct Qwen permission args and docs
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The local_directory section read as a neutral alternative to github_repo,
so users with ordinary clonable repos were picking it and then asking for
worktree-based parallelism — which contradicts the mode's whole premise.
- Add an up-front warning: this exists for people with no other option
(tens-of-GB game checkouts), and github_repo gives unlimited concurrency.
- Replace the "fine-grained changes, review locally" recommendation with
the real qualifying cases (clone too expensive, not a git repo at all,
machine-local state) plus an explicit list of non-reasons.
- State that serial execution is a premise of the mode, not a limitation
pending a worktree fix.
- Add a pointer at the top of "Attaching a local directory".
Applied to en / zh / ja / ko.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869)
A manual retry (execution-log "retry" button -> POST /api/issues/{id}/rerun)
previously set force_fresh_session=true unconditionally, so the daemon threw
away the failed run's workdir and started from an empty one. For a transient
failure (network blip, provider 5xx / rate-limit, runtime_offline, timeout)
that discards work the agent already did.
New contract: a retry ALWAYS reuses the source task's workdir when it still
exists on disk; only the agent SESSION is gated, on whether the source task's
failure poisoned the conversation.
- RerunIssue derives force_fresh_session from the source task's failure_reason
via resumeUnsafeFailureReason instead of hardcoding true. Transient/cancelled
failures resume the session; conversation-poisoning failures start fresh.
- The daemon claim handler resolves the retry's session/workdir precisely from
rerun_of_task_id, not the most-recent (agent,issue) row that a parallel task
could hijack. PriorWorkDir is returned whenever present; PriorSessionID only
when resume-safe AND on the same runtime.
- force_fresh_session now gates only the session, never the workdir.
- Add agent_error.context_overflow to the resume-unsafe set (Go helper plus the
GetLastTaskSession / GetLastChatTaskSession blacklists): resuming an overflowed
context would immediately overflow again.
Objectively-unreusable cases (workdir GC'd, different runtime, failed before a
workdir was recorded) fall back to a fresh Prepare via the daemon's existing
execenv.Reuse / gateResumeToReusedWorkdir path, so no daemon change is needed.
Tests: RerunIssue force_fresh classification by failure_reason; claim-layer
workdir-always-reused plus session gating (same/different runtime, poisoned);
context_overflow classifier.
Co-authored-by: multica-agent <github@multica.ai>
* fix(task): make manual-retry workdir reuse rollback-safe and error-text aware (MUL-4869)
Addresses code review on #5525.
- Rollback safety: RerunIssue no longer writes force_fresh_session based on the
source failure. Rerun rows are pinned to force_fresh_session=true again, so an
OLD claim handler picked up mid rolling-deploy degrades to a clean start
instead of falling back to the (agent, issue) most-recent lookup and resuming
a *different* execution. The new claim handler ignores the flag for reruns and
computes session reuse from the exact source task (rerun_of_task_id).
- Legacy poison defense: add shared service.ResumeUnsafeFailure(failureReason,
errorText), which combines the failure_reason poison set with the same
400/invalid_request_error raw-error-text guard GetLastTaskSession applies. The
claim handler's exact-source path uses it, so legacy 'agent_error' /
deploy-window rows carrying a 400 marker are no longer resumed.
- CI: TestRerunIssueTargetsSourceTaskAgent passes again (rerun rows stay
force_fresh_session=true). Service test now asserts that rollback-safe
invariant across failure classes; the claim test drives session gating from
the source task and adds a legacy-400 case.
- Docs: tasks.{mdx,zh,ja,ko}.mdx and the GetLastTaskSession SQL comment now
distinguish execution-log per-row retry (task_id: reuse workdir, conditional
session) from CLI/API rerun (no task_id: fresh session + fresh workdir).
- Clarify cross-runtime workdir is best-effort: the daemon offers the source
workdir regardless of runtime (a shared mount may resolve it) and only the
per-cwd session is runtime-gated.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): stop routing CodeBuddy skills/memory through Claude's .claude paths
CodeBuddy Code is a Claude Code fork but ships its own native config
directory (~/.codebuddy, .codebuddy/) with its own memory filename
(CODEBUDDY.md). It only reads .claude/skills or CLAUDE.md if a user
manually symlinks/copies them during migration
(https://www.codebuddy.ai/docs/cli/troubleshooting#migrating-from-claude-code).
Multica's daemon/execenv code treated "codebuddy" as an alias for
"claude" in three places, so skills synced by Multica landed in
.claude/skills/ and CLAUDE.md — paths the default CodeBuddy install
never reads — instead of ~/.codebuddy/skills, .codebuddy/skills, and
CODEBUDDY.md as documented at
https://www.codebuddy.ai/docs/cli/codebuddy-dir and
https://www.codebuddy.ai/docs/cli/skills.
Split the "claude", "codebuddy" switch cases in:
- daemon/local_skills.go (user-level local skill discovery/import)
- daemon/execenv/context.go (per-task skill materialization)
- daemon/execenv/runtime_config.go (runtime brief target file)
Added regression tests locking in the new paths and updated the
install-agent-runtime / providers docs (all 4 locales) that had
documented the old .claude/skills behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(daemon): cover CodeBuddy sidecar hygiene and lifecycle
Address PR #5224 review feedback: exclude CODEBUDDY.md/.codebuddy from
repo-cache worktrees, extend sidecar lifecycle matrices to codebuddy, and
make the local-skills CodeBuddy test exercise a true same-key collision.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Eve <eve@multica-ai.local>
Adds `multica workspace create` (--name/--slug/--description/--context/--issue-prefix, JSON/table output). Creation does not switch the current workspace. Both --name and --slug are required to match the server contract, and the slug is immutable after creation. Docs (en/zh/ja/ko) and the CLI reference now show the executable command.
Closes#5055
Add xAI Grok Build (`grok`) as a first-class Multica runtime over ACP
(`grok --no-auto-update agent --always-approve stdio`), reusing
hermesClient like traecli and kimi. Includes daemon discovery,
protocol_family migration 174, model discovery, MCP passthrough,
thinking effort, frontend branding, and product docs.
Follows xAI's documented headless ACP flow: after `initialize`, read the
advertised `authMethods` and send `authenticate` (preferring xai.api_key
when XAI_API_KEY is set, else the cached login token) before any session
operation — a real, logged-in CLI rejects session/new and session/load
without it. Model discovery performs the same handshake so it returns the
live catalog instead of the static fallback.
`--no-auto-update` is passed as a global flag (and kept daemon-owned in
the blocked custom-arg set) so a background update check can't stall an
unattended ACP task. Thinking effort uses the current `--effort` flag,
and the minimum grok version is 0.2.89 (ACP + authenticate + session/load
+ session/set_model + MCP + --effort).
Closes#2895
* fix(execenv): overlay per-task HERMES_HOME so Hermes discovers bound skills
Hermes has no workspace-relative skill discovery — it scans <HERMES_HOME>/skills
first, then skills.external_dirs from config.yaml (verified against the bundled
agent/skill_utils.py). The daemon wrote assigned skills to the generic
.agent_context/skills/ fallback, which Hermes never reads, so they silently never
took effect (#5242).
When (and only when) an agent has skills bound, redirect HERMES_HOME to a minimal
per-task compatibility overlay; a skill-less Hermes task keeps its real home and
original behavior:
- mirror every top-level entry of the shared home via symlink except the
overlay-owned ones (denylist), reconciling entries deleted from the shared home;
- derive a task-local config.yaml whose skills.external_dirs references the shared
skills dir plus the user's existing external_dirs, expanded against the
sanitized effective child env (unknown vars preserved, blocklisted keys resolved
to the process value) and normalized to absolute paths;
- write only the bound skills into the task-local skills/ dir (home skills scanned
first, so they win); global skills are referenced, not copied;
- keep memories/ overlay-owned (fresh per-task dir) AND disable the external
memory.provider, so neither on-disk memory nor a shared backend crosses tasks;
- keep active_profile/profiles out of the overlay so Hermes can't follow a sticky
profile and redirect past it at startup.
Profile handling mirrors hermes_cli.profiles: the daemon reads -p/--profile with
agent.HermesProfileFromArgs and seeds the overlay from that profile's home via
ResolveHermesSourceHome (default/invalid -> base, valid name -> <base>/profiles/
<name>, validated; a missing named profile fails closed). The profile flags are
stripped from the acp argv ONLY when the overlay is active (hermesLaunchArgs), so
a skill-less task's profile passes through unchanged. HERMES_HOME is no longer
custom_env-blocklisted: no skills -> user value passes through; skills -> overlay
overrides after layering. Fail closed — Prepare errors, Reuse returns nil.
Task home 0700, derived config 0600 via atomic replace. Platform-native default
home (%LOCALAPPDATA%\hermes, incl. the LOCALAPPDATA-missing fallback, on Windows).
Tests span execenv/daemon/agent: no-skill no-op, child-env layering + env
sanitization, profile parse/unquote + conditional strip + final args/env per
scenario, custom/profile/default/invalid/missing/Windows source home, sticky-
profile not mirrored, memory dir isolation + external provider disable, mirror
reconciliation, external_dirs rebasing + sanitized/unknown-var expansion,
local-precedence slug, perms, fail-closed, resume teardown. Docs (en + ja/ko/zh).
Fixes#5242
* fix(hermes): make profile selection one resolver contract matching Hermes
Round 5 review: the profile chain approximated Hermes' semantics in three
separate places (argv parsing, source-home selection, arg filtering), so it
diverged from native Hermes in several merge-blocking cases. Collapse it into
one authoritative resolution:
- agent.ParseHermesProfileArgs replaces HermesProfileFromArgs/
FilterHermesProfileArgs. It reproduces _apply_profile_override step 1/1b
(first occurrence, value-flag skipping, `--` and `mcp add --args` boundaries,
space-form profile-id guard) and returns the exact argv occurrence to consume;
StripHermesProfileArgs removes only that occurrence.
- execenv.ResolveHermesProfile replaces ResolveHermesSourceHome. It derives the
Hermes root exactly like get_default_hermes_root (an already-profile-scoped
HERMES_HOME roots at its grandparent), selects an explicit profile first,
otherwise trusts a profile-scoped home (step 1.5) and only then the sticky
<root>/active_profile (step 2), and validates via normalize/validate_profile_name
(reserved hermes/test/tmp/root/sudo and empty inline `--profile=` are hard
errors). Profiles always resolve under the root, so `-p default` re-roots and
`-p <sibling>` is a sibling, never nested.
- The daemon runs one parse + resolve, fails the task closed on a reserved/
invalid selection (matching Hermes' sys.exit(1)), and exports the selected
source home as the effective env's HERMES_HOME so ${HERMES_HOME} in a profile's
skills.external_dirs expands against the selected profile home (as native
Hermes does before loading config.yaml), not the root or the overlay.
Regressions added: root + sticky named profile selection; already-profile-scoped
home with no flag; that home with -p default and -p <sibling>; reserved and empty
inline profile values; and a selected profile whose external_dirs contains
${HERMES_HOME}.
* fix(hermes): overlay-owned derived .env + symlink-resolved root
Round 6 review, two remaining overlay-bypass paths:
1. A source `.env` could redirect HERMES_HOME after profile resolution. Hermes
runs `_apply_profile_override()` then `load_hermes_dotenv()`, which loads
`<HERMES_HOME>/.env` with override=True — so a mirrored source `.env` carrying
an out-of-band `HERMES_HOME=` overwrote the overlay's home, repointing skill
discovery and memory back at the source. `.env` is now overlay-owned and
DERIVED (writeDerivedHermesEnv): it preserves the source's credentials/settings
but strips any `HERMES_HOME` assignment and pins `HERMES_HOME` to the overlay
last (single-quoted, literal), written 0600 via atomic replace. It is written
even when the source has none, so Hermes' project-`.env` fallback (override=True
only when no user `.env` loaded) can't relocate the home either.
2. Root derivation was lexical-only, diverging from `get_default_hermes_root`,
which compares `env_path.resolve()` with `native_home.resolve()`. A HERMES_HOME
symlinked into `<native>/profiles/<x>` was treated as its own root, so
`-p default`/`-p <sibling>` resolved wrong. `hermesRootFromHomeFor` now resolves
symlinks (Path.resolve(strict=False)-style best effort) for the containment
decision while keeping the returned root unresolved, matching Hermes.
Regressions: source `.env` with HERMES_HOME replayed through the override=True
dotenv order (bound skill + task memory stay on the overlay; creds preserved);
minimal overlay `.env` created when the source has none; and a symlinked profile
home resolving `-p default`/`-p <sibling>` to the native root.
* fix(codex): bound app-server startup RPCs
Co-authored-by: multica-agent <github@multica.ai>
* test(codex): de-flake bounded-handshake test
The single 500ms handshake bound was shared by the successful preamble
RPCs, so a slow fork/exec of the /bin/sh fake app-server could make
initialize spuriously time out under parallel load. Raise the test bound
to 3s (still below the 5s semantic timeout and 10s harness ceiling) and
loosen the elapsed assertion to match.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
Projects become schedulable planning objects alongside their issues: add
optional start_date / due_date, mirroring issue.start_date / issue.due_date.
This is only the first slice of #5227 — labels, metadata, and the editable
metadata UI are still out of scope.
- migration 166: two nullable DATE columns on `project` (calendar days, no
FK/index — matches the issue end-state after migration 112)
- sqlc CreateProject / UpdateProject carry the dates; UpdateProject uses
narg so an explicit null clears
- handler: parse YYYY-MM-DD (400 on bad format), rawFields-presence clear on
update, and the hand-scanned SearchProjects query returns the columns
- CLI: `project create/update --start-date/--due-date` (empty clears on update)
- frontend + mobile types/zod schemas: the two new schema fields are
nullable().default(null) so a project from an older backend (frontend
deploys before backend) parses to null instead of degrading the batch to
the empty fallback; added a search schema drift test
- projects skill / CLI docs
Part of #5227
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(github): fan out PR/check_suite webhooks to all bound workspaces (MUL-4343)
One GitHub App installation can be bound to several workspaces (#4855), but
pull_request and check_suite webhooks were still routed to a single workspace
via resolveWorkspaceForRepo (workspace.repos registry + oldest-binding
fallback). Every workspace but one silently received nothing for a shared repo,
with no way to opt in.
Deliver each repo event to every workspace bound to the installation. Repo
scope is whatever GitHub authorized the installation for; we no longer gate on
the workspace.repos registry (that list means "code the agent clones", not a
webhook subscription). Each workspace independently mirrors the PR, auto-links
against its own issue prefix + github toggles, records check suites against its
own PR mirror, and gets its own realtime broadcast.
- Extract mirrorPullRequestForWorkspace / recordCheckSuiteForWorkspace and loop
over all installation bindings instead of resolving one workspace.
- Remove the now-dead resolveWorkspaceForRepo / repoIdentityFromURL routing.
- Replace the registry-routing tests with PR + check_suite fan-out tests.
Co-authored-by: multica-agent <github@multica.ai>
* test(github): out-of-order fan-out test + drop dead query + document multi-workspace delivery (MUL-4343)
Addresses review feedback on the webhook fan-out change:
- Add TestWebhook_CheckSuite_OutOfOrderFansOutToBoundWorkspaces: a check_suite
that arrives before the PR must stash a pending row per bound workspace, and
each workspace must drain its own row when the PR fans out.
- Remove the now-unused ListWorkspacesWithRepos query (its only caller was the
deleted resolveWorkspaceForRepo) and regenerate sqlc; fix the stale
"picks the target workspace via the repos registry" comment on
ListGitHubInstallationsByInstallationID.
- Document multi-workspace event delivery in the GitHub integration docs
(en + zh), including an explicit self-host upgrade note: delivery is now
keyed on the GitHub connection, so a workspace that relied on the
code-repository list alone (without connecting GitHub) must connect the
installation to keep receiving events. This is an intentional, documented
behavior change — the PR description's earlier "single-binding behavior is
unchanged" claim was inaccurate and has been corrected.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): bound daemon.log size with rotation (MUL-4330)
The background daemon redirected its stdout/stderr into daemon.log opened
O_APPEND and never rotated it, so the file grew without limit until it was
too large to open. Every structured log line already flows through slog
(including agent subprocess stderr, forwarded via newLogWriter), so the
daemon's logger is effectively the sole author of the file's volume.
Route the foreground daemon's slog output — both the injected component
logger and the package-global slog default — through a size-based rotating
writer (lumberjack) that keeps the active daemon.log small (20MB default,
5 gzip-compressed backups, 30d), all env-overridable. Raw crash output
(Go runtime panics, pre-logger errors) now goes to a separate daemon.err.log
so the child's inherited fds never hold daemon.log open, which would block
rotation's rename on Windows.
The Desktop app spawns the daemon via this same launcher and its log tail
already handles size-shrink, so both CLI and Desktop are covered.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): address log-rotation review — foreground output, Windows handles, bounded err log (MUL-4330)
Resolves the blocking review items on the daemon.log rotation change:
1. Windows first-upgrade rotation: a foreground managed daemon now re-points
its own stdout/stderr to daemon.err.log at startup (SetStdHandle) before
building the rotator, releasing any daemon.log handle an older self-update
launcher inherited (Go opens files without FILE_SHARE_DELETE, which would
otherwise block rename-on-rotate). No-op on Unix, where an open fd never
blocks rename.
2. `daemon logs -f` vs rotation: Unix uses `tail -F` (reopen by name); Windows
opens the reader with FILE_SHARE_DELETE so it can't block the rotator's
rename, and reopens the file on size-shrink to follow across rotation.
3. Self-update handoff no longer briefly runs two rotators on one file: the old
process closes its rotator and moves remaining handoff logs (incl. the slog
default) to the crash sink before the successor starts.
4. daemon.err.log is now bounded: it rolls to a single ".1" backup once past
5MB at open time, so a crash loop can't move the growth problem to it. It is
also surfaced in the troubleshooting docs.
5. Explicit `--foreground` in a terminal keeps live stdout/stderr logging (a
documented debugging path); only detached/background children rotate into
daemon.log. Decided by whether stderr is a terminal.
Also: rotation env knobs now reject 0/negative (0 means 100MB / keep-all in
lumberjack), preventing an accidental unbounded config. Adds unit tests for
the err-log rolling and positive-int parsing; Windows/Linux(arm64) cross-builds
and `GOOS=windows go vet` pass.
Co-authored-by: multica-agent <github@multica.ai>
* docs: sync zh/ja/ko troubleshooting with daemon.log rotation + daemon.err.log (MUL-4330)
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): bump the agent's own runtime version in quick-create parent test (MUL-4330)
TestQuickCreateIssueParentTrustBoundary bumped an arbitrary `LIMIT 1`
agent_runtime, but the handler version-checks agent.RuntimeID — the runtime
bound to the request's agent. In the shared handler test workspace, other
tests register additional runtimes, so the two diverge and the agent's real
runtime keeps the seed's empty cli_version, tripping the daemon-version gate
(422 daemon_version_unsupported) before the parent_issue_id assertions run.
Bump the runtime tied to the agent instead, making the setup deterministic.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Update public docs and landing copy to the current 14-runtime list; add Qoder / Trae CLI across localized docs. Follow-up: finish JA tool counts (tasks.ja / skills.ja) and align localized Trae section anchors with the /providers#trae links.
Closes#4945
Co-authored-by: vicksiyi <zeroicework@163.com>
The Slack `/issue` slash command used to directly create a raw issue: the
typed line became the title verbatim and a `todo` issue was assigned to the
agent to work on immediately. That files a rough, unstructured issue and starts
the agent on it before it is well-formed.
Switch the slash command to the quick-create pipeline instead
(TaskService.EnqueueQuickCreateTask, the same path as the web "quick create"
modal): the invoker's natural-language description is handed to the
installation's agent as a prompt, and the agent authors a well-formed issue
(proper title + structured description) in the background, attributed to the
bound member. Because creation is now asynchronous, the ephemeral reply is an
acknowledgement ("On it…") rather than a created-confirmation with a number;
the agent's completion surfaces to the invoker as a Multica inbox notification
through the shared quick-create completion path.
Installation routing and identity/membership checks are unchanged, so the same
workspace boundary and account-binding rules apply. Scope is the slash command
only — the message-based `@bot /issue` still runs through the shared
cross-platform engine (which also serves Lark) and keeps its direct-create
behavior.
- slash_command.go: swap IssueService.Create for EnqueueQuickCreateTask via a
narrow quickCreateEnqueuer interface; prompt is the full text (no title/body
split); drop the now-unused splitIssueText / issueCreatedText / GetWorkspace.
- router.go: wire h.TaskService instead of h.IssueService.
- tests: cover enqueue + ack, multiline prompt pass-through, empty prompt,
unbound, non-member, inactive, team mismatch, and enqueue-failure.
- docs (4 locales): describe the quick-create behavior.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
A Slack user who linked their Multica identity to one bot was re-prompted
to link again when messaging a second bot (a different Slack app) in the
SAME Slack workspace, because bindings are keyed per-installation
(installation_id, channel_user_id).
On an unbound inbound, the identity resolver now looks up an existing
binding for the same (Multica workspace, Slack team, Slack user) via the
new FindReusableChannelUserBinding query and, if that user is still a
workspace member, materializes a binding for the new installation instead
of returning ErrSenderUnbound — so the second bot resolves the user
silently. Reuse is fenced to one Multica workspace AND one Slack team, so
it never crosses either boundary; legacy installs with no recorded team
never reuse.
Adds resolver unit tests for every decision path and updates the Slack
integration docs (en/zh/ja/ko).
MUL-3911
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): native /issue slash command over Socket Mode (MUL-3908)
A message beginning with `/issue` is intercepted by the Slack client as a slash command and never delivered to the app, so the message-prefix /issue never worked on Slack (no event, no 👀, no issue).
Register /issue as a real slash command in the app manifest and handle EventTypeSlashCommand over the existing per-installation Socket Mode connection. It is a one-shot issue creation (no chat session / agent run) that reuses the shared IssueService and the same installation-routing + identity/membership checks as the message path, replying privately via the command's response_url (ephemeral) since a slash command has no message to react to.
Docs: register the command in the manifest and describe the slash-command behavior across all four locales.
Co-authored-by: multica-agent <github@multica.ai>
* docs(slack): add commands scope to /issue manifest; fix chat-run wording (MUL-3908)
Review follow-up: the manifest examples registered features.slash_commands but omitted the commands bot scope, so updating + reinstalling could still fail to grant the /issue command. Add - commands to oauth_config.scopes.bot in all four locales and document it in the permissions table.
Also correct the misleading "no agent run" wording in the slash-command header and router comment: a todo issue assigned to the agent still triggers it via maybeEnqueueOnAssign (issue-assignment), like the message /issue — the slash command only skips the chat session / chat run.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Follow-up to #4724, which added the Trae CLI (traecli) ACP backend but
left the surrounding docs behind.
- install-agent-runtime: add a Trae CLI section (install, ACP transport,
enterprise login, inline runtime brief, MULTICA_TRAECLI_MODEL)
- providers: fix the MCP paragraph — Trae also receives ACP mcpServers
- daemon-runtimes: add Qoder + Trae CLI to the built-in detection list
- README: add Trae CLI to the architecture diagram and runtime row
- bump stale English tool counts (12/13 -> 14) across cross-references;
the '12' lists were already missing Qoder before this change
Scope: English docs only. The ja/zh localizations are separately behind
(they predate Qoder too) and need their own translation-sync pass.
Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Adds the official ByteDance TRAE CLI (the `traecli` binary documented at
https://docs.trae.cn/cli — the product paired with the Trae IDE, not the
open-source bytedance/trae-agent) as a built-in agent backend. traecli is
ACP-native, so it is driven over the standard ACP JSON-RPC transport via
`traecli acp serve --yolo`, reusing the shared hermesClient exactly like the
Kiro and Qoder backends.
Validated end-to-end against the real traecli v0.120.42 with a logged-in
account: initialize advertises loadSession:true + mcpCapabilities{http,sse};
session/new returns result.sessionId + models.availableModels (18 models
discovered); session/prompt streams session/update notifications with
sessionUpdate=agent_message_chunk (hermesClient already normalizes this Zed-ACP
wire shape); a real board task ran 14 tool calls and completed in ~47s.
Implementation:
- server/pkg/agent/traecli.go: ACP backend; session/load resume
(loadSession:true), session/set_model, MCP via ACP mcpServers, --yolo
bypass-permissions for headless runs, blocked-arg filtering (acp, serve,
--yolo, --print, --output-format, --permission-mode)
- agent.go: New() + launch header "traecli acp serve"
- models.go: discoverTraecliModels via the shared discoverACPModels
- daemon/config.go: auto-detect the `traecli` binary
(MULTICA_TRAECLI_PATH / MULTICA_TRAECLI_MODEL)
- daemon.go: inline the runtime brief (traecli reads .trae/rules/, not
AGENTS.md) and surface the runtime as "Trae" (providerDisplayName)
- execenv: AGENTS.md + .traecli/skills wiring; ~/.traecli/skills local root
- packages/core mcp-support: traecli consumes mcp_config
- frontend: official Trae provider logo
- docs: providers.mdx matrix + section, CLI_AND_DAEMON.md, README
Tests: fake-ACP unit tests matching the real wire format (streaming,
blocked-arg filtering, session/set_model failure, session/load resume) plus a
gated real-binary smoke test (TestTraecliRealACPSmoke) that skips when traecli
is absent or not logged in. Built-in provider only (mirrors qoder): not in
SupportedTypes / RUNTIME_PROFILE_PROTOCOL_FAMILIES, so no migration is needed.
Resolves#4376.
Slack is not officially launched yet, so the 'already created your app
with an older manifest — add reactions:write and reinstall' guidance is
unnecessary; nobody is running a pre-launch manifest in production. Remove
the warning callout from all four locales (en/zh/ja/ko).
The reactions:write scope in the manifest and the scope table stay, since
the typing indicator still depends on it.
Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(slack): add typing reaction on inbound message (MUL-3874)
Mirror the Feishu typing indicator on Slack: react with 👀 on the user's
message when it is ingested, then remove the reaction when the agent's run
finishes (EventChatDone) or fails (EventTaskFailed).
- New slack.TypingIndicatorManager: Add on ingest, Clear on terminal run
events; state keyed by chat_session_id, bot token re-resolved from the DB on
clear (never held in memory), all failures logged and swallowed (best-effort).
- Wire via the channel-agnostic engine.TypingNotifier seam (slackTypingNotifier
in the ResolverSet) — the Router already calls OnIngested off the ACK path.
- Clear subscribes to the event bus directly so a failed run also drops the
reaction (the outbound replier only handles EventChatDone).
- Skip messages older than 2m so Socket Mode reconnect replays don't restamp.
Requires the installed Slack app to hold the reactions:write scope.
Co-authored-by: multica-agent <github@multica.ai>
* fix(slack): clear typing reaction when no task runs; document reactions:write (MUL-3874)
Addresses review feedback on the typing-indicator PR.
1. Stuck reaction on offline/archived agent. The debounced flush
(flushChatRun) enqueues no task when the agent has no runtime or is
archived (or on any enqueue/reload error), so no task lifecycle event is
ever published and the bus-driven clear never fires — leaving the 👀 (and
Feishu's Typing) reaction stuck on the user's message. Fix at the shared
engine seam: add TypingNotifier.OnSettled(ctx, sessionID), which the Router
calls from the flush on every no-task exit (before any offline/archived
notice). Both the Slack and Feishu notifiers route it to manager.Clear, so
the latent Feishu case is fixed too. Adds engine coverage (offline/archived
clear, success does not) and a Slack OnSettled test.
2. Missing reactions:write scope in docs. reactions.add/remove silently fail
without the scope, but the BYO app manifest/docs never listed it. Add
reactions:write to the manifest + scope table and a reinstall note across
all four locales (en/zh/ja/ko).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Gemini CLI runtime was removed in MUL-3617 (#4503), but the canonical
"12 built-in providers" list still advertised [Gemini](/providers#gemini)
(a now-dangling anchor) and omitted CodeBuddy, which the daemon actually
auto-detects (config.go probes `codebuddy`). Swap Gemini -> CodeBuddy
across all 16 occurrences (index / how-multica-works / cloud-quickstart /
daemon-runtimes x en/zh/ja/ko); the count stays at twelve.
MUL-3861
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Match the code fix (#4703): the "link your account" link is built from the web
app URL (MULTICA_APP_URL ?? FRONTEND_ORIGIN), which a normal deployment already
sets — not MULTICA_PUBLIC_URL (the backend/API URL). Updates en + zh + ja + ko.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(slack): declutter the Slack connect UI
Trim the Slack bring-your-own-app UI to match the leaner Lark card and
stop burying the setup behind prose nobody reads:
- Drop the "Required bot scopes: …" block from the connect dialog.
- Shorten the Slack integration card description to mirror the Lark
card; the token/admin details stay in the setup docs.
- Remove the dialog intro paragraph and the per-field token hints;
replace the small "Read the setup guide" link with a larger,
more prominent step-by-step guide link.
Removes the now-unused i18n keys (byo_dialog_intro, byo_bot_token_hint,
byo_app_token_hint, byo_scopes_hint) across en/zh-Hans/ja/ko.
* docs(slack): drop the users:read warning callout
The bot manifest already lists users:read as a required scope (with the
bots.info rationale in the scopes table), so the standalone warning
callout was redundant. Removed across en/zh/ja/ko.
* docs: channel integrations overview + Slack bot page + Slack app setup guide (MUL-3666)
- channels.mdx: channel-engine overview with an architecture diagram (Mermaid),
the inbound pipeline, the session/context model, and the authorization gates
(account binding + workspace membership) — all shared by Lark and Slack.
- slack-bot-integration.mdx: the Slack channel page (mirrors lark-bot-integration)
— BYO connect flow, usage (@ in channel / DM / /issue), one-bot-per-agent,
permissions, and self-host (MULTICA_SLACK_SECRET_KEY).
- create-slack-app.mdx: standalone step-by-step — create a Slack app from a
copy-paste manifest, install it, and grab the bot + app-level tokens.
- meta.json: list the three pages under Integrations.
English (canonical) only this pass; zh/ja/ko localization to follow.
Co-authored-by: multica-agent <github@multica.ai>
* docs(slack): inline full manifest + step-by-step setup into the Slack page (MUL-3666)
The Slack page only linked out for setup, which read as too thin. Fold the
complete, code-verified app manifest and the full walkthrough (create from
manifest → install + bot token → app-level token with connections:write →
connect in Multica) directly into slack-bot-integration.mdx, plus a table
explaining what each scope/event is for.
Remove the now-redundant standalone create-slack-app.mdx (its content lives on
the Slack page) and update meta.json + the channels.mdx links accordingly, so
there's one comprehensive Slack page and no duplicated manifest to drift.
Co-authored-by: multica-agent <github@multica.ai>
* docs(i18n): translate channel + Slack pages to zh/ja/ko and add to nav (MUL-3666)
Adds Simplified Chinese, Japanese, and Korean versions of channels.mdx and
slack-bot-integration.mdx, and lists both pages under Integrations in
meta.{zh,ja,ko}.json. The copy-paste manifest YAML and dotenv blocks are kept
byte-identical to the English source across all languages; in-page anchors in
the channel page point at the slug of each translated heading.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(docs): add reusable VideoEmbed and embed intro video on zh homepage
Add a provider-agnostic, click-to-load <VideoEmbed> component (Bilibili now,
YouTube reserved) and embed the Chinese intro video (BV1cv7Y6gEg7) at the top
of the Chinese docs homepage. The facade renders on first paint; the
third-party player iframe only mounts on user click, so first paint pays
nothing for an external player or its trackers. Registered in both docs MDX
component maps so it is reusable on any docs page.
Scope is zh docs only — no usecase, no other locales, no analytics, no video
hosting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(zh): drop duration from intro video title
Use the duration-free title "Multica 中文介绍视频" for the homepage VideoEmbed
instead of a minute-count phrasing. Copy accuracy only; no component, layout,
or provider changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>