Compare commits

..

55 Commits

Author SHA1 Message Date
Eve
6adae11e98 docs(changelog): refine v0.3.11 release notes
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 17:59:39 +08:00
Eve
22b7573303 docs(changelog): add v0.3.11 release notes
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 17:17:43 +08:00
Bohan Jiang
90a737fc7e fix(daemon): retry terminal task callbacks on transient errors (MUL-2780) (#3443)
CompleteTask / FailTask used to be fire-once. A 1-second upstream 502
burst would drop the call, then the immediate fail-fallback also 502'd,
leaving the task stuck in `running` forever and showing the agent as
"still working" in the UI.

Add a bounded retry around the two terminal callbacks: 4s, 8s, 16s,
32s, 64s backoff schedule (5 retries, ~124s ceiling), retrying only
on transient errors (5xx, 408, 429, transport-level) and bailing
immediately on permanent 4xx. Also fix a latent bug where a transient
complete failure would silently downgrade a successful run to a fail:
the fallback now triggers only on permanent errors. Server-side
CompleteTask / FailTask are already idempotent on "already terminal",
so replays from a retry are safe even if the prior 502'd response was
actually persisted.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 17:06:41 +08:00
LinYushen
7bbca54e92 feat(billing): test page consuming /api/cloud-billing/* (#3442)
* feat(billing): test page consuming /api/cloud-billing/*

Stuffs every cloud-billing endpoint onto a single dev page so we can
verify the proxy + Stripe end-to-end flow without a designed UI.
Reachable at /<workspaceSlug>/billing — the page is account-level
data but lives under the workspace dashboard layout because that's
where the authenticated shell sits. No sidebar entry on purpose;
this is test-quality and meant to be deleted when the real billing
UI ships.

What's there:

  * Balance card (GET /balance)
  * Stripe-success polling banner — visible only when ?session_id=
    is in the URL (Stripe substitutes it into checkout_success_url
    on its way back). React Query refetchInterval polls every 2s
    until the topup status reaches credited / failed / canceled,
    then a 'Clear from URL' button calls navigation.replace(pathname).
  * Buy section: server-authoritative price tier buttons (GET
    /price-tiers) → POST /checkout-sessions → window.location to the
    Stripe URL. We do NOT hard-code amounts on the frontend; tier
    config lives in cloud's billing.price_tiers.
  * Stripe Billing Portal button (POST /portal-sessions). Opens in a
    new tab so the originating page stays put for easy verification.
    Documented behaviour: 400 is expected for users with no Stripe
    customer record yet.
  * Three lists: transactions / batches / topups.

Plumbing:

  * packages/core/types/billing.ts — interfaces mirroring the cloud
    response shapes. Status / source / tx_type fields are typed
    'string' rather than enum unions to match the schemas' z.string()
    parsing (same convention as CloudRuntimeNode); the canonical
    enum values are exported as separate type aliases for callers
    that want to switch on them.
  * packages/core/api/schemas.ts — 9 zod schemas + 7 EMPTY_ fallbacks,
    all .loose() so a non-breaking cloud-side field addition doesn't
    crash the parser.
  * packages/core/api/client.ts — 8 methods using parseWithFallback,
    matching the existing cloud-runtime shape.
  * packages/core/billing/{queries,mutations,index}.ts — React Query
    queryOptions + mutations. Notable choices: balance / lists are
    NOT keyed on workspace (account-level data), and the
    checkout-session polling stops automatically when status is
    terminal so we don't poll forever after a user closes the tab.
  * packages/core/package.json + packages/views/package.json — exports
    map updated for @multica/core/billing and @multica/views/billing.

Verification:

  * pnpm --filter @multica/core typecheck clean
  * pnpm --filter @multica/views typecheck — only pre-existing
    hast-util-to-html error in editor code (exists on main)
  * pnpm --filter @multica/core test — 412 passing
  * pnpm --filter @multica/views test — 877 passing, 1 failure
    (editor/readonly-content) is also pre-existing on main, not
    caused by this change

Out of scope: real production-quality billing UI; sidebar entry; i18n
strings; mobile app. This is a single test page; it gets replaced
when the real UI ships.

* fix(billing): refetch balance/lists when checkout polling reaches terminal

Closes the second-half of the Stripe-return race the previous commit
left dangling.

Symptom:
  After Stripe redirects back with ?session_id=..., the banner polls
  /checkout-sessions/{id} every 2s and the rest of the page (balance,
  transactions, batches, topups) is fetched once on mount. The
  webhook race means those four queries usually see pre-credit state
  — but the banner is the only thing that keeps polling, so once it
  reads 'credited' nothing else on the page knows. The user would
  see 'Final status: credited' next to a stale balance card until
  they manually refresh.

Fix:
  Add useInvalidateBillingDataAfterCredit() in @multica/core/billing —
  a hook returning a callback that flushes balance / transactions /
  batches / topups (NOT the checkout-session itself; its
  refetchInterval already terminated, refetching would just confirm
  the same value). The Stripe-success banner runs this callback in
  a useEffect keyed on terminal-status transition, so it fires
  exactly once when the polling lands.

  Strict scope is documented in the hook's JSDoc:
    - balance/transactions/batches: only change at the 'credited'
      transition (cloud writes ledger + batch + wallet in one DB tx)
    - topups: changes on every terminal transition
    - For 'failed' / 'canceled' we technically over-fetch the first
      three; three cheap round-trips, simplifies the call site, fine
      on a test page.

  Effect dep is . terminal flips false→true at most once
  per session id (the polling stops when terminal is true so the
  data won't change again). If the user lands here with a session
  that is already terminal (re-opened tab on a credited URL), the
  effect still fires on first data load and we still re-fetch —
  correct, the cached snapshot is just as stale in that case.

go build / pnpm typecheck / pnpm test clean (core 412 passing; only
pre-existing hast-util-to-html error in unrelated editor code on
views, same as on main).
2026-05-28 16:48:04 +08:00
Bohan Jiang
90ddfb04e2 feat(self-host): DISABLE_WORKSPACE_CREATION env var (MUL-2777) (#3441)
* feat(self-host): DISABLE_WORKSPACE_CREATION env var (MUL-2777, #3433)

When self-hosters set DISABLE_WORKSPACE_CREATION=true, POST /api/workspaces
returns 403 for every caller and the UI hides every "Create workspace"
affordance (sidebar, modal, /workspaces/new page, onboarding Step 2). This
closes the gap where ALLOW_SIGNUP=false still let any signed-in user open
an isolated workspace the platform admin couldn't see.

- server: new Config.DisableWorkspaceCreation, gate in CreateWorkspace,
  workspace_creation_disabled in /api/config, Go tests.
- frontend: new workspaceCreationDisabled in configStore, hide sidebar
  entry, swap NewWorkspacePage / CreateWorkspaceModal / onboarding
  StepWorkspace to a "creation disabled, ask for invite" state when the
  flag is on, EN + zh-Hans locale strings.
- ops: .env.example, docker-compose.selfhost, helm values + configmap,
  SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md, environment-variables docs
  (EN + zh).

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

* fix(onboarding): drive create path off workspaceCreationAllowed (#3433)

PR #3441 review: when DISABLE_WORKSPACE_CREATION=true and the user already
has a workspace, StepWorkspace still walked the resume copy (`headline_resume`
/ `lede_resume` mentioning "or start another") and `creatingActive` ignored
the flag, leaving a stale clickable create CTA possible if /api/config
arrived late.

Refactor StepWorkspace to derive a single `workspaceCreationAllowed`
boolean from the config store. It now drives:

- Initial `mode` state (defaults to "existing" when disabled + reusing so
  the CTA is pre-armed for the only valid action).
- `creatingActive` so the footer CTA cannot fall back into the create
  branch even mid-render.
- Eyebrow / headline / lede strings — adds
  `creation_disabled_{eyebrow,headline,lede}_resume` (EN + zh-Hans) for
  the disabled + reusing variant.

Tests: cover the three reachable shapes — flag off + no existing, flag on
+ no existing, flag on + existing.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 16:42:08 +08:00
Bohan Jiang
09f9c7e2ce MUL-2764 feat(agent): wire mcp_config through ACP runtimes (Hermes / Kimi / Kiro) (#3439)
* MUL-2764 feat(agent): wire mcp_config through ACP runtimes (Hermes / Kimi / Kiro)

The MCP config Tab (#3419) already lets admins save mcp_config on an
agent, and the daemon plumbs it through to `agent.ExecOptions.McpConfig`
for every runtime. Claude and Codex consume it; the three ACP runtimes
(Hermes / Kimi / Kiro) ignored the field and hardcoded an empty
`mcpServers: []` in their `session/new` requests.

Add `buildACPMcpServers` to translate the Claude-style `{"mcpServers":
{"<name>": {...}}}` object-of-objects into the array shape ACP requires
(`[{name, command, args, env: [{name,value}, ...]}, ...]` for stdio;
`[{type, name, url, headers: [...]}, ...]` for http/sse), then pass the
translated array on `session/new` (all three) and `session/load` (kiro
resume). Malformed JSON fails the launch closed — same contract Codex's
`renderCodexMcpServersBlock` uses — so users see a real error instead of
silently running with no MCP servers. Individual unclassifiable entries
(no command, no url) are skipped with a warning so one bad row can't
take MCP down for the rest of the agent.

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

* MUL-2764 fix(agent): wire mcp_config through ACP resume + gate http/sse on capability

Addresses the two blockers Elon raised on #3439:

1. session/resume now carries mcpServers for Hermes and Kimi (Kiro's
   session/load already did). Per the ACP Session Setup spec the resume
   path re-attaches MCP servers, and without this a resumed task lost
   access to MCP tools that a fresh task on the same agent would have
   had. Pinned with new TestHermesResumeIncludesMcpServers and
   TestKimiResumeIncludesMcpServers integration tests that inspect the
   recorded wire request.

2. Added extractACPMcpCapabilities + filterACPMcpServersByCapability so
   http/sse MCP entries get dropped (with a daemon-log warning naming
   the entry) when the runtime's initialize response doesn't advertise
   mcpCapabilities.http / .sse. Sending those entries to a stdio-only
   runtime is a spec violation and reliably tanks session/new; now they
   get filtered and the rest of the session still starts. Stdio entries
   pass through unconditionally. Both backends wire the filter in right
   after initialize so session/new and session/resume see the same
   filtered list.

Also added TestKiroLoadIncludesMcpServersFromConfig — Elon flagged that
no test pinned "non-empty mcp_config actually reaches the wire" for
Kimi/Kiro, so the wire assertions go in for all three runtimes.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 16:29:49 +08:00
Bohan Jiang
03f70209c4 fix(daemon): preserve user CLAUDE.md / AGENTS.md / GEMINI.md in local_directory runs (#3438)
* fix(daemon): preserve user CLAUDE.md / AGENTS.md / GEMINI.md in local_directory runs (MUL-2753)

InjectRuntimeConfig previously called os.WriteFile unconditionally, which
truncated whatever file lived at the same path. For the local_directory
project_resource flow the workdir is the user's own repo, so the agent
silently destroyed any repo-level CLAUDE.md / AGENTS.md / GEMINI.md the
first time it ran in that directory, and the daemon's local-directory
cleanup explicitly skips the user's path so the file was never restored.

Write the brief inside a marker block instead:

  <!-- BEGIN MULTICA-RUNTIME (auto-managed; do not edit) -->
  ...brief...
  <!-- END MULTICA-RUNTIME -->

writeRuntimeConfigFile handles three states:

- file missing  -> create with just the marker block,
- file present, no marker block -> append the marker block at the end
  (preserves user-authored content above), and
- file present, marker block already there -> replace the block body in
  place so repeated runs don't grow the file unboundedly.

This is the short-term fix called out on MUL-2753. The sidecar question
(.agent_context/, .claude/skills/, .multica/project/resources.json) is
left for a follow-up — those files don't overwrite user content, just
litter the workdir.

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

* fix(daemon): cleanup runtime config marker block after local_directory tasks (MUL-2753)

Address Elon's review on PR #3438:

1. Add `CleanupRuntimeConfig` and wire it into the daemon's task path so
   `local_directory` runs excise the marker block on the way out. Without
   it, a user's subsequent manual `claude` / `codex` / `gemini` run in
   the same directory picks up the previous task's stale brief (issue
   id, trigger comment id, reply rules) and acts on the wrong context.
   Cloud workspace runs skip the cleanup — their scratch workdir is
   wiped by the GC loop anyway.

2. If excising the block would leave the file empty / whitespace-only,
   the file is removed so we don't leave behind a stub the user has to
   delete by hand. Surviving user content is preserved byte-for-byte.

3. Harden the marker parser: search for the end marker strictly after
   the begin marker. The previous `strings.Index` pair mishandled two
   malformed cases —
     - a stray end marker before any begin (e.g. user pasted a
       documentation snippet showing the wire format) would cause
       every run to stack another block, growing the file unboundedly;
     - a half-block left by a previous crashed run would cause every
       subsequent run to append a fresh block beneath the half-block.
   The `locateMarkerBlock` helper now anchors the end search past the
   begin offset, and treats "begin found, no end after" as "block runs
   to EOF" so the next write replaces it cleanly.

Centralised the provider→filename mapping in `runtimeConfigPath` so
Inject and Cleanup can't drift past each other when a new provider is
added.

Tests cover: parser hardening (stray-end-before-begin idempotency,
half-block recovery), Cleanup happy path / file removal / no-op cases /
malformed half-block / per-provider mapping, and an end-to-end
inject→cleanup round trip that locks in byte-identical restoration of
the user's pre-injection file.

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

* fix(daemon): byte-exact inject/cleanup round trip for runtime config (MUL-2753)

Address Elon's second-round review on PR #3438. The previous cleanup
relied on `TrimRight + "\n"` for trailing newlines and `TrimSpace == ""`
for file removal — both compensated for the inject path's "normalise
trailing newlines so there's always exactly `\n\n` before the block"
step, but they did so by mutating the user's bytes. The result was a
real diff on three boundary cases:

  - file ended without a newline (`rules`) → cleanup added one;
  - file ended with two or more newlines (`rules\n\n`) → cleanup
    collapsed to a single newline;
  - file pre-existed but was empty / whitespace-only → cleanup
    deleted it.

Reshape the contract so the bytes inject adds are the exact bytes
cleanup removes, with no user-byte mutation in between:

  - Define `runtimeManagedSeparator = "\n\n"` as a fixed managed
    separator that inject always inserts (unconditionally — including
    for files that already end in two or more newlines) between
    pre-existing user content and the marker block.
  - Inject's missing-file branch still writes the block alone (no
    separator); that absence is the marker Cleanup uses to identify
    "we created this file from scratch" and is the only condition
    under which Cleanup is allowed to `os.Remove` the file.
  - Cleanup detects `HasSuffix(pre, runtimeManagedSeparator)` and
    strips exactly those bytes; whatever remains is written back
    verbatim with no `TrimRight` / `TrimSpace`, so the pre-injection
    bytes survive exactly.

The replace-in-place branch is untouched — the managed separator
established by the first inject lives in pre and survives across
subsequent runs, so byte-exactness is preserved through arbitrary
inject→inject→cleanup chains.

Tests:

  - `TestInjectThenCleanupRoundTripByteExactBoundaries` parameterises
    9 seed shapes (missing file, empty, whitespace-only, no trailing
    newline, one trailing newline, two trailing newlines, many
    trailing newlines, CRLF line endings, no final newline with
    embedded blank lines) and asserts byte-identical round trip
    across two full cycles.
  - `TestInjectReplaceThenCleanupRestoresByteExact` covers the
    replace-in-place branch for the same boundary seeds.
  - `TestWriteRuntimeConfigFileAlwaysInsertsFixedManagedSeparator`
    pins the new invariant at the source: regardless of seed shape,
    inject emits `<seed><\n\n><marker block>` with no normalisation.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 16:15:07 +08:00
Bohan Jiang
72e0cb70aa fix(transcript): truncate workdir chip + click-to-copy (#3440)
Real workdir paths are routinely long enough to push every other chip
off the transcript-dialog metadata row, leaving the row scrolling or
wrapping awkwardly. Turn the chip into a fixed-width button:

- max-w-[16rem] + truncate so the path tail gets a clean ellipsis no
  matter the depth
- title attribute carries the full relative_work_dir for a hover peek
- click copies relative_work_dir to clipboard, icon flips to a green
  Check for 2s as feedback
- swap FolderTree icon for the simpler Folder mark

The pre-existing privacy invariant is preserved unchanged: only the
server-cleaned relative_work_dir reaches the DOM / title / clipboard;
the absolute task.work_dir still never leaves the server response.
2026-05-28 16:14:50 +08:00
LinYushen
3943358e67 feat(billing): proxy /api/cloud-billing/* + Stripe webhook to multica-cloud (#3434) 2026-05-28 16:05:19 +08:00
Naiyuan Qing
5e78e5100a feat(comments): since-delta new-comment hint + default-on comment session resume (#3432)
* feat(db): add unresolved comment count + list filter queries

Add CountUnresolvedComments (excludes the agent's own comments) and
ListUnresolvedCommentsForIssue. Both are additive — existing callers stay
on the unfiltered queries — so old clients are unaffected.

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

* feat(handler): support unresolved-only comment listing

Wire an additive `unresolved` query param into ListComments. Defaults off
so an old CLI that never sends it gets unchanged behavior; only true/1
enable it. Rejects combining unresolved with thread/recent (whole-issue
filter vs navigation models). Includes filter + count query tests.

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

* feat(handler): plumb unresolved count + thread root into claim, gate comment resume

Populate trigger_parent_id (thread root of the trigger comment) and
unresolved_count (excludes the agent's own comments) on comment-triggered
claim responses. Both fields are omitempty so old daemons ignore them.

Gate comment-triggered session resume behind MULTICA_RESUME_COMMENT_SESSION
(default off): resumed comment turns can inherit the prior turn's "Done."
final message, so this stays an explicit rollout switch. The runtime-match
and poisoned-session guards still apply regardless of the flag.

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

* feat(daemon): inject unresolved-comments hint + resolve step into agent brief

Add a shared BuildUnresolvedCommentsHint helper rendered on both the
per-turn prompt and the CLAUDE.md workflow (kept in sync per PR #2816). It
ships only the count and the relevant CLI call — never comment bodies — so
the server stays cheap. Thread case points at --thread <root>; issue case
points at --unresolved. Suppressed when the count is 0.

Also add a workflow step telling the agent to `multica comment resolve
<thread-root>` once a thread is fully handled, so the unresolved set
converges.

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

* feat(cli): add comment list --unresolved and comment resolve command

Add an --unresolved filter to `issue comment list` (wired to the server's
unresolved param, rejected when combined with --thread/--recent) and a
top-level `comment resolve <id>` command that POSTs to the existing
/api/comments/{id}/resolve endpoint, letting an agent close threads it has
fully handled.

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

* refactor(comments): since-delta new-comment hint + default-on comment resume

Simplifies the comment-triggered agent flow down to what's actually needed:

- New-comment awareness is now a pure time delta: the claim response carries
  new_comment_count + new_comments_since (anchored on the prior run's
  started_at, never completed_at so a long run can't miss comments). The
  per-turn prompt and CLAUDE.md workflow render one line — "N new comment(s)
  since your last run, --since <ts>" — via a shared BuildNewCommentsHint so the
  two surfaces can't drift. Cold start (no prior run) falls back to a plain read.
- Comment-triggered tasks resume the prior session by default (same runtime),
  dropping the MULTICA_RESUME_COMMENT_SESSION rollout gate. The "Focus on THIS
  comment" prompt guard defends against inheriting the prior turn's "Done."
  marker; GetLastTaskSession still excludes poisoned sessions.
- Drops the resolved-based machinery from the first draft: CountUnresolvedComments
  / ListUnresolvedCommentsForIssue queries, the `comment list --unresolved`
  flag, the `multica comment resolve` command, and the resolve workflow step.
- Removes the verbose cursor-pagination paragraph from the comment prompt; the
  --thread/--recent/--since flags stay in the CLI/API, just no longer explained
  inline every turn.

Compatibility: new claim fields are omitempty (old daemons ignore them).
Comment resume is default-on and affects even old daemons, which already
consume prior_session_id.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:58:42 +08:00
Jiang Bohan
f26bdffaf2 fix(provider-logo): handle Next.js vs vite PNG import shape divergence
Next.js types PNG imports as StaticImageData ({ src, width, height });
vite/electron-vite types them as plain string. Component is consumed by
both apps/web (Next.js) and apps/desktop (electron-vite), so a single
type can't satisfy both — last CI failed apps/web typecheck.

Normalise via unknown at the import site so neither side's narrower
type causes the other side's branch to collapse to never. assets.d.ts
declares the union; the component derives a plain-string src once at
module load.
2026-05-28 15:58:28 +08:00
Bohan Jiang
1195255e43 MUL-2771: feat(transcript): server-derived relative work_dir chip (#3428)
* MUL-2771: feat(transcript): server-derived relative work_dir chip

Adds a privacy-safe `relative_work_dir` field to the agent task wire
shape so the transcript dialog can show where a task ran without
leaking the user's home directory. Standard tasks strip the daemon's
workspaces root to `<wsUUID>/<taskShort>/workdir`; local_directory
tasks fall back to the trailing two path segments (`repos/foo`),
which keeps enough context for the user to recognise the directory
without exposing $HOME or the username.

The derivation lives in `taskToResponse` so every endpoint that
serves a task — list, snapshot, claim, rerun, cancel, complete,
fail — fills the field consistently. taskToResponse now also
populates `workspace_id`, which the prior shape declared but never
set. shortTaskID mirrors execenv.shortID; a colocated test pins the
two helpers together so future daemon-side layout changes don't
silently degrade the chip into the local_directory fallback.

Replaces the front-end stripping attempt in PR #3379, which passed
issue_id where workspace_id was required and therefore rendered the
full absolute path on every standard task.

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

* MUL-2771: harden privacy guards on transcript work_dir chip

Address second-round review feedback from PR #3428:

1. Drop the `title={task.work_dir}` tooltip in the transcript dialog.
   The visible chip was safe but native browser tooltips re-rendered the
   absolute `/Users/<name>/...` on hover, leaking into screen shares,
   screenshots, and recordings — defeating the stated goal of the chip.
   The absolute path now never reaches the DOM (no title, aria, or data
   attribute).

2. Replace the "tail two segments" fallback for local_directory paths
   with explicit home-prefix stripping plus a basename-only final
   fallback. The old behaviour leaked the username on shallow paths like
   `/Users/alice/foo`, `/home/alice/project`, and `C:\Users\alice\foo`.
   The new behaviour recognises common per-user home layouts on macOS,
   Linux, and Windows (case-insensitive), strips them down to the
   remainder, and falls back to the basename for any path under an
   unrecognised root — a single segment can never carry the home prefix.

3. Align the Go and TypeScript field comments with the real fallback
   policy so future readers see "strip home / basename" instead of the
   outdated "tail two segments" description.

Tests: expanded `TestRelativeWorkDir` to cover shallow `/Users/...`,
`/home/...`, and `C:\Users\...` paths, the exact-home edge cases,
case-insensitive matching, and the non-home basename-only fallback.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:53:16 +08:00
Bohan Jiang
bae8a84abd MUL-2767 feat(agent): add Antigravity runtime backend (#3427)
* feat(agent): add Antigravity runtime backend

Adds Google's Antigravity CLI (`agy`) as the 12th supported coding-tool
runtime, alongside Claude / Codex / Cursor / Copilot / Gemini / Hermes /
Kimi / Kiro / OpenCode / OpenClaw / Pi.

The CLI emits plain assistant text on stdout (no structured event
stream), so the backend streams stdout line-by-line as `MessageText`
events and accumulates the same text as the final `Result.Output`.
Session resumption uses `--conversation <id>`; because the conversation
UUID is not echoed on stdout, the daemon routes `--log-file` to a temp
file and recovers the id from the glog-formatted log lines.

MUL-2767

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

* fix(agent): correct Antigravity capability contract from Elon review

- ModelSelectionSupported now returns false for antigravity. `agy` has no
  --model flag and antigravityBackend deliberately drops opts.Model, so
  the UI must render a disabled "Managed by runtime" picker instead of
  an empty dropdown plus a silently-ignored manual-entry field. Also
  stop seeding AgentEntry.Model from MULTICA_ANTIGRAVITY_MODEL — the
  backend would silently ignore it.

- Antigravity skills now write to {workDir}/.agents/skills/, the CLI's
  native workspace path (inherits Gemini CLI's layout per
  https://antigravity.google/docs/gcli-migration). Previously they went
  to the .agent_context/skills/ fallback that the CLI doesn't scan.
  Runtime brief moves antigravity into the native-discovery branch and
  local_skills.go points the user-level skill root at
  ~/.gemini/antigravity-cli/skills for Runtime → local skill import.

- Doc + UI comment sync: providers matrix / install-agent-runtime /
  cloud-quickstart / agents-create / tasks (session-resume support) /
  skills / README all now list Antigravity in the right buckets, and
  the model-picker / model-dropdown comments cite antigravity (not the
  stale hermes reference) as the supported=false example.

New tests: TestAntigravityModelSelectionUnsupported,
TestInjectRuntimeConfigAntigravity (native discovery wording),
TestWriteContextFilesAntigravityNativeSkills (.agents/skills/ landing,
.agent_context/skills/ NOT written).

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

* feat(provider-logo): swap inline placeholder for real Antigravity PNG

Replaces the hand-drawn planet+arc placeholder with the official asset
shipped from Downloads. Stored next to the component; bundlers
(Next.js / electron-vite) resolve the PNG import to a URL string at
build time. Added a small assets.d.ts so packages/views' tsc accepts
PNG / SVG module imports — there was no prior asset usage in this
package to register the declaration.

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:40:05 +08:00
Bohan Jiang
d39da9f7f0 MUL-2764: feat(agents): add MCP config tab to agent detail page (#3419)
* MUL-2764: feat(agents): add MCP config tab to agent detail page

Backend already stores `mcp_config` and the daemon forwards it to the
runtime CLI via `--mcp-config`; this only adds the UI entry point.

The new tab presents a JSON editor that pretty-prints the existing
config, validates the buffer on every keystroke, and saves through the
existing `PUT /api/agents/{id}` path. Clearing the editor sends
`mcp_config: null`, which the handler reads as "wipe the column" and
the daemon falls back to the CLI's own default.

When the caller can't see secrets (agent actor, or a non-owner
non-admin member), the server already returns `mcp_config: null` with
`mcp_config_redacted: true`; the tab renders a read-only "configured
but hidden" state in that case so a non-privileged member cannot
silently overwrite an admin-owned config by saving an empty editor.

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

* fix(agents): MCP tab — preserve in-flight edits + warn non-Claude runtimes

- Fix stale-editor sync: compare the local draft against the *previous*
  original via a ref, so a background agent refetch updates an untouched
  editor instead of being silently ignored. Without this, a draft equal to
  the OLD original was treated as user-edited after the prop changed, and
  the next Save would write the old config back over a concurrent admin
  edit.
- Surface a notice inside the tab when the agent's runtime provider is not
  Claude — today's daemon only forwards mcp_config via Claude's
  --mcp-config, so saving on e.g. a Codex agent was silent but ineffective.
- Tests for both: rerender resyncs an untouched editor, rerender preserves
  an in-flight edit, warning renders on non-Claude / hides on Claude.

MUL-2764

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

* MUL-2764: feat(agents): codex MCP support + hide MCP tab on unsupported runtimes

- Backend: codex.go now translates agent.mcp_config (Claude-style
  `{"mcpServers": {...}}`) into `-c mcp_servers.<name>=<inline-toml>`
  flags for `codex app-server`, so MCP servers configured in the UI
  reach Codex's per-task config layer. Bad mcp_config JSON downgrades
  to a warn-and-skip so it can't break the agent launch.
- Frontend: AgentOverviewPane hides the MCP tab when the agent's
  runtime provider doesn't read mcp_config — only `claude` and `codex`
  are supported today, every other provider sees no MCP tab. The
  previous in-tab warning is removed (no longer reachable).
- New shared helper `providerSupportsMcpConfig` lives in
  `@multica/core/agents` so views and any future caller share one list
  of MCP-aware providers.
- Tests: new go-side coverage for stdio + url + multi-server inputs,
  TOML string escaping, malformed-input fallback, and arg ordering vs
  custom_args; new views-side coverage for which providers surface the
  MCP tab. En + zh-Hans copy and parity test refreshed.

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

* MUL-2764: fix(agents): keep codex mcp_config secrets out of argv/logs

Move the agent's mcp_config from a `-c mcp_servers.<id>=<inline-toml>`
argv flag into a daemon-managed `[mcp_servers.*]` block inside the
per-task `$CODEX_HOME/config.toml`. mcp_servers.<id>.env is a documented
Codex config field and the UI already treats mcp_config as redacted for
non-admins; argv would have leaked those values into `ps aux` and the
`agent command` log line. The file is forced to 0600 to keep secrets in
the daemon owner's lane regardless of the seed file's mode.

Also drop user-supplied `-c/--config mcp_servers.*` entries from
custom_args. Codex `-c` is last-wins (verified against codex-cli 0.132.0),
so without filtering, a custom_args entry could silently shadow whatever
the MCP Tab saved.

Strip inherited `[mcp_servers.*]` tables from the per-task config.toml
when the agent has its own mcp_config, mirroring Claude's
`--strict-mcp-config`: avoids TOML "table already exists" errors on
name collisions and matches admin expectations that the MCP Tab is the
authoritative source for that task.

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

* MUL-2764: fix(agents): codex mcp_config three-state semantics + custom_args compat

Address the third review pass:

1. Distinguish nil vs present-but-empty mcp_config. `{}` and
   `{"mcpServers":{}}` now count as "admin saved an explicit (empty)
   managed set" — strip inherited user `[mcp_servers.*]` and pin an
   empty managed marker block. Only SQL NULL / JSON `null` map to
   "absent" and fall back to the user's global `~/.codex/config.toml`.
   This aligns Codex with the API's three-state contract (omit / null
   / object) and with Claude's `--strict-mcp-config` semantics.

2. Fail closed on `ensureCodexMcpConfig` errors and on managed
   mcp_config without CODEX_HOME. Previous warn-and-launch would
   silently inherit the user's global MCP servers and look identical
   to a successful apply — exactly the surprise the MCP Tab is meant
   to remove.

3. Only filter `-c mcp_servers.*` from `custom_args`/`extra_args`
   when the agent has a managed mcp_config. Pre-MUL-2764 agents that
   configured MCP via custom_args keep working; once an admin opts
   in via the MCP Tab the daemon owns the `mcp_servers` namespace
   and overrides are dropped (last-wins safety).

4. Update mcp_config locale intro to mention $CODEX_HOME/config.toml
   instead of the now-removed `-c mcp_servers.*` argv path.

Tests:
- Split `TestEnsureCodexMcpConfigEmptyInputsAreNoop` into
  `TestEnsureCodexMcpConfigAbsentLeavesUserTablesAlone` (nil/null)
  and `TestEnsureCodexMcpConfigEmptyManagedSetStripsUserMcp` (`{}`,
  `{"mcpServers":{}}`).
- Add `TestEnsureCodexMcpConfigEmptyManagedSetIdempotent` to pin
  byte-identical reruns on the empty managed marker block.
- Add `TestHasManagedCodexMcpConfig` covering the eight relevant
  inputs.
- Add `TestBuildCodexArgsPreservesCustomMcpOverridesWhenUnmanaged`
  and `TestBuildCodexArgsDropsCustomMcpOverridesWhenManaged` to
  pin the new gating.
- Add `TestCodexExecuteFailsClosedWhenMcpConfigInvalid` and
  `TestCodexExecuteFailsClosedWhenManagedMcpButNoCodexHome` for the
  Execute paths.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:11:28 +08:00
Multica Eve
202200bc62 feat: publish helm chart to ghcr (#3415)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:04:05 +08:00
QJ Yu
56bddc5e06 fix(issues): place new issues at top of column in manual sort mode
Fixes PER-145.
2026-05-28 14:20:20 +08:00
Bohan Jiang
fe2c990296 docs(self-host): document Microsoft Exchange / SMTP relay modes and failure diagnostics (#3426)
GH#3405 / MUL-2768. Self-host docs already point at the SMTP path, but on-prem operators ran into two gaps:

- The Option B env block in auth-setup and self-host-quickstart only showed a 587 authenticated example, with no copy-pasteable block for the most common Exchange "anonymous internal relay on port 25" pattern, and no explicit mapping between port / auth / TLS / supported-or-not.
- troubleshooting "Emails not received" only covered Resend; SMTP failures (smtp dial / starttls / auth / MAIL FROM / RCPT TO / DATA) surface as wrapped errors in the backend logs, but operators had no doc telling them which Exchange-side fix maps to each.

Adds:

- A relay-mode table (anonymous 25 / authenticated 587 / 465 still unsupported) and two copy-pasteable env blocks in both auth-setup.mdx and self-host-quickstart.mdx (EN + ZH).
- Explicit note on the EmailService startup log line so operators can confirm SMTP is the active provider after restart, without leaking credentials.
- An SMTP failure-mode table in troubleshooting.mdx (EN + ZH) keyed on the exact wrapped error string, with the Exchange-side fix for each.

No code changes; env variable surface unchanged (still SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD / SMTP_TLS_INSECURE). Port 465 stays "not supported" pending #3340.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 13:59:10 +08:00
Bohan Jiang
5732b0dae8 fix(issues): clear deleted ids from recent issues store (#3420)
cleanupDeletedIssueCaches now also calls a new
useRecentIssuesStore.forgetIssue(wsId, issueId) action so the persisted
Recent Issues bucket no longer keeps deleted ids around. Both the delete
mutation and the WS delete event flow through the same cleanup, so this
covers self-delete and cross-client delete. Without this, Cmd+K fires a
detail query for every recent id on open and returns a steady stream of
404s for issues the user has deleted (#3413).

MUL-2765

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 13:06:13 +08:00
yushen
1947830d4b chore(mobile): re-ignore .DS_Store under apps/mobile/data/
The `!data/**` negation that rescues apps/mobile/data/ from the
root .gitignore's `data/` rule was inadvertently pulling .DS_Store
back in too — Finder metadata kept showing up in git status. Restate
.DS_Store after the negation so last-match-wins re-ignores it.
2026-05-28 12:50:50 +08:00
Bohan Jiang
2bda4065d0 MUL-2708: fix(agent): preserve multi-line Pi prompt on Windows by bypassing the .cmd shim (#3417)
Pi is installed on Windows via npm, which lays down `pi.cmd` → `pi.ps1`
→ `node_modules/@mariozechner/pi-coding-agent/dist/cli.js`. The daemon
spawns Pi with `exec.Command("pi", ...)`; PATHEXT resolves that to
`pi.cmd`, and cmd.exe expands `%*` in the shim by re-tokenising the
original command line, which truncates any argv containing newlines.

buildPiArgs passes the full prompt as the last positional argv, so the
multi-line system+user prompt is silently cut at the first newline
before it reaches the JS entrypoint. The session JSONL then records
only the first line ("You are running as a chat assistant for a Multica
workspace.") and Pi replies as if the user message were missing
(GitHub multica-ai/multica#3306).

Mirror the existing cursor-agent fix: when LookPath resolves Pi to a
.cmd/.bat launcher and a sibling pi.ps1 exists, invoke PowerShell with
`-File <ps1>` directly and forward each arg as a discrete token. This
keeps us on the official launch path while skipping the cmd.exe %*
re-expansion. Falls back to the original launcher when pi.ps1 or
PowerShell can't be located.

The Windows test asserts the rewrite produces the expected argv and
that the multi-line positional prompt survives unchanged.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 12:36:16 +08:00
Dmitry Rybalka
7722a98a6a fix(ui): coalesce split task transcript messages (#3097) 2026-05-28 12:34:51 +08:00
DimaS
ccbd62c7ad fix(daemon): ignore gc meta with empty parent ids (#3407)
Co-authored-by: “646826” <“646826@gmail.com”>
2026-05-28 12:33:34 +08:00
DENG
ec5874db96 fix(runtimes): consolidate local machine by device name
Fixes #3333
2026-05-28 11:49:44 +08:00
Bohan Jiang
4864831721 MUL-2744: feat(auth): auto-renew daemon PAT in-place within 7-day window (#3360)
* MUL-2744: feat(auth): auto-renew daemon PAT in-place within 7-day window

Daemons currently hold a 90-day PAT and have no renewal path: once the
token's expires_at passes, every request 401s and the user has to find
the silent failure in the daemon log and re-run `multica login`.

This adds an in-place renewal:

- New `POST /api/tokens/current/renew` (Auth-protected, mul_ only). The
  server checks remaining lifetime: ≥ 7 days is a no-op; < 7 days bumps
  expires_at to now + 90 days via a guarded UPDATE that makes concurrent
  renews idempotent (the WHERE expires_at < $2 clause means only one
  writer wins; the loser sees pgx.ErrNoRows and reports the already-
  extended value). No raw token rotation — the same secret stays in
  every CLI/daemon process sharing the config.

- Daemon-side `tokenRenewalLoop`: fires once on startup (covers
  machine-was-off cases) and then every 3 days. With a 7-day server
  threshold this gives at least two renewal attempts before the window
  closes, so a single network blip can't push the token out.

- 401 fallback: when the renew call comes back 401 (token already
  revoked/expired), the daemon logs a user-actionable WARN telling the
  operator to run `multica login` — instead of the current silent
  failure mode. Loop keeps running so the warning repeats until fixed.

PAT cache (auth.AuthCacheTTL = 10m) doesn't need invalidation: the next
miss after the UPDATE re-reads the row and re-caches with the bumped
TTL automatically.

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

* MUL-2744: fix(auth): renew PAT before first sync; CAS against renewal threshold

Addresses the two issues Elon raised on #3360.

Must-fix: if the PAT is already revoked/expired when the daemon starts,
syncWorkspacesFromAPI 401s and Run returns before the background
tokenRenewalLoop ever fires its initial renewal. The operator only sees
a generic auth failure in the workspace-sync log with no hint that
'multica login' is the fix. Now the startup path runs an inline
tryRenewToken first, surfacing the existing 401 WARN before anything
else gets a chance to fail. Pulled the renew + first-sync pair into
preflightAuth so the ordering invariant is enforced at one site and
tests can exercise the failure modes without spinning up the full Run
setup. Removed the redundant initial tryRenewToken from
tokenRenewalLoop — startup now owns the first call.

Nit: the previous WHERE clause on ExtendPersonalAccessTokenExpiry
(expires_at < $2) did not actually make concurrent renews idempotent
the way the comment claimed. Two callers race-computing
$2 = now + 90d produce strictly-different values, and the second
writer's $2 always exceeds the row the first writer just wrote, so the
UPDATE re-matches and bumps again. Switched to a CAS against the
renewal threshold (expires_at <= $renew_threshold_at, i.e. now + 7d):
once writer A pushes expires_at past the threshold, writer B's UPDATE
matches zero rows and the loser falls back to reporting the
already-extended value as a no-op.

Tests:
- TestPreflightAuth_RenewsBeforeWorkspaceSyncOnExpiredToken locks in
  the call ordering — renew endpoint is hit before workspaces, and the
  re-login WARN appears even though both endpoints 401.
- TestPreflightAuth_SyncProceedsWhenRenewIsNoOp covers steady-state
  startup: a renew=false no-op must still progress to workspace sync.
- TestPreflightAuth_TransientRenewFailureDoesNotBlockStartup covers a
  500 from the renew endpoint — startup must continue, no WARN.
- TestRenewPAT_ParallelRenewExtendsExactlyOnce fires N=8 concurrent
  renews at one row and asserts exactly one returns renewed=true with
  the others reporting the same already-extended expires_at, plus the
  DB carries only that single bumped value.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 22:22:26 +08:00
Multica Eve
be32e5af00 docs(changelog): add v0.3.10 release notes (#3362)
* docs(changelog): add 2026-05-27 release notes

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

* docs: refine v0.3.10 changelog copy

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 17:51:37 +08:00
Bohan Jiang
bed032f937 fix(issues): move local-directory hint out of the comment composer (#3363)
The "Agent will work in-place at …" banner used to render directly above
the comment input, which made it read like an input adornment. Move it
to the top of the Activity section (below the "Activity" heading, above
the live agent card) so it reads as section context instead of composer
chrome. Update the component's JSDoc and tweak the margin (mb-2 → mt-3)
to match the new placement.

MUL-2752
2026-05-27 17:21:46 +08:00
Naiyuan Qing
171ee842d4 fix(editor): preserve raw html-like text on paste (#3355)
* fix(editor): fall back to literal paste when markdown parser drops all content

When pasting text like `<T>` or `<MyComponent>`, the CommonMark-compliant
markdown parser treats them as inline HTML tags. ProseMirror's schema doesn't
recognize unknown HTML elements, so they are silently dropped — producing an
empty document from non-empty input.

Detect this case (non-empty input → empty parse result) and fall back to
literal text insertion so the user sees their text instead of nothing.

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

* fix(editor): escape non-standard HTML tags in paste to prevent content loss

When pasting mixed content containing multiple <tag> patterns (e.g.
"<t>\n裸 `<tag>` 做转\n<tag>\n<t>"), CommonMark treats bare <word>
as inline HTML. ProseMirror silently drops unknown HTML elements,
causing partial content loss. The previous empty-result fallback only
caught the single-tag case where the entire parse result was empty.

Pre-process paste text before markdown parsing: escape <tag> patterns
whose tag name is not a standard HTML element, while respecting inline
code spans and fenced code blocks. Standard HTML (div, br, img, etc.)
passes through normally.

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

* fix(editor): preserve raw html-like text on paste

* fix(editor): prefer rich html paste when semantic

* fix(editor): avoid native paste when html drops raw tags

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 17:14:10 +08:00
Naiyuan Qing
85daf7818a fix(editor): render code blocks when lowlight highlightAuto returns empty tree (#3358)
lowlight.highlightAuto() returns a Root with zero children (relevance 0)
for content it cannot classify into any language. toHtml() of that tree
produces an empty string, so dangerouslySetInnerHTML rendered a blank
<code> element — the <pre> background was visible but the text was gone.

Fall through to plain text render when toHtml produces nothing.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 16:57:38 +08:00
Kagura
f02bc56e70 fix(agent/cursor): remove obsolete 'chat' subcommand from argv (#3077) (#3092)
The current cursor-agent CLI no longer has a 'chat' subcommand. The
positional 'chat' argument was silently treated as prompt text, leaking
into the user message (e.g. 'chat <actual prompt>').

Remove 'chat' from buildCursorArgs so the generated argv matches the
current cursor-agent CLI interface.

Fixes #3077
2026-05-27 16:40:29 +08:00
Anderson Shindy Oki
bdb60acae9 fix: swimlane empty lanes in due to pagination (MUL-2724) (#3326)
* fix: Swimlane lazy load issues

* wip

* refactor

* fix: Rebase issues

* fix: rerender

* refactor bactch and chunking
2026-05-27 16:28:15 +08:00
Bohan Jiang
d16ed2806a MUL-2748 docs(autopilots): document webhook event filters + link from UI (#3359)
* MUL-2748 docs(autopilots): document webhook event filters and link from UI

Follow-up to PR #3231. The webhook event filters feature shipped
without user-facing docs and the UI section gave no hint about how
event/action are derived from inbound requests.

- Add an "Event filters" subsection to autopilots.mdx and .zh.mdx
  under the existing "Trigger from a webhook" section: what the
  filter does (with the `event_filtered` outcome), where the event
  name and action come from (body envelope / headers / body
  fallbacks), examples, a non-string-action gotcha, and a curl
  recipe verifying both the allowed and filtered paths.
- Add a small ExternalLink icon next to the "Event filters" label
  in WebhookEventFilterSection that opens the docs section in a new
  tab. Locale-aware: zh users land on the Chinese page anchor, en
  users on the English one.

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

* docs(autopilots): expand "where event name and action come from" with curl examples

Add concrete curl request/inference pairs under each derivation step
(body envelope, headers, body fallback, default) and a "common gotcha"
explaining why filter `event=trigger, action=true` does not match
`{"trigger": true}`. Mirrors the explanation that resolved the
on-call confusion about Event filter semantics.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 16:07:20 +08:00
Alex
746c0c4456 MUL-2746 fix(avatar): normalize relative avatar urls in desktop/web (#3100)
* fix(avatar): normalize relative avatar urls in desktop/web

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

* fix: test

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

* fix(avatar): normalize avatar url in AvatarPicker preview

MUL-2746. The picker is used by create-agent and create-squad, and also
prefills from a template's `avatar_url` when duplicating an agent. The
upload result / template URL is root-relative in local-storage setups,
so on Desktop (file:// runtime) the preview <img> resolves against the
local filesystem and the avatar fails to render. Route the value through
`resolvePublicFileUrl` for rendering only; the stored URL stays raw so
the parent's create call still posts what the backend expects.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J (Multica agent) <agents@multica.ai>
2026-05-27 16:02:08 +08:00
Raúl Anatol
2b5696703f MUL-2703: feat(autopilots): webhook event filters per trigger (MUL-2334 follow-up) (#3231)
* feat(autopilots): webhook event filters per trigger (MUL-2334 follow-up)

Adds schema-backed event/action filtering to webhook triggers so operators
can declare exactly which GitHub (or generic) events should spawn autopilot
runs. Events outside the declared scope are recorded as ignored with reason
'event_filtered' — visible in the delivery log but without expensive run/task
creation.

Closes #3093 (supersedes the description-parsing approach from that PR).

Backend:
- Migration 108 adds event_filters JSONB to autopilot_trigger
- sqlc queries updated for CREATE / UPDATE / LIST / GET
- HandleAutopilotWebhook filters against trigger.event_filters before dispatch
- Create/Update trigger handlers accept event_filters in the request body
- Response shape includes event_filters so the UI can render it

Frontend:
- New WebhookEventFilterSection component in the autopilot dialog
- Inputs for event name + comma-separated actions
- i18n strings added (en + zh-Hans)

Tests:
- Unit tests for splitWebhookEvent and webhookEventAllowedByTriggerScope
- Handler-level integration tests for filtered / allowed / no-filter paths

co-authored-by: ZephaniaCN <agent/autopilot-webhook-filter>

* fix: recognize gitlab/bitbucket/gitea as providers in splitWebhookEvent

TestSplitWebhookEvent failed because only 'github' was recognized as a
provider prefix. Extract isKnownProvider() to handle gitlab, bitbucket,
and gitea as well.

* fix(autopilots): address PR #3231 review for webhook event filters

Must-fix from PR #3231 review:

1. event_filters now uses typed []WebhookEventFilter at the HTTP boundary
   instead of []byte. encoding/json was base64-encoding the field on the
   way out, so the UI could not .map() the response, and a real JSON
   array on the way in failed to decode. Response field also decodes the
   stored JSONB into a typed slice before serialising back.

2. UpdateAutopilotTriggerRequest.EventFilters is *[]WebhookEventFilter
   with tri-state PATCH semantics: nil pointer = leave alone, [] =
   clear, [...] = replace. The handler marshals an explicit empty slice
   to the JSONB literal `[]` so COALESCE overwrites instead of preserves.
   AutopilotDialog now PATCHes the webhook trigger when event_filters
   change in edit mode (previously the toast said "updated" while the
   backend was unchanged).

3. webhookEventAllowedByTriggerScope no longer short-circuits to false
   on the first event-name match whose actions don't line up. Earlier
   code silently shadowed any later filter that shared the same event
   name with disjoint actions.

Robustness: validateWebhookEventFilters rejects empty event names /
actions at write time, and the matcher fails closed on malformed stored
bytes instead of widening the allowlist.

Tests: handler tests now post real JSON arrays (the prior []byte path
masked the contract bug). Adds round-trip / clear-with-[] / preserve-
when-omitted / replace / invalid-filter / filters-on-schedule coverage,
plus matcher tests for same-event multi-filter and malformed-deny.

Migration renamed 108 → 110 to avoid colliding with main's
108_task_token (came in via the merge from main).
2026-05-27 15:47:36 +08:00
Bohan Jiang
e3723dbb22 refactor(autopilot): centralize timezone default and cover invalid-timezone fallback (MUL-2742) (#3356)
Follow-up nits from PR #3324 review:

- Export DefaultAutopilotTriggerTimezone so the autopilot scheduler reuses
  the same source-of-truth as the service layer instead of hardcoding "UTC"
  in two places.
- Add tests that lock down the invalid-timezone fallback (e.g. "Foo/Bar")
  for both buildIssueDescription and interpolateTemplate, so a future change
  to the resolve/format helpers can't silently emit a half-formatted
  timestamp or date.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 15:40:05 +08:00
YOMXXX
607e64d722 fix(autopilot): render trigger output in trigger timezone (#3324) 2026-05-27 15:26:49 +08:00
Naiyuan Qing
bd1fb10afa chore: react-doctor cleanup — button types, useContext→use(), toSorted, error fixes (#3350)
- Add explicit type="button" to 61 <button> elements missing the attribute
- Replace useContext() with React 19 use() across 16 context consumers
- Replace [...arr].sort() with arr.toSorted() in 12 web/desktop files
  (mobile excluded — Hermes lacks toSorted support)
- Fix rules-of-hooks violation: useSidebar try/catch → useSidebarSafe null check
- Fix nested component definition: useMemo wrapping HeaderRight → useCallback
- Fix missing ARIA: add aria-expanded + aria-controls to combobox in create-squad

React Doctor score: 23 → 30. No behavioral changes, no business logic modified.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:57:07 +08:00
LinYushen
c968c13c87 feat(auth): support mcn_ Cloud Node PATs verified via Fleet (#3349)
* feat(auth): support mcn_ Cloud Node PATs verified via Fleet

Adds a new token kind, mcn_ (multica cloud node), recognized in both
the regular Auth and DaemonAuth middlewares. mcn_ tokens are minted
and owned by Multica Cloud (not the local personal_access_tokens
table); the server validates them by POSTing to the Fleet's
/api/v1/pat/verify endpoint and uses the returned owner_id as
X-User-ID for downstream handlers.

Cloud is the authoritative owner of token status, so this is a
verifier-only path with no DB fallback:

  * Fleet says valid:false -> 401 (token genuinely bad)
  * Fleet unreachable / 5xx -> 503 (transient, retry)
  * No MULTICA_CLOUD_FLEET_URL configured -> 401 (fail closed)

Verification results are cached in Redis for 60s under
mul:auth:mcn:<sha256> to bound the per-request load on Fleet without
extending the revocation window beyond what the Cloud doc allows.
Negative results are NOT cached, so a freshly minted token doesn't
get locked out by a stale 'token_not_found'.

Reuses MULTICA_CLOUD_FLEET_URL (the same env the cloud-runtime proxy
already uses) so deployments don't need a second config knob.

Tests cover the happy path, every documented invalid reason, 4xx/5xx
mapping, network error, decode error, ctx cancellation, the
fail-closed valid:true-without-owner_id case, trailing-slash URL
normalization, and the Redis cache short-circuit + negative
no-cache contract. Middleware tests pin the four 401/503/200 outcomes
in both Auth and DaemonAuth.

* auth(mcn): require owner_id to map to a real local user; drop X-User-PAT plumbing

Two related changes:

1. Cloud-verified owner_id is now checked against our local users table.
   The Cloud owner_id and our users.id share the same UUID space by
   contract; a missing local user means either the row was deleted
   under an active node or something is forging owner_ids — either
   way, fail closed.

   CloudPATVerifier.Verify takes a new OwnerLookupFunc:
     - returns (true, nil)   -> success, cache + return
     - returns (false, nil)  -> ErrCloudPATInvalid (reason='owner_unknown'),
                                NOT cached (so a freshly-created user
                                doesn't get locked out for a TTL window)
     - returns (_, error)    -> ErrCloudPATUnavailable (transient,
                                middleware emits 503)

   Both Auth and DaemonAuth wire ownerLookupFor(queries), a new shared
   helper that wraps queries.GetUser, mapping pgx.ErrNoRows / unparseable
   UUIDs to (false, nil) and other errors to a real Go error.

2. Removed all X-User-PAT plumbing. Cloud now mints node-scoped mcn_
   PATs itself during /api/v1/nodes (see multica-cloud
   docs/api/node-pat.md) and ships them into the EC2 instance via SSM,
   so multica-api no longer needs to forward the caller's mul_ PAT.
   Propagating a long-lived user PAT into a remote machine widened
   the blast radius of any node compromise; that's gone now.

   Removed:
     - cloud_runtime.go: withUserPAT option, cloudRuntimeUserPAT,
       generateCloudRuntimePAT, revokeGeneratedPAT
     - cloudruntime/Request.UserPAT field + X-User-PAT header
     - X-User-PAT from CORS allowed headers
     - obsolete handler tests:
         TestCreateCloudRuntimeNodeForwardsValidatedPAT
         TestCreateCloudRuntimeNodeRejectsUnownedPAT
         TestCreateCloudRuntimeNodeRejectsExpiredPAT
         TestCreateCloudRuntimeNodeAutoGeneratesPAT
       replaced with TestCreateCloudRuntimeNodeForwardsBody
     - X-User-PAT references in packages/core/api/client.test.ts

Tests:
  * 3 new verifier-level tests (owner_unknown not cached, lookup error
    -> Unavailable, success path is cached for both fleet AND lookup)
  * 5 new owner_lookup_test.go tests (nil queries, existing user,
    missing user, malformed UUID, DB error)
  * 1 new end-to-end DaemonAuth test (cloud says valid, no local user
    -> 401)
  * Existing X-User-PAT TS assertions removed; full vitest run passes.
  * go test ./... and go vet ./... clean on the server module.
2026-05-27 14:52:03 +08:00
Bohan Jiang
963c33f030 MUL-2618 docs(project-resources): document local_directory resource type (#3347)
* docs(project-resources): document local_directory resource type

Add user-facing guidance for the local_directory project resource introduced
in #3283 — when to pick it over github_repo, the Desktop / CLI attach flow,
path validation rules, the daemon-scoped one-per-(project, daemon) limit,
serial task execution + waiting_local_directory status, what the daemon will
and won't touch in the user's folder, and the v1 limits to call out
(no auto branch switch / commit / PR; dirty tree carried through).

Also ship the missing Chinese counterpart of project-resources and wire it
into meta.zh.json.

MUL-2618

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

* docs(project-resources): cover write-conflict trade-off + mixed-resource behavior

Expand the local_directory docs in response to review feedback:

- Restate "when to pick local_directory" as two distinct use cases (clone
  cost; fine-grained changes needing frequent local review) instead of a
  one-liner, and make the trade-off explicit: v1 ships no file-level write
  lock, so the per-directory serial gate is the only protection against
  cross-issue agents touching the same files.
- Add a new "Mixing resource types, and multiple local_directory resources"
  section that answers: github_repo + local_directory on the same project
  (local takes precedence on the bound daemon, github_repo falls back
  everywhere else), and two local_directory resources (only possible
  across two daemons, routed by the agent's runtime assignment, no
  load-balancing).

Mirrored into the Chinese translation. typecheck + tests still pass.

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

* docs(project-resources): tighten local_directory wording per review

- Soften "only the bound daemon can take tasks" to "only the bound
  daemon uses this local directory" (zh) — aligns with the
  mixed-resource fallback section where other daemons still run.
- Clarify that local_directory does not create/use a github_repo
  worktree for that task (en + zh); the per-workspace repo cache may
  still sync as a background behaviour.
- Match implementation for the Desktop "Add local directory" button:
  it stays visible but is disabled with a hint when daemon is offline
  or the per-daemon limit is reached; only the web app hides it
  outright (en + zh).

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 14:33:11 +08:00
Naiyuan Qing
31b58494cf feat(comments): align UpdateComment post-processing with CreateComment (#3337)
* feat(comments): align UpdateComment post-processing with CreateComment (#2965 follow-up)

Part 1 — PR #2965 code review follow-ups:
- Fix sqlc Column3 naming → AttachmentIds via sqlc.arg(attachment_ids)
- Return 500 on ReplaceCommentAttachments failure instead of logging + 200
- Remove optional marker from onEdit attachmentIds (always passed)
- Add optimistic update for attachments in useUpdateComment
- Extract useEditAttachmentState hook from CommentRow/CommentCardImpl
- Add integration tests for attachment replacement scenarios

Part 2 — Edit-comment logic alignment:
- Add ExpandIssueIdentifiers to UpdateComment (bare identifiers now expand)
- Add handleEditMentionDiff: diff old vs new agent/squad mentions on edit,
  cancel tasks for removed mentions, enqueue tasks for added mentions,
  cancel + re-trigger when content changes but mentions are unchanged

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

* fix(sqlc): regenerate with v1.31.1 + add mention diff integration tests

Fixes sqlc version downgrade (v1.31.1 → v1.30.0) that was introduced
when the original PR was authored with a local v1.30.0 binary.
Regenerated all sqlc output with v1.31.1 to match main.

Adds integration tests for handleEditMentionDiff covering: edit adds
mention → task enqueued, edit removes mention → task cancelled, edit
changes content with same mentions → cancel + re-trigger.

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

* refactor(comments): simplify edit post-processing to cancel-all + re-trigger

Replace handleEditMentionDiff (120-line mention diff) with a simpler
model: when content changes, cancel all tasks triggered by this comment,
then re-run the same three trigger paths as CreateComment (assignee,
squad leader, mentions). Fixes gap where assignee/squad-leader tasks
were not cancelled or re-triggered on edit.

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

* refactor(comments): extract triggerTasksForComment to unify Create/Edit trigger paths

Create and Edit duplicated the same three trigger paths (assignee,
squad leader, mentioned agents). A fourth path would need changes
in two places. Extract into a shared function so the composition is:
  Create: trigger() + unresolve()
  Edit:   cancel()  + trigger()
  Delete: cancel()

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 14:30:41 +08:00
Bohan Jiang
0dcc35edc8 ci: split mobile lint/typecheck out of frontend job (#3346)
* ci: split mobile lint/typecheck out of frontend job

Mobile lint (~38s) + typecheck (~13s) ran on every web/desktop PR even
though mobile has no vitest suite and main CLAUDE.md already promises a
parallel mobile-verify workflow. Excluding @multica/mobile from the
frontend turbo filter pulls those 50s off the critical path, and the new
mobile-verify.yml runs them in parallel only when apps/mobile/** or
packages/core/types/** changes.

MUL-2729

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

* ci(mobile-verify): broaden path filter to cover real mobile deps

The initial filter only watched `apps/mobile/**` and
`packages/core/types/**`, but mobile imports runtime modules from many
more `@multica/core/*` paths (agents, markdown, permissions,
api/schemas, etc.). PRs that touched only those subtrees would skip
main CI (via `--filter='!@multica/mobile'`) AND skip Mobile Verify — a
coverage regression vs. the pre-split CI.

Expand paths to:
- `packages/core/**` (covers every importable subpath)
- root install/turbo configs that affect mobile build:
  `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 14:22:26 +08:00
Bohan Jiang
17714c3ad1 fix(create-issue): preserve parent_issue_id through Create with agent flow (MUL-2534) (#3083)
* fix(create-issue): preserve parent_issue_id through Create with agent flow (MUL-2534)

When the create-issue modal was opened from the "Add sub issue" entry on
an existing issue and the user switched to "Create with agent", the
parent_issue_id was silently dropped: switchToAgent only forwarded
prompt + actor + project_id, the AgentCreatePanel had no notion of
parent context, and the daemon prompt never instructed the agent to
pass --parent <uuid>. The sub-issue intent was lost and the new issue
landed as a standalone.

This fix threads parent_issue_id through the whole pipeline silently —
no new editable form field, the existing carry channel handles it:

- Frontend: ManualCreatePanel.switchToAgent + AgentCreatePanel.switchToManual
  now carry parent_issue_id (and identifier, for display) so the sub-issue
  intent survives mode flips in either direction. AgentCreatePanel reads
  parent from `data`, forwards to api.quickCreateIssue, and renders a
  read-only "Sub-issue of MUL-XX" chip so the user can see the relationship.
- API: quickCreateIssue accepts optional parent_issue_id.
- Backend: QuickCreateIssueRequest validates parent_issue_id belongs to the
  same workspace (same path as CreateIssue), persists it in
  QuickCreateContext, and the daemon claim handler resolves the parent's
  identifier for prompt context.
- Daemon prompt: when ParentIssueID is set, buildQuickCreatePrompt instructs
  the agent to pass `--parent <uuid>` and treat the modal entry point as
  authoritative.

Tests cover all three hops: switchToAgent carry payload, AgentCreatePanel →
api.quickCreateIssue, and the daemon prompt's --parent injection (with both
identifier-present and UUID-only fallback branches).

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

* test(create-issue): cover quick-create parent trust boundary + identifier fallback (MUL-2534)

Address review on PR #3083:

- Add server-side test for POST /api/issues/quick-create parent_issue_id:
  same-workspace parent threads through QuickCreateContext.ParentIssueID,
  foreign-workspace and bogus UUIDs return 400 and never enqueue a task.
- Fall back to `data.parent_issue_identifier` in ManualCreatePanel's
  switchToAgent when the parent detail query hasn't hydrated yet, so the
  agent chip never renders "Sub-issue of " with an empty tail.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 14:18:48 +08:00
Jiang Bohan
d15da372a9 chore: gitignore agent runtime caches (.agent_context/, .multica/) 2026-05-27 14:02:38 +08:00
Jiang Bohan
329fc0139d fix(create-project): anchor Source popover to top so it doesn't get pushed below the trigger when the modal is expanded 2026-05-27 13:51:20 +08:00
Bohan Jiang
341ce7bfa5 feat: support local working directory for projects (MUL-2618 v1) (#3283)
* feat(project): add local_directory project_resource type (MUL-2662)

Adds a second project_resource type alongside github_repo so a project
can be pinned to an existing directory on a specific daemon (the v1 of
the local-working-directory flow tracked in MUL-2618). The ref schema is
{ local_path, daemon_id, label? }; local_path must be absolute and
daemon_id is required. The same (daemon_id, local_path) pair is allowed
on multiple projects by design — no UNIQUE constraint is added.

Implementation reuses the existing project_resource API surface: the new
type is wired through the validator switch with no migration, no new
events, and no daemon-handler changes (daemon already passes through
arbitrary resource types via ProjectResources). The CLI gains
--local-path / --daemon-id / --ref-label shortcuts so
`multica project resource add --type local_directory` mirrors the
existing `--type github_repo --url ...` ergonomics; the generic --ref
flag still works for both types.

Tests cover the full CRUD lifecycle, the same-path-across-projects
allowance, the same-path-same-project conflict, the validator rejections
(missing/blank/relative path, missing daemon_id, wrong payload type),
and the cross-platform isAbsoluteLocalPath helper.

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

* feat(project): add update endpoint + label-shadow guard for project_resource (MUL-2662)

Addresses the Elon review on PR #3263:

- Add PUT /api/projects/{id}/resources/{resourceId} with sqlc query,
  matching handler, CLI `project resource update`, and a new
  EventProjectResourceUpdated WS event. resource_type stays immutable;
  ref/label/position are all individually optional.
- Catch same-project (daemon_id, local_path) collisions where only the
  embedded label differs — the row-level UNIQUE only matches the full
  ref JSON, so a label typo would otherwise let the same working
  directory bind twice.
- Tests cover the update lifecycle (label-only / ref / clear / 404 /
  invalid path) and the label-shadow conflict on both create and
  update; the in-place rename still succeeds because the conflict
  scan ignores the row being edited.

Incidental: regenerating sqlc picked up a missing skills_local scan in
UpdateAgentCustomEnv that drifted in from #3200.

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

* fix(project): close bundled-create label-shadow gap + merge resource_ref on CLI update (MUL-2662)

Two follow-ups from MUL-2662 review round 2:

- CreateProject inline resources path now dedupes local_directory entries on
  (daemon_id, local_path) before opening the transaction. The DB-level
  UNIQUE(project_id, resource_type, resource_ref) constraint only fires on a
  full JSON match, so two rows with the same target but different `label`
  would otherwise slip past. Standalone POST/PUT already cover this via
  findLocalDirectoryConflict; bundled create was the missing surface.
- `multica project resource update` now seeds resource_ref from the existing
  row before applying per-type shortcut flags, so `--default-branch-hint x`
  on its own no longer constructs a payload missing `url` (which the server
  400s on). Local_directory partial edits get the same merge behavior.

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

* feat(desktop): local_directory project_resource UI (MUL-2665) (#3273)

* feat(desktop): local_directory project_resource UI (MUL-2665)

First UI surface for the local-working-directory flow tracked in MUL-2618.
Lets users on the desktop pin a project to an existing folder on this
machine; web stays read-only since the per-daemon check can't be done in
the browser.

What's new for the renderer:

- ProjectResourcesSection grows a desktop-only "Add local directory"
  button next to the existing GitHub-repo popover. Clicking it opens
  Electron's native folder picker, validates the path through a new
  IPC pair (existence + r/w), and submits a project_resource of
  resource_type=local_directory with daemon_id pulled live from
  daemonAPI.getStatus.
- LocalDirectoryRow renders the rename pencil + path tooltip, and
  greys out when ref.daemon_id != this machine's daemon_id (with a
  "only available on the machine that registered this directory"
  tooltip). Delete stays enabled so users can drop stale registrations
  from any device.
- LocalDirectoryHint sits above the issue-detail comment composer and
  shows "Agent will work in-place at {label} ({path})" when the issue's
  project has a local_directory matching this daemon. Hidden on web.
- TaskStatusPill picks up a new "waiting_for_directory_release" stage
  that the daemon will publish when it dequeues a task but can't
  acquire the path lock. The render is in place now so the daemon
  sibling subtask can wire the status string without an additional UI
  PR.

Plumbing:

- @multica/core/types gains LocalDirectoryResourceRef +
  UpdateProjectResourceRequest, and the api client gets the matching
  PUT method backed by the server endpoint that landed in
  2ac3faebb (MUL-2662). A useUpdateProjectResource hook drives the
  in-place label edit.
- New Electron handlers under apps/desktop/src/main/local-directory.ts:
    local-directory:pick     -> dialog.showOpenDialog (openDirectory)
    local-directory:validate -> stat + access(R_OK + W_OK)
  exposed through the preload as desktopAPI.pickDirectory /
  validateLocalDirectory. View code talks to them via a thin
  packages/views/platform helper that returns reason=unsupported on
  web instead of crashing.
- useLocalDaemonStatus exposes the local daemon's id, device name, and
  running flag from daemonAPI.onStatusChange so the renderer can do the
  cross-device match without coupling to the desktop preload typings.

Tests:

- pickStageKeys gets a unit test covering the new stage and proving
  the directory-release status outranks availability hints.
- LocalDirectoryHint tests cover the four render branches (no project,
  no daemon, foreign daemon, matching daemon).
- i18n parity stays green; new keys added under projects.resources.*
  and chat.status_pill.stages.waiting_for_directory_release in both
  locales.

Out of scope (will land separately):
- The daemon-side waiting/lock signal that flips the pill into the
  new state.
- Adding local_directory to the create-project modal's bulk
  attach flow.
- Docs page refresh for project-resources.mdx — left for the
  MUL-2618 umbrella sweep.

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

* fix(desktop): hide rename for foreign daemon local_directory rows (MUL-2618)

Address review nit on #3273: the rename pencil was gated only by
`canEdit`, so a foreign / unknown-daemon row still showed it even
though the spec says cross-device rows are disabled. Gate rename on
`!mismatch` so it disappears on those rows; delete stays available
so a stale registration can still be dropped from any device.

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

---------

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

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663) (#3274)

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663)

Wires up the daemon side of the local_directory project_resource introduced
in MUL-2662. When a task is dispatched against a project whose resources
include a local_directory pinned to this daemon's UUID, the daemon now:

  - Validates the path (absolute, exists, daemon process can read+write,
    not in the system-root / $HOME blacklist) and fails the task fast on
    any precondition violation, with a user-readable reason.
  - Serialises concurrent tasks on the same on-disk path via a
    daemon-local LocalPathLocker keyed by symlink-resolved realpath. The
    lock is held for the entire task lifetime (claim → context write →
    agent → result report).
  - When the lock is contended, the daemon flips the row to a new
    waiting_local_directory status on the server (carrying a wait_reason
    like "<path> (held by task <short id>)") so the UI can render
    "等待本地目录释放" instead of leaving the row silently in dispatched
    past the sweeper timeout. The status accepts being woken into running
    once the lock is acquired.
  - Sets execenv.WorkDir to the user's path (no copy, no mount). envRoot
    still lives under workspacesRoot/<wsID>/ and hosts output/, logs/, and
    .gc_meta.json — the daemon's logbook for the run.
  - Stamps GCMeta.LocalDirectory=true so the GC loop never RemoveAlls
    envRoot for these tasks (gcActionClean → gcActionCleanArtifacts,
    gcActionOrphan → gcActionSkip). The user's directory was never under
    envRoot to begin with, so this is defense in depth.
  - Skips execenv.Reuse for local_directory tasks because the prior
    WorkDir is the user's path and reusing it through that code path
    loses the envRoot association the GC loop needs. Prepare is cheap
    here (no clone, no copy), so always running it is fine.

Server-side protocol changes:

  - New CHECK value 'waiting_local_directory' on agent_task_queue.status
    plus a wait_reason TEXT column (migration 109).
  - All cancel / active / counted-as-running / orphan-recovery queries
    expanded to include the new status; FailStaleTasks intentionally
    excludes it (the daemon owns the wait).
  - New SQL MarkAgentTaskWaitingLocalDirectory(id, reason) and a relaxed
    StartAgentTask that accepts both dispatched and
    waiting_local_directory as preconditions (and clears wait_reason on
    the way through).
  - New POST /api/daemon/tasks/{taskId}/wait-local-directory endpoint,
    TaskService.MarkTaskWaitingLocalDirectory broadcaster, and matching
    daemon Client.MarkTaskWaitingLocalDirectory.

Tests cover: path blacklist + R/W enforcement, mutex serialisation +
ctx-cancelled wait, lock handover between two tasks, GC never returns
gcActionClean / gcActionOrphan for local_directory rows (with negative
control for the standard path), and Prepare/Cleanup correctly substitute
+ protect the user's WorkDir.

The desktop UI side (UI for adding a local_directory resource, surfacing
the "等待本地目录" badge) is MUL-2665; the agent-task lifecycle changes
(no branch switch, dirty-tree tolerant, auto-commit) are MUL-2664.

This PR targets the shared MUL-2618 v1 feature branch agent/j/912b8cb1,
not main; the whole v1 will be merged to main together when complete.

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

* fix(daemon): tighten local_directory status, symlink, cancel handling (MUL-2618)

Address the 3 must-fix items from Elon's review of PR #3274.

1. Status string unified. The server / daemon publish
   `waiting_local_directory`; align views, locales, and the
   pickStageKeys test (PR #3273 had used `waiting_for_directory_release`
   on a placeholder string). Without this, the daemon's wait state
   never reached the pill once the two siblings merged.

2. validateLocalPath now also runs the blacklist against the
   symlink-resolved realpath, with macOS's `/etc` -> `/private/etc`
   redirect handled via `isBlacklistedRealPath` which compares
   canonical forms. Without this, a symlink such as
   `/Users/me/proj/home -> /Users/me` slipped the literal $HOME check
   while every daemon write still landed in the user's home. Tests
   cover symlink-to-home, symlink-to-system-root, and the negative
   case (symlink to a regular subdirectory).

3. acquireLocalDirectoryLockIfNeeded now spins up a cancellation
   watcher inside `onWait` (lazy — the fast path stays free) so the
   gap between dispatch and StartTask responds to server-side cancel
   or row deletion. If the watcher fires while the daemon is parked
   on the path mutex, the lock-wait context is cancelled, Acquire
   returns promptly, and the helper exits silently the same way the
   run-phase poller does. New TestAcquireLocalDirectoryLock_CancelDuringWait
   exercises the path end-to-end with a fake server.

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

* fix(daemon): unconditional canonical blacklist + Windows drive-root generalisation (MUL-2618)

- validateLocalPath now always runs isBlacklistedRealPath on the
  symlink-resolved path, not only when it differs from absPath. The old
  guard let users type the canonical form of an OS-symlinked banned root
  (e.g. /private/tmp, /private/etc, /private/var on macOS) straight
  through, since EvalSymlinks is a no-op on already-canonical input.
- Windows drive-root rejection moved off the static C/D/E/F enumeration
  onto filepath.VolumeName via a new isDriveRoot helper, so removable /
  network drives mounted at G:..Z: and UNC \\server\share roots are also
  blocked. systemRootBlacklist keeps the well-known C:\ trees only.
- Tests: macOS-only case exercises direct /private/{tmp,etc,var}; a
  new TestIsDriveRoot covers the Windows generalisation (skipped on
  POSIX runners by runtime guard).

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

---------

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

* feat(views): wire waiting_local_directory end-to-end in issue UI + presence (MUL-2618)

Connect the daemon-emitted `task:waiting_local_directory` and `task:running`
events through to issue execution log, sticky agent banner, activity indicator,
and agent presence so a parked task is no longer invisible on the issue page.

- Add `waiting_local_directory` to `AgentTask.status` and the typed
  `task:running` / `task:waiting_local_directory` WS event payloads.
- Chat realtime sync writes both new statuses into the pending-task cache so
  the chat StatusPill flips out of a stale `dispatched` frame.
- ExecutionLogSection: count `waiting_local_directory` as active, add tone +
  status label, treat parked tasks the same as dispatched for time anchor /
  transcript visibility / terminate-confirm note.
- AgentLiveCard: subscribe to both new events, rank the parked state between
  dispatched and queued, and surface a "is waiting for the local directory"
  banner with the muted "Clock" treatment used for queued.
- IssueAgentActivityIndicator: route parked tasks into the queued bucket so
  the hover stack and chip stay visible.
- derive-presence: parked tasks count toward `queuedCount` so the agent
  workload chip stays out of `idle` while the daemon waits on the path lock.
- Locales: add `agent_live.is_waiting_local_directory` and
  `execution_log.status_waiting_local_directory` (en + zh-Hans).

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

* feat(project): enforce one local_directory per (project, daemon) (MUL-2618)

The daemon-side resolver picks the first matching local_directory by
daemon_id, so allowing two rows on the same daemon — even at different
paths — let the agent silently write into whichever sorted first. Tighten
the invariant top to bottom:

- server: `findLocalDirectoryConflict` rejects any second row sharing a
  daemon_id, regardless of `local_path` or label. Bundled-create surface in
  `CreateProject` runs the same daemon-scoped dedupe up front.
- daemon: `findLocalDirectoryAssignment` fails fast when it finds more than
  one row pinned to the current daemon (older API client / direct DB
  writes can still produce that state — refuse to guess).
- desktop UI: hide the "Add local directory" action once the current
  daemon owns a row on this project, with a hint and a defensive toast on
  the call path; foreign-daemon rows stay visible read-only as before.
- Tests:
  * daemon: new `two local_directory rows on this daemon fail fast` /
    `local_directory rows on different daemons coexist` cases.
  * handler: rewrite the legacy `LabelShadow` cases as
    `DaemonScopedConflict` / `BundledLocalDirectoryDaemonConflict` —
    asserts 409 on same-daemon different-path, 201 on per-daemon bundles.
- Locales: en + zh-Hans copy for the new hint + toast.

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

* chore(sqlc): drop stale skills_local in UpdateAgentCustomEnv (MUL-2618)

Follow-up to the main-merge in 0f8e8ca7: the auto-merge preserved most
of main's skills_local revert but kept the column reference inside the
UpdateAgentCustomEnv scanner because that block hadn't been touched by
either side. Re-running `sqlc generate` regenerates the file without
skills_local in this query, matching the rest of the file and the
post-revert schema.

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

* feat(create-project): binary source picker — repos OR local directory

Turn the create-project dialog's "Repos" pill into a binary Source
picker. A project's source is mutually exclusive: either a set of
GitHub repos (worktree mode, default) or a single local working
directory (local mode, desktop-only). Mirrors the constraint the
backend will enforce next.

Behavior:
- Pill shows the active mode's selection (GitHub icon + repo count, or
  folder icon + local label/path).
- Popover has a 2-tab segmented control at the top; the Local tab is
  hidden entirely on web (local_directory needs a daemon_id).
- Local tab requires the daemon online — amber notice + disabled picker
  when offline, re-renders automatically via useLocalDaemonStatus.
- Switching tabs preserves the other side's stash, but handleSubmit
  only emits the resource matching the active sourceMode, so abandoned
  picks never leak into the created project.

Backend mutual-exclusion validation + the resources-section
conditional-add-button still to come — this PR just unblocks the
dialog so it can be demoed.

* fix(mobile): cover waiting_local_directory in run row status maps (MUL-2618)

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica J <j@multica.ai>
2026-05-27 13:44:31 +08:00
Naiyuan Qing
2af2f7b3e4 fix(comments): defer input clearing until submit succeeds (#3344)
clearContent() and setIsEmpty() were called before await onSubmit(),
causing permanent content and draft loss on network failure. Move both
to the success path, consistent with attachment and draft cleanup.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 13:42:22 +08:00
DENG
7bc1aa7563 fix(daemon): detect Codex Desktop bundle CLI (#3332)
Co-authored-by: codex <codex@multica.local>
2026-05-27 13:39:54 +08:00
Bohan Jiang
b343e13ec4 fix(settings): remove orphan repo count from GitHub tab shortcut card (#3342)
The bare "N" under the Repositories shortcut had no label and was
adjacent to a sentence ("Repository URLs live in the Repositories
tab") that has no semantic link to a number, so users read it as a
typo. The card is a navigation shortcut, not a status panel — the
actual count is visible after clicking through.

MUL-2725

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 13:32:21 +08:00
Bohan Jiang
668fe99cce fix(cli): drop "Showing N comments." stderr preamble on issue comment list (#3341)
This was the only `list` subcommand that printed a human-readable count
to stderr. Consumers that merge stdout/stderr (agent harnesses, CI
`2>&1`) saw it interleaved with the JSON array on `--output json`, and
in table mode it carried no information the table itself didn't.

The `Next thread cursor` / `Next reply cursor` lines stay — they're
real paging signals the agent runtime reads from stderr.

Closes #3303
MUL-2709

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 13:08:03 +08:00
Naiyuan Qing
6261e2b6b2 fix(comments): clear editor immediately on submit to eliminate WS race glitch (#3331)
* fix(comments): clear editor immediately on submit to eliminate WS race visual glitch

The comment editor stayed populated while WebSocket delivered the new
comment faster than the HTTP response, causing a "duplicate comment"
flash. Move clearContent/setIsEmpty before the await so the editor clears
at click time. Also remove dead `submitting` state in useIssueTimeline
(redundant with the input components' own guards) and dead `isTemp` logic
in comment-card (no code path ever creates temp- prefixed entries).

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

* fix(comments): preserve attachments on submit failure and fix CommentRow indentation

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 10:39:21 +08:00
李冠辰
963ed5cd0e feat(comments): allow selecting multiple attachments 2026-05-27 10:13:27 +08:00
YOMXXX
7d24a8594a fix(comments): support edit-time attachment removal (#2965) 2026-05-27 09:48:59 +08:00
Naiyuan Qing
730fb61f4a fix(views): keep sort label centered in viewport during board scroll (#3325)
The "Board ordered by" overlay used absolute positioning inside a
scrollable container, causing it to drift with scroll content. Move
the overlay outside the scroll area into a non-scrolling wrapper so
it stays centered in the visible viewport.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 09:00:08 +08:00
Jiayuan Zhang
e55f050b84 fix(i18n): clean up zh-Hans translation inconsistencies (#3308)
- Normalize nav/page/section titles to plural English (Issues/Skills/Tasks) per conventions.zh.mdx rules for section titles
- Lowercase 'Issue' inside UI short phrase '我的 Issue' (UI short-phrase rule)
- Translate concept words in GitHub settings (Connection/Features/Repositories/Done)
- Translate 'Cloud Runtime' to '云端运行时' to match runtime→运行时 glossary

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 00:11:48 +08:00
Bohan Jiang
fa2a0e57ec feat(views): swimlane supports parent / project / assignee grouping (MUL-2711) (#3311)
* feat(views): swimlane supports parent / project / assignee grouping (MUL-2711)

The swimlane view was hard-coded to group by parent issue. This adds a
display dropdown so users can pick parent (default), project, or
assignee — analogous to how the board view exposes its grouping option.

- Generalise the lane builder in swimlane-view.tsx behind a `LaneGroup`
  abstraction (matcher + per-grouping `moveUpdates` payload) so the
  drag-end handler no longer branches on grouping. Cell ids gain a
  `<grouping>:<rawId>` prefix and lane sortable ids include the
  grouping so dnd-kit cannot collide entries from different groupings.
- Extend the view store with `swimlaneGrouping`, `swimlaneOrders` (one
  saved order per grouping), and a grouping-keyed `collapsedSwimlanes`.
  The persist `merge` defends against the old `string[]` shape so a
  pre-upgrade snapshot doesn't crash on first read.
- Wire `setSwimlaneGrouping` into the issues display popover next to
  the existing board grouping control. Add en / zh-Hans copy for the
  three swimlane buckets (Parent issue / Project / Assignee) and the
  two new pinned lanes (No project / Unassigned).
- Expand swimlane tests with parent / project / assignee smoke cases
  and update existing mocks to the new lane-id format. Add stable
  `useActorName` / `projectListOptions` mocks to avoid the
  set-state-in-effect loop that an unstable `getActorName` would
  trigger via the cells-rebuild memo.

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

* feat(views): default swimlane grouping to assignee

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:23:14 +08:00
Bohan Jiang
df02fcf175 fix(cli): show real MEMBERS count in multica squad list (#3307)
The MEMBERS column was hardcoded to "-" in the table output, so every
squad looked empty even though the backend already returns
`member_count` (and `member_preview`) on each row. `squad get --output
json` exposed the correct data, which is why the bug was cosmetic but
confusing.

Read `member_count` from the response and render it; fall back to "-"
when missing or zero so empty squads stay visually distinct.

Fixes #3304 (MUL-2706).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 20:11:22 +08:00
358 changed files with 23254 additions and 1879 deletions

View File

@@ -208,6 +208,14 @@ ALLOWED_EMAIL_DOMAINS=
# Optional: Only allow these exact email addresses (comma-separated)
ALLOWED_EMAILS=
# Set to "true" to disable workspace creation for every caller on this
# instance (#3433). Operators usually leave this unset, bootstrap the
# shared workspace, then flip this to "true" and restart so subsequent
# users join only via invitations and the entire deployment is visible to
# the platform admin. The web UI reads this from /api/config at runtime,
# so toggling requires a backend restart but not a frontend rebuild.
DISABLE_WORKSPACE_CREATION=
# ==================== Analytics (PostHog) ====================
# Product analytics events feed the acquisition → activation → expansion funnel.
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server

View File

@@ -42,7 +42,12 @@ jobs:
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Build, type check, lint, and test
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
# Mobile lives in a parallel mobile-verify workflow (path-filtered
# to apps/mobile/** + packages/core/types/**) so it doesn't add
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
# filter in sync with the root package.json scripts, which also
# exclude @multica/mobile.
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs' --filter='!@multica/mobile'
backend:
runs-on: ubuntu-latest

65
.github/workflows/mobile-verify.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Mobile Verify
# Runs lint + typecheck for apps/mobile only, parallel to the main CI
# workflow. Path-filtered so PRs that don't touch mobile (or anything
# mobile transitively depends on) pay zero cost.
#
# Path scope rationale — mobile transitively depends on:
# - apps/mobile/** — the app itself
# - packages/core/** — mobile imports types AND pure functions
# (agents, markdown, permissions, api/schemas, …),
# not only @multica/core/types
# - package.json / pnpm-lock.yaml — install graph
# - pnpm-workspace.yaml — catalog versions
# - turbo.json — turbo task pipeline
#
# Mobile has no vitest suite today; if one lands, add `test` to the turbo
# task list below.
on:
push:
branches: [main]
paths:
- "apps/mobile/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/mobile-verify.yml"
pull_request:
branches: [main]
paths:
- "apps/mobile/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/mobile-verify.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
mobile:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Type check and lint
run: pnpm exec turbo typecheck lint --filter=@multica/mobile

View File

@@ -322,6 +322,47 @@ jobs:
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
helm-chart:
needs: [verify, docker-backend-merge, docker-web-merge]
if: github.repository_owner == 'multica-ai'
runs-on: ubuntu-latest
concurrency:
group: release-helm-chart-${{ github.ref }}
cancel-in-progress: true
env:
CHART_DIR: deploy/helm/multica
OCI_REGISTRY: oci://ghcr.io/${{ github.repository_owner }}/charts
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Sync chart metadata with release tag
env:
TAG_NAME: ${{ needs.verify.outputs.tag_name }}
run: |
chart_version="${TAG_NAME#v}"
sed -i -E "s/^version:.*/version: ${chart_version}/" "$CHART_DIR/Chart.yaml"
sed -i -E "s/^appVersion:.*/appVersion: \"${TAG_NAME}\"/" "$CHART_DIR/Chart.yaml"
echo "CHART_VERSION=${chart_version}" >> "$GITHUB_ENV"
- name: Lint chart
run: helm lint "$CHART_DIR"
- name: Package chart
run: helm package "$CHART_DIR" --destination .chart-packages
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
- name: Push chart to GHCR
run: helm push ".chart-packages/multica-${CHART_VERSION}.tgz" "$OCI_REGISTRY"
- name: Verify published chart
run: helm show chart "$OCI_REGISTRY/multica" --version "$CHART_VERSION"
# Build the Desktop installers for Linux and Windows and upload them to
# the GitHub Release that the `release` job above just published. macOS
# Desktop continues to ship via the manual `release-desktop` skill so it

2
.gitignore vendored
View File

@@ -48,6 +48,8 @@ apps/web/test-results/
# context (agent workspace)
.context
.agent_context/
.multica/
# local settings
.claude/

View File

@@ -114,7 +114,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`) on your PATH.
### 2. Verify your runtime
@@ -124,7 +124,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Antigravity). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task

View File

@@ -73,7 +73,7 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
Changes to `ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads all three from `/api/config` at runtime, so no web rebuild is needed. See [Advanced Configuration → Signup Controls](SELF_HOSTING_ADVANCED.md#signup-controls-optional) for the recommended sequence to lock down workspace creation.
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
@@ -139,7 +139,7 @@ multica daemon status
## Kubernetes Deployment (Alternative)
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the Helm chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the released OCI Helm chart at `oci://ghcr.io/multica-ai/charts/multica` or the source chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
The chart creates the following resources in the target namespace:
@@ -196,15 +196,29 @@ Leave optional values empty for now — you can fill them in later (see [Step 5
### Step 4 — Install the chart
```bash
helm install multica deploy/helm/multica -n multica
helm install multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica
```
To override defaults, copy `deploy/helm/multica/values.yaml`, edit it, and pass it with `-f`:
Released chart versions strip the leading `v` from the Git tag. For example, release tag `v0.3.5` publishes chart version `0.3.5`; the chart defaults the backend and frontend image tags to `v0.3.5`.
To override defaults, export the chart values, edit them, and pass them with `-f`:
```bash
cp deploy/helm/multica/values.yaml my-values.yaml
helm show values oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> > my-values.yaml
# edit my-values.yaml — e.g. change ingress hosts, image tags, resource limits
helm install multica deploy/helm/multica -n multica -f my-values.yaml
helm install multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
When developing from a checkout, use the local chart path instead:
```bash
helm install multica deploy/helm/multica -n multica
```
Watch the pods come up:
@@ -245,14 +259,16 @@ The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend.
- **Deterministic local/private testing:** set `backend.config.appEnv: development` in your values file and `MULTICA_DEV_VERIFICATION_CODE=888888` in the Secret, then `helm upgrade` and restart. This fixed code is ignored when `APP_ENV=production`.
```bash
helm upgrade multica deploy/helm/multica -n multica \
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml --set backend.config.appEnv=development
kubectl -n multica patch secret multica-secrets --type=merge \
-p '{"stringData":{"MULTICA_DEV_VERIFICATION_CODE":"888888"}}'
kubectl -n multica rollout restart deploy/multica-backend
```
`ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml`. After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
`ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml` (as `allowSignup`, `disableWorkspaceCreation`, and `googleClientId`). After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads all three from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
@@ -270,13 +286,22 @@ Make sure the machine running the daemon has the same `/etc/hosts` (or DNS) entr
### Updating
To pull the latest images without changing the chart version:
To pull the latest images without changing the chart version when your values still use the mutable `latest` image tag:
```bash
kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend
```
To pin a specific Multica release, set the image tags in your values file:
To upgrade to a specific Multica release, upgrade to the matching chart version. The released chart defaults its app images to the matching Git tag:
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
If you need to override the app images independently from the chart version, set the image tags in your values file:
```yaml
images:
@@ -286,10 +311,13 @@ images:
tag: v0.2.4
```
Then upgrade:
Then run the same upgrade command with `-f my-values.yaml`:
```bash
helm upgrade multica deploy/helm/multica -n multica -f my-values.yaml
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
To roll back if an upgrade goes sideways:

View File

@@ -67,8 +67,20 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
| `DISABLE_WORKSPACE_CREATION` | Set to `true` to make `POST /api/workspaces` return 403 for every caller — users can only join workspaces they were invited to |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` and `DISABLE_WORKSPACE_CREATION` from `/api/config` at runtime, so no web rebuild is needed.
#### Locking down workspace creation
`ALLOW_SIGNUP=false` blocks new accounts from being created, but it does **not** block an already-signed-in user from creating another workspace via `POST /api/workspaces`. On a self-hosted instance where every issue/repo/agent must be visible to the platform admin, set `DISABLE_WORKSPACE_CREATION=true` to close that gap. The recommended bootstrap sequence is:
1. Start the instance with `DISABLE_WORKSPACE_CREATION=false` (the default).
2. Sign in as the admin and create the shared workspace.
3. Set `DISABLE_WORKSPACE_CREATION=true` and restart the backend. Optionally set `ALLOW_SIGNUP=false` at the same time if you also want to block new account creation.
4. Going forward, additional users join via invitation only — the "Create workspace" affordance is hidden in the UI and any direct API call returns 403.
> Note: setting `ALLOW_SIGNUP=false` blocks **all** new account creation, including users who already have a pending invitation. If you need invited users to be able to sign up but not create their own workspaces, keep `ALLOW_SIGNUP=true` (optionally combined with `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS`) and only flip `DISABLE_WORKSPACE_CREATION=true`.
### File Storage (Optional)

View File

@@ -5,6 +5,7 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { setupLocalDirectory } from "./local-directory";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { handleAppShortcut } from "./keyboard-shortcuts";
@@ -460,6 +461,7 @@ if (!gotTheLock) {
setupAutoUpdater(() => mainWindow);
setupDaemonManager(() => mainWindow);
setupLocalDirectory(() => mainWindow);
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {

View File

@@ -0,0 +1,93 @@
import { ipcMain, dialog, BrowserWindow } from "electron";
import { access, stat } from "fs/promises";
import { constants as fsConstants } from "fs";
import { basename, isAbsolute } from "path";
export interface PickDirectoryResult {
ok: boolean;
path?: string;
basename?: string;
/** Set when ok=false. "cancelled" = user dismissed; otherwise an error blurb. */
reason?: "cancelled" | "no_window" | "error";
error?: string;
}
export interface ValidateLocalDirectoryResult {
ok: boolean;
/** When ok=false, identifies which check failed so the renderer can render a
* specific message without parsing free-form text. */
reason?:
| "not_absolute"
| "not_found"
| "not_a_directory"
| "not_readable"
| "not_writable"
| "error";
error?: string;
}
async function validateLocalDirectory(
path: string,
): Promise<ValidateLocalDirectoryResult> {
if (!path || !isAbsolute(path)) {
return { ok: false, reason: "not_absolute" };
}
try {
const st = await stat(path);
if (!st.isDirectory()) return { ok: false, reason: "not_a_directory" };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return { ok: false, reason: "not_found" };
return { ok: false, reason: "error", error: errorMessage(err) };
}
try {
await access(path, fsConstants.R_OK);
} catch {
return { ok: false, reason: "not_readable" };
}
try {
await access(path, fsConstants.W_OK);
} catch {
return { ok: false, reason: "not_writable" };
}
return { ok: true };
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function setupLocalDirectory(
windowGetter: () => BrowserWindow | null,
): void {
ipcMain.handle(
"local-directory:pick",
async (_event, defaultPath?: string): Promise<PickDirectoryResult> => {
const win = windowGetter();
if (!win) return { ok: false, reason: "no_window" };
try {
const result = await dialog.showOpenDialog(win, {
// Multiple-selection is intentionally disabled — a project_resource
// points at a single directory, and the create flow expects one
// path per click. Multi-add would have to be a separate UX.
properties: ["openDirectory", "createDirectory"],
...(defaultPath ? { defaultPath } : {}),
});
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, reason: "cancelled" };
}
const picked = result.filePaths[0];
if (!picked) return { ok: false, reason: "cancelled" };
return { ok: true, path: picked, basename: basename(picked) };
} catch (err) {
return { ok: false, reason: "error", error: errorMessage(err) };
}
},
);
ipcMain.handle(
"local-directory:validate",
(_event, path: string): Promise<ValidateLocalDirectoryResult> =>
validateLocalDirectory(path),
);
}

View File

@@ -45,6 +45,32 @@ interface DesktopAPI {
) => () => void;
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
/** Open the OS folder picker and return the chosen absolute path.
* Used by the Project settings "Add local directory" flow. */
pickDirectory: (
defaultPath?: string,
) => Promise<{
ok: boolean;
path?: string;
basename?: string;
reason?: "cancelled" | "no_window" | "error";
error?: string;
}>;
/** Validate that a path is an existing readable+writable directory.
* Mirrors the daemon's runtime check so the user sees errors before submit. */
validateLocalDirectory: (
path: string,
) => Promise<{
ok: boolean;
reason?:
| "not_absolute"
| "not_found"
| "not_a_directory"
| "not_readable"
| "not_writable"
| "error";
error?: string;
}>;
}
interface DaemonStatus {

View File

@@ -156,6 +156,12 @@ const desktopAPI = {
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
};
},
/** Open the OS folder picker and return the chosen absolute path. */
pickDirectory: (defaultPath?: string) =>
ipcRenderer.invoke("local-directory:pick", defaultPath),
/** Validate that a path is an existing readable+writable directory. */
validateLocalDirectory: (path: string) =>
ipcRenderer.invoke("local-directory:validate", path),
};
interface DaemonStatus {

View File

@@ -34,6 +34,7 @@ function SidebarTopBar() {
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
@@ -42,6 +43,7 @@ function SidebarTopBar() {
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"

View File

@@ -124,6 +124,7 @@ function SortableTabItem({
const tabButton = (
<button
type="button"
ref={setNodeRef}
style={style}
{...attributes}
@@ -221,6 +222,7 @@ function NewTabButton() {
return (
<button
type="button"
onClick={handleClick}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"

View File

@@ -26,6 +26,7 @@ export function UpdateNotification() {
return (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
<button
type="button"
onClick={() => setDismissed(true)}
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
>
@@ -43,12 +44,14 @@ export function UpdateNotification() {
</p>
<div className="mt-2 flex items-center gap-1.5">
<button
type="button"
onClick={() => setDismissed(true)}
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
Later
</button>
<button
type="button"
onClick={() => window.updater.installUpdate()}
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
import {
createContext,
useContext,
use,
type AnchorHTMLAttributes,
type ReactNode,
} from "react";
@@ -30,7 +30,7 @@ export function DocsLocaleProvider({
}
export function useDocsLocale(): Lang {
return useContext(DocsLocaleContext);
return use(DocsLocaleContext);
}
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same

View File

@@ -21,7 +21,7 @@ The form has only two required fields: **name** (unique within the workspace) an
## Pick an AI coding tool
Each runtime is backed by a specific AI coding tool. Multica supports 11 of them. The most common choices:
Each runtime is backed by a specific AI coding tool. Multica supports 12 of them. The most common choices:
| Tool | Good for |
|---|---|
@@ -31,7 +31,7 @@ Each runtime is backed by a specific AI coding tool. Multica supports 11 of them
| **Copilot** | Teams leveraging their GitHub account entitlements |
| **Gemini** | Users in the Google ecosystem |
The other six (Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
The other seven (Antigravity, Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
## Writing system instructions
@@ -123,5 +123,5 @@ Archived agents can't be assigned new tasks.
## Next steps
- [Skills](/skills) — attach knowledge packs to an agent
- [AI coding tools comparison](/providers) — full capability matrix across all 11 tools
- [AI coding tools comparison](/providers) — full capability matrix across all 12 tools
- [Assigning issues to agents](/assigning-issues) — put your new agent to work

View File

@@ -21,7 +21,7 @@ multica agent create
## 选一款 AI 编程工具
运行时背后是一款具体的 AI 编程工具。Multica 支持 11 款,最常用的几款:
运行时背后是一款具体的 AI 编程工具。Multica 支持 12 款,最常用的几款:
| 工具 | 适合 |
|---|---|
@@ -31,7 +31,7 @@ multica agent create
| **Copilot** | 用 GitHub 账号权益的团队 |
| **Gemini** | Google 生态用户 |
另外 6Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
另外 7 款(Antigravity、Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
## 写系统指令
@@ -123,5 +123,5 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
## 下一步
- [Skills](/skills) —— 给智能体挂专业知识包
- [AI 编程工具对照](/providers) —— 11 款工具的完整能力差别
- [AI 编程工具对照](/providers) —— 12 款工具的完整能力差别
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来

View File

@@ -29,18 +29,39 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
### Option B: SMTP relay (for self-hosted / on-premise deployments)
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set — if `SMTP_HOST` is non-empty the server always goes through SMTP, even if `RESEND_API_KEY` is also configured, so verification and invite mail never leaves the internal network.
The SMTP path supports the three relay modes most on-premise mail servers (notably Microsoft Exchange's receive connectors) expose:
| Mode | Port | Auth | TLS |
|---|---|---|---|
| Anonymous internal relay | `25` | none — submission is trusted by IP / subnet | none on the wire (internal segment only) |
| Authenticated submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, upgraded automatically |
| Implicit TLS (SMTPS) | `465` | — | **not supported yet** — use port 25 or 587 |
**Anonymous Exchange relay on port 25** — the typical "internal SMTP relay" / Exchange anonymous receive connector that accepts mail from a trusted subnet without credentials:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
**Authenticated submission on port 587** — for relays that require a service account; STARTTLS is upgraded automatically when the server advertises it:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
At startup the server prints which provider it picked — for example `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com` (or `Resend API` / `DEV mode`). The password is never logged. If you don't see the SMTP line after restart, `SMTP_HOST` didn't reach the process — check the container env (`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`).
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.

View File

@@ -29,18 +29,39 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
### Option BSMTP relay内网/自部署)
适合内网无法访问 `api.resend.com`或者已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`:只要 `SMTP_HOST` 非空server 一律走 SMTP 路径,即便 `RESEND_API_KEY` 也配着,验证码和邀请邮件也不会经过 Resend 外发出内网
SMTP 路径覆盖大多数本地邮件服务器(特别是 Microsoft Exchange 的 receive connector暴露的三种 relay 模式:
| 模式 | 端口 | 认证 | TLS |
|---|---|---|---|
| 匿名内部 relay | `25` | 无 —— 按 IP / 子网信任 | 链路上无 TLS仅限内网段 |
| 认证提交submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS自动升级 |
| 隐式 TLSSMTPS | `465` | —— | **暂不支持** —— 请用 25 或 587 |
**匿名 Exchange relay端口 25** —— 经典的 "internal SMTP relay" / Exchange 匿名 receive connector按可信子网放行不要求凭据
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465SMTPS / 隐式 TLS请使用 25 或 587。
**认证提交,端口 587** —— 需要 service account 的 relay服务端 advertise STARTTLS 时会自动升级:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
启动时 server 会打印当前选择的 provider比如 `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com`(或 `Resend API` / `DEV mode`),密码不会出现在日志里。重启后没看到 SMTP 这行,说明 `SMTP_HOST` 没进到进程,确认下容器环境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)。
**两种都不配**server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。

View File

@@ -99,6 +99,126 @@ nothing matches, the event is `webhook.received`.
When configuring GitHub or similar sources, set the content type to
`application/json` — form-encoded webhook payloads are not accepted.
### Event filters
A new webhook trigger fires on every inbound POST, which is fine for a
single-purpose URL but noisy for sources that fan out many event types
(GitHub being the obvious one — a single repo webhook can deliver
`push`, `pull_request`, `workflow_run`, `check_suite`, and more). The
**Event filters** section on a webhook trigger lets you restrict which
events actually dispatch a run; everything else is recorded in delivery
history with `status = ignored` and `reason = event_filtered`, and no
run or issue is created.
Each row is one rule: an **event name** plus an optional
comma-separated **actions** list. Multica allows a webhook if **any**
row matches; leave the section empty to accept everything (the
pre-filter behavior).
Examples:
| Event name | Actions | Matches |
| -------------- | ------------------- | ------------------------------------------------------------------------ |
| `workflow_run` | `completed, failed` | `workflow_run` events with `action: completed` or `action: failed` only |
| `workflow_run` | _(empty)_ | every `workflow_run` event, regardless of action |
| `push` | _(empty)_ | every `push` event |
#### Where the event name and action come from
Multica derives the `event` name and `action` from the inbound request
in this order — **the first match wins**.
**1. Body envelope.** If the body is a JSON object with a string
`event` field, that value is the event name directly. An optional
`eventPayload` object then supplies action candidates from its
`action` / `state` / `conclusion` / `status` fields.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
# inferred: event = trigger, action candidate = true
```
**2. Headers.** When no body envelope is present, Multica reads the
following well-known provider headers:
- `X-GitHub-Event: <event>` — combined with the top-level body
`action` field (when present) to form `github.<event>.<action>`.
- `X-Gitlab-Event: <event>` — becomes `gitlab.<event>`.
- `X-Event-Type: <event>` — passed through verbatim.
```bash
# GitHub-style: header gives the event name, body gives the action.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# inferred: event = github.workflow_run.completed
# → matches a filter row of workflow_run / completed
# Generic event-type header — no body fields needed.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-Event-Type: trigger.true' \
-H 'Content-Type: application/json' \
-d '{}'
# inferred: event = trigger.true → matches trigger / true
```
**3. Body fallback.** If neither a body envelope nor a known header is
present, Multica falls back to top-level body string fields in this
order: `event` → `type` → `action`.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"trigger","action":"true"}'
# inferred: event = trigger (from `type`), action candidate = true
```
**4. Default.** If nothing above matches, the event is
`webhook.received` and there are no action candidates.
**Action candidates, in full.** Once the event is determined, Multica
considers every value below as a possible action match:
- The event-name suffix, when the event has the form
`provider.event.<action>` (e.g. `github.workflow_run.completed` →
`completed`).
- The body fields `action`, `state`, `conclusion`, and `status` —
**only when they are JSON strings**. A boolean (`{"action": true}`)
or a number does not qualify, so a filter expecting
`event=trigger, action=true` will never match a body of
`{"trigger": true}` because `true` is a bool, not a string.
**Common gotcha.** A filter row like `Event name: trigger` /
`Actions: true` does **not** mean "fire when the body has
`trigger: true`" — Event filters match the *inferred event and
action*, not arbitrary body fields. Send `trigger.true` via
`X-Event-Type` (or use the body envelope shown above) to hit it.
Surrounding whitespace in saved filter rows (`" workflow_run "`) is
stored verbatim and will never match — trim before saving.
#### Quick test
Once a filter is configured, you can confirm both branches with `curl`:
```bash
# Allowed — header drives event=workflow_run, body drives action=completed
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# → 200 {"status":"accepted", ...}
# Filtered — same event, action not in allowlist
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}
```
### URL is a bearer secret
The generated URL **is** the credential. Anyone with it can fire the

View File

@@ -98,6 +98,116 @@ curl -X POST "$MULTICA_WEBHOOK_URL" \
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
表单编码的 webhook payload 在 v1 里不接受。
### 事件过滤
新建的 webhook 触发器对每一条入站 POST 都会触发,这在单一用途的 URL
上没问题,但对那种会扇出很多事件类型的来源(典型就是 GitHub——
一个仓库 webhook 就会同时下发 `push`、`pull_request`、`workflow_run`、
`check_suite` 等等就会很吵。webhook 触发器上的**事件过滤**区块用来
限制哪些事件真正派发一次 run其它的只记录到投递历史里
`status = ignored`、`reason = event_filtered`,不会建任何 issue 或 run。
每一行是一条规则:一个**事件名**加可选的、逗号分隔的 **action** 列表。
**任意一行命中**即放行;区块留空则接受所有事件(即过滤前的行为)。
例子:
| 事件名 | Actions | 命中范围 |
| ---------------- | ------------------- | ------------------------------------------------------------------------ |
| `workflow_run` | `completed, failed` | 只有 `action` 为 `completed` 或 `failed` 的 `workflow_run` 事件 |
| `workflow_run` | _(留空)_ | 所有 `workflow_run` 事件,不限 action |
| `push` | _(留空)_ | 所有 `push` 事件 |
#### 事件名和 action 从哪来
Multica 按下面的顺序从入站请求里推断 `event` 和 `action`**先命中先用**。
**1. Body envelope。** 如果 body 是一个 JSON 对象、且带字符串字段
`event`,就直接用它作为事件名。可选的 `eventPayload` 对象再
从自己的 `action` / `state` / `conclusion` / `status` 字段里提供
action 候选。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
# 推断结果event = triggeraction 候选 = true
```
**2. 请求头。** 没有 body envelope 时按以下头部识别:
- `X-GitHub-Event: <event>` —— 结合 body 顶层的 `action` 字段
(如果有),拼成 `github.<event>.<action>`。
- `X-Gitlab-Event: <event>` —— 拼成 `gitlab.<event>`。
- `X-Event-Type: <event>` —— 原样使用。
```bash
# GitHub 风格:事件名来自 headeraction 来自 body。
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# 推断结果event = github.workflow_run.completed
# → 命中 workflow_run / completed 的过滤规则
# 通用 event-type 头部 —— 不需要任何 body 字段。
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-Event-Type: trigger.true' \
-H 'Content-Type: application/json' \
-d '{}'
# 推断结果event = trigger.true → 命中 trigger / true
```
**3. Body 兜底。** Body envelope 和上面的 header 都没有时,
从 body 顶层字符串字段里依次找:`event` → `type` → `action`。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"trigger","action":"true"}'
# 推断结果event = trigger取自 `type`action 候选 = true
```
**4. 默认值。** 以上都没命中时,事件名取 `webhook.received`
没有 action 候选。
**action 候选的完整清单。** 事件名确定后,下面这些值都会被列为
可能的 action
- 事件名后缀,当事件形如 `provider.event.<action>` 时
(例如 `github.workflow_run.completed` → `completed`)。
- body 里 `action`、`state`、`conclusion`、`status` 这四个字段——
**必须是 JSON 字符串**。布尔(`{"action": true}`)或数字都不算
候选,所以 `event=trigger, action=true` 的过滤规则**永远命中不了**
`{"trigger": true}` 这种 body因为 `true` 是 bool 不是字符串。
**常见误区。** 一条 `Event name: trigger` / `Actions: true` 的规则
**不是**「body 里出现 `trigger: true` 就放行」的意思——事件过滤匹配的是
**推断出来的事件和 action**,不是任意 body 字段。要命中它,请用
`X-Event-Type` 头发送 `trigger.true`,或者用上面的 body envelope。
保存时带空格的值(例如 `" workflow_run "`)会被原样保存,但永远命中
不了——保存前请先 trim。
#### 快速验证
配好过滤后,可以用 `curl` 同时验证「命中」和「被过滤」两条路径:
```bash
# 命中 —— 请求头给出 event=workflow_runbody 给出 action=completed
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# → 200 {"status":"accepted", ...}
# 被过滤 —— 同样的事件,但 action 不在白名单
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}
```
### URL 即 bearer secret
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:

View File

@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
## 1. Create an account
@@ -114,6 +114,6 @@ The web UI updates in **real time** (via WebSocket) — no refresh needed.
- [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean
- [Tasks](/tasks) — task lifecycle and retry rules
- [AI coding tools compared](/providers) — capability differences across the 11 tools
- [AI coding tools compared](/providers) — capability differences across the 12 tools
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
- [Self-host quickstart](/self-host-quickstart) — run your own backend

View File

@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
## 1. 注册账号
@@ -114,6 +114,6 @@ Web 界面会**实时**(通过 WebSocket显示进度——不需要刷新
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
- [执行任务](/tasks) —— 任务生命周期、重试规则
- [AI 编程工具对照](/providers) —— 11 款工具的能力差异
- [AI 编程工具对照](/providers) —— 12 款工具的能力差异
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套

View File

@@ -21,7 +21,7 @@ multica daemon start
On startup it does four things:
1. Reads the credentials saved when you logged in
2. Detects AI coding tools installed on your `PATH` (11 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
2. Detects AI coding tools installed on your `PATH` (12 built-in: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
3. Registers itself with the server, along with a runtime for each detected tool
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
@@ -108,4 +108,4 @@ More scenarios in [Troubleshooting](/troubleshooting).
## Next
- [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools
- [Providers Matrix](/providers) — capability differences across the 12 AI coding tools

View File

@@ -21,7 +21,7 @@ multica daemon start
启动后它会做四件事:
1. 读取你登录时保存的凭证
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
@@ -108,4 +108,4 @@ Multica 对并发有两层限额:
## 下一步
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照
- [Providers Matrix](/providers) —— 12 款 AI 编程工具的能力差异对照

View File

@@ -128,6 +128,22 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
## Locking down workspace creation
`ALLOW_SIGNUP=false` blocks new accounts, but it does **not** block an already-signed-in user from creating another workspace via `POST /api/workspaces`. On a self-hosted instance where every issue, repo, and agent must be visible to the platform admin, set `DISABLE_WORKSPACE_CREATION=true` to close that gap.
| Variable | Default | Description |
|---|---|---|
| `DISABLE_WORKSPACE_CREATION` | `false` | When `true`, every call to `POST /api/workspaces` returns `403 workspace creation is disabled for this instance`. The web UI hides every "Create workspace" affordance via `/api/config`. There is no role/owner exception — the gate is global per instance |
Recommended bootstrap sequence:
1. Start the instance with `DISABLE_WORKSPACE_CREATION` unset (the default).
2. Sign in as the admin and create the shared workspace.
3. Set `DISABLE_WORKSPACE_CREATION=true` and restart the backend. From this point on, users join via invitation only.
If you also want to keep `ALLOW_SIGNUP=true` so invited users can finish signup with their first verification code, combine `DISABLE_WORKSPACE_CREATION=true` with `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS` to scope which addresses can sign up. Setting `ALLOW_SIGNUP=false` will additionally block pending invitees from creating their account at all — useful only on instances where every member already has a Multica account.
## Rate limiting (optional Redis)
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.

View File

@@ -128,6 +128,22 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
## 锁死工作区创建
`ALLOW_SIGNUP=false` 能挡住新注册,但**挡不住**已经登录的用户继续 `POST /api/workspaces` 自助开新工作区。在希望平台管理员能看到全部 issue / 仓库 / 智能体的自部署实例里,把 `DISABLE_WORKSPACE_CREATION=true` 打开以堵上这个口子。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `DISABLE_WORKSPACE_CREATION` | `false` | 设为 `true` 时,对 `POST /api/workspaces` 的任何调用都返回 `403 workspace creation is disabled for this instance`。Web UI 通过 `/api/config` 隐藏所有「创建工作区」入口。该开关是实例级的,没有 owner / admin 例外 |
推荐的开服流程:
1. 启动实例时保持 `DISABLE_WORKSPACE_CREATION` 未设置(默认)。
2. 管理员登录并创建共享工作区。
3. 把 `DISABLE_WORKSPACE_CREATION=true` 设上并重启 backend。之后新用户只能通过邀请加入。
如果还想让被邀请的新用户能完成首次注册,保留 `ALLOW_SIGNUP=true`(必要时用 `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS` 限定可注册邮箱),只把 `DISABLE_WORKSPACE_CREATION=true` 打开即可;如果同时设 `ALLOW_SIGNUP=false`,连有 pending invite 的邮箱都无法完成首次注册,仅适合所有成员都已有 Multica 账号的实例。
## 速率限制(可选 Redis
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**fail-open后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。

View File

@@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro
- **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks.
- **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds.
- **AI coding tools** — one of the eleven (or several in parallel): [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
- **AI coding tools** — one of the twelve (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud.

View File

@@ -13,7 +13,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
- **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。
- **守护进程**daemon——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server开始每 3 秒领一次任务、每 15 秒发一次心跳。
- **AI 编程工具**——[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 11 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
- **AI 编程工具**——[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 12 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。

View File

@@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica.
Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model:
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Eleven are built in today: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Twelve are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
<Callout type="info">
**Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified.

View File

@@ -13,7 +13,7 @@ Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在
智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式:
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
<Callout type="info">
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。

View File

@@ -1,11 +1,11 @@
---
title: Install an agent runtime
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 12 supported tools so the daemon can detect them.
---
import { Callout } from "fumadocs-ui/components/callout";
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 12 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
This page is the install-side companion to:
@@ -31,9 +31,9 @@ multica daemon restart
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
## The 11 supported tools
## The 12 supported tools
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 12.
### Claude Code (Anthropic)
@@ -147,9 +147,20 @@ Minimalist. **Session resumption is unusual** — the resume id is the path to a
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
| Authentication | Per the vendor's docs. |
### Antigravity (Google)
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
| | |
|---|---|
| Daemon looks for | `agy` |
| Install | Follow the official guide at [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview). The CLI ships pre-built — run `agy install` once to wire up PATH and shell aliases. |
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text. |
## After installing
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`, `which agy`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.

View File

@@ -1,11 +1,11 @@
---
title: 安装一个 Agent 运行时
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 12 款工具,让守护进程能扫到。
---
import { Callout } from "fumadocs-ui/components/callout";
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 12 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
这一页是装机的入口,和它配套的是:
@@ -31,13 +31,13 @@ multica daemon restart
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
## 11 款支持的工具
## 12 款支持的工具
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 12 个全装。
### Claude CodeAnthropic
集成最完整的一款。会话续接好用MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
集成最完整的一款。会话续接好用MCP 好用,而且 **12 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
| | |
|---|---|
@@ -147,9 +147,20 @@ ACP 协议 agent和 Kimi 共享传输层。会话续接可用。Skill 注
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
| 认证 | 按厂商文档。 |
### AntigravityGoogle
Google 的 Antigravity CLI`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
| | |
|---|---|
| 守护进程扫描 | `agy` |
| 安装 | 看官方指引 [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview)。CLI 是预编译的,跑一次 `agy install` 配好 PATH 和 shell 别名即可。 |
| 认证 | 交互式跑一次 `agy` 走 Google 账号登录流程;或者通过 Antigravity 桌面端登录——CLI 会复用 GUI 写入 keyring 的凭据。 |
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 思考过程和最终回复都会作为 text 消息送回 Multica。 |
## 装完之后
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`、`which agy`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。

View File

@@ -11,6 +11,7 @@
"issues",
"projects",
"comments",
"project-resources",
"---智能体---",
"agents",
"agents-create",

View File

@@ -1,25 +1,27 @@
---
title: Project Resources
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
description: Attach typed pointers (Git repos, local directories, more later) to a project so agents can pick them up as scoped context.
---
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
A **Project Resource** is a typed pointer — a Git repo URL, a path on your own machine, a Notion page tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
The result: the agent knows which repo to check out (or which local directory to work in), and which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
## Mental model
A project is no longer just a label. It is a small **resource container**:
- A project has 0..N **resources**.
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
- A resource has a `resource_type` (e.g. `github_repo`, `local_directory`) and a `resource_ref` (a JSON payload typed by `resource_type`).
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
## Today: `github_repo`
Today two resource types ship: [`github_repo`](#resource-type-github_repo) (clone-per-task into an isolated worktree) and [`local_directory`](#resource-type-local_directory) (run directly inside a folder on a specific daemon's machine).
The first resource type ships ready to use:
## Resource type: `github_repo`
The default resource type — checked out per task into an isolated worktree:
```json
{
@@ -33,6 +35,122 @@ The first resource type ships ready to use:
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
## Resource type: `local_directory`
For repos that can't reasonably be re-cloned per task — multi-gigabyte game checkouts, large monorepos, or any project where the worktree-per-task model is painful — a project can instead point at an **existing directory on a specific [daemon](/daemon-runtimes)'s machine**. The agent runs **directly inside that folder**, with no clone, no copy, and no worktree.
```json
{
"resource_type": "local_directory",
"resource_ref": {
"local_path": "/Users/me/code/big-game",
"daemon_id": "0001234e-…",
"label": "main checkout"
}
}
```
The trade-off vs. `github_repo` is intentional: only the bound daemon can pick up tasks against the directory, and tasks on the same directory run **serially** instead of in parallel. In exchange you keep your existing checkout, your existing branch, your existing dirty state — Multica never re-clones it.
### When to pick `local_directory` over `github_repo`
| Concern | `github_repo` (worktree) | `local_directory` |
| --- | --- | --- |
| Checkout cost per task | Fresh clone + worktree | None — agent runs in place |
| Concurrency on the same repo | Many tasks in parallel | One at a time per directory |
| Branch / dirty state | Each task gets a fresh branch from the default | Whatever the directory currently has |
| Where it can run | Any daemon | Exactly one daemon (the one bound) |
| Disk footprint | One worktree per task | Zero overhead — your existing folder |
Pick `local_directory` when **either** of these matches:
1. **Re-cloning is prohibitively expensive** — a multi-gigabyte game checkout, a monorepo with heavy LFS assets, or anything where the per-task `git clone` would dominate the actual work. You trade concurrency for a clone-free run.
2. **Your changes are fine-grained and you want to review them locally as they happen** — you're iterating on a single component, you want to flip between the agent's edits and your editor every few minutes, and you'd rather have your existing checkout be the source of truth than a per-task worktree you have to dig out of `~/multica_workspaces/`.
The trade-off you accept in both cases is the same: **this version ships no file-level write lock.** The per-directory serial gate (one task at a time on the same folder) is the only protection against agents in two different issues touching the same files at the same time. If you point two issues' agents at the same `local_directory`, their tasks queue rather than parallelise — that's by design. If you need real parallelism on the same codebase, stay on `github_repo`.
### Attaching a local directory
The folder picker lives in the **Desktop app** only — the web app has no way to read OS paths, so the "Add local directory" button is hidden there. On Desktop:
1. Open the project → **Resources** panel.
2. Click **Add local directory**. A native folder picker opens.
3. Pick the folder. The path is bound to **the daemon currently registered by this Desktop install** — the resource record stores both the path and that daemon's ID.
On Desktop the button stays visible but is **disabled with a hint** when the daemon on this machine is offline, or when the project already has a `local_directory` bound to this daemon — so you can see *why* it's unavailable. (On the web app the button is hidden outright, since there's no folder picker available there at all.) To bind another machine's directory, install Desktop on that machine and add the resource from there.
From the CLI (works on web-only environments too, as long as you supply the daemon ID yourself):
```bash
multica project resource add <project-id> \
--type local_directory \
--local-path /Users/me/code/big-game \
--daemon-id <daemon-uuid> \
--ref-label "main checkout" # optional
multica project resource update <project-id> <resource-id> \
--local-path /Users/me/code/big-game-new
```
`--daemon-id` comes from `multica daemon list`. The CLI also accepts the generic `--ref '<json>'` escape hatch if you'd rather pass the payload directly.
### Path rules
The path you attach must clear both an attach-time and a per-task validation. Both are enforced by the daemon that owns the resource — the server only stores the JSON. A path that breaks any rule fails the task with a typed error and leaves your directory untouched:
- Must be **absolute**.
- Must **exist** and be a **directory** (not a file, symlink to a file, or device node).
- Must be **readable and writable** by the daemon process.
- Cannot be a system root or an entire user profile — `/`, `/Users`, `/home`, `/root`, `/etc`, `/tmp`, `/var`, `/usr`, `/opt`, `/Users/Shared`, your own `$HOME`, any Windows drive root (`C:\`, `D:\`, …), or `C:\Users` / `C:\ProgramData` / `C:\Program Files` / `C:\Program Files (x86)` / `C:\Windows`.
- A symlink that resolves to any of the above is rejected, and so is the canonical form of an OS-aliased path (e.g. on macOS, typing `/private/tmp` is rejected the same way as `/tmp`).
The blacklist is intentionally aggressive — picking your home directory would put Multica's runtime files at the root of your account, which is never what you want. Pick a sub-folder (typically your actual project checkout) instead.
### One per (project, daemon)
A project may hold **at most one `local_directory` per daemon**. Attempting to add a second one on the same daemon returns a `409` from the API; the Desktop button hides itself when the limit is already reached and surfaces a tooltip explaining why.
Different daemons are independent — a shared project can have one `local_directory` per teammate's machine, each binding the same project to a different folder on a different host. When the daemon claims a task, it picks the row that matches its own ID and ignores the rest.
### Mixing resource types, and multiple `local_directory` resources
Two cross-resource configurations show up in practice:
- **`github_repo` + `local_directory` on the same project.** On the daemon that has a matching `local_directory` binding, the local directory **takes precedence**: the agent runs in your folder, and the daemon does not create or use a `github_repo` worktree for that task. (The per-workspace repo cache may still sync as usual — that's a background behaviour unrelated to this task's working tree.) The `github_repo` URL still appears in `.multica/project/resources.json` and in the agent's `## Repositories` section for reference — but the working tree the agent edits is your local one, not a worktree. On a daemon that has **no** `local_directory` row for this project (different machine, or before that teammate attached one), the task falls back to the usual `github_repo` worktree flow. Effectively the local directory is a per-daemon override of the worktree path.
- **Two `local_directory` resources on the same project.** Because each `local_directory` is bound to exactly one daemon, this only happens across two different machines (the API rejects two on the same daemon at attach time, see above). Tasks are routed by the agent's runtime assignment, not by which daemon has a local directory: a task lands on the daemon that owns the receiving agent's runtime, that daemon picks the `local_directory` row matching its own ID, and ignores the rest. There is no load-balancing — if you want a specific machine to run a task, dispatch the agent that's bound to that machine's runtime.
A daemon that has no `local_directory` row for a project that has one bound elsewhere is **not** blocked — its tasks simply proceed via the project's other resources (typically the `github_repo` fallback). The `local_directory` only matters for the daemon it's bound to.
### Running tasks against a local directory
When a task is dispatched on an issue whose project has a `local_directory` bound to the receiving daemon, the daemon:
1. Re-validates the path (rules above).
2. Acquires a per-directory lock keyed on the symlink-resolved real path — so two routes to the same folder (one via a symlink, one direct) still serialise.
3. Writes the agent's `CLAUDE.md` / `AGENTS.md` (and `.multica/project/resources.json`) **into the user's directory**. The agent works there, just as if you'd opened the folder yourself.
4. Keeps Multica's runtime artefacts (`output/`, `logs/`, `.gc_meta.json`) in a separate envRoot **outside** the user's directory.
If a second task for the same directory arrives while the first is running, it parks with status **Waiting for local directory** (等待本地目录释放). The status is visible everywhere the task is — the chat task pill, the agent banner, the execution log, and the activity indicator — and the parked task counts toward the agent's "queued" presence. Cancelling the parked task releases its slot immediately; cancelling the running task lets the next one promote.
The wait is not a timeout — a parked task stays parked until either the lock releases or the user / agent cancels it.
### What Multica will and won't touch in your directory
- **Will write** `CLAUDE.md` / `AGENTS.md` (or the equivalent for your agent's provider) and `.multica/project/resources.json` at the directory root, so the agent has its meta-skill and resource list. Add these to your `.gitignore` if you don't want them committed.
- **Will write** whatever code edits the agent decides to make — exactly the same way as if you'd run the agent locally yourself.
- **Will never physically delete** the directory or anything inside it. Garbage collection is path-aware: for `local_directory` envRoots it cleans only its own `output/` and `logs/` under `workspacesRoot`, and treats the user's directory as off-limits.
### v1 limits (will tighten in follow-ups)
The first release deliberately ships with sharper edges than `github_repo`. Expect this list to shrink over time — what's documented here is what's true today:
- **No automatic branch switching.** The agent runs in whatever branch you have checked out. Switch branches before dispatching if it matters.
- **No dirty-tree protection or auto-commit.** Uncommitted changes are visible to the agent, may be modified in place, and won't be stashed. Treat the directory as a real working tree and commit before risky runs.
- **No automatic PR.** When the task ends, the changes sit on whatever branch they were made on — nothing is pushed and no PR is opened. Push and open the PR yourself when you're ready.
- **`waiting_local_directory` shows status, not the holder.** The badge tells you the task is parked; it doesn't surface which task or which file path is currently holding the directory.
These are tracked as the agent-task-lifecycle follow-up to the local-directory work; until that ships, treat `local_directory` as "the agent runs in your folder, the same way you would."
## Attaching repos at project creation
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.

View File

@@ -0,0 +1,262 @@
---
title: 项目资源
description: 给项目挂上有类型的指针GitHub 仓库、本地目录,未来更多),让智能体执行任务时自动拿到对应上下文。
---
**项目资源Project Resource** 是挂在 [项目](/workspaces) 上的有类型的指针 —— 今天可以挂 GitHub 仓库 URL 或者本机的一个目录,未来还会有 Notion 页面、文档链接等等。当 [智能体](/agents) 在这个项目的 issue 上跑任务时,守护进程会自动把项目的资源列表写进智能体的工作目录,并塞进它的 [元 skill](/skills) prompt 里。
带来的结果:智能体知道应该 checkout 哪个仓库(或者在哪个本地目录里工作),知道项目"主要参考资料"是哪些 —— 没人需要把上下文复制粘贴进 issue 描述里。
## 心智模型
项目不再只是一个标签,而是一个小的 **资源容器**
- 一个项目可以挂 0..N 个 **资源**。
- 每个资源有一个 `resource_type`(比如 `github_repo`、`local_directory`),以及一个 `resource_ref`(按 `resource_type` 定型的 JSON 负载)。
- 加新资源类型只需要加一个字符串 + 一个 handler。**不需要 schema 迁移,不需要重写前端。**
这个形状是有意的 —— 跟 Multica 里 agent provider 的设计完全一致:一个 `type` 判别字段 + 一个有类型的 payload。schema 保持稳定,未来加"Notion 页面"、"Google Doc"、"上传文件"、"外部 URL"都是小型的、增量的改动。
目前内置两种资源类型:[`github_repo`](#resource-type-github_repo)(每次任务克隆出独立 worktree和 [`local_directory`](#resource-type-local_directory)(直接在某台守护进程所在机器的目录里运行)。
## 资源类型:`github_repo`
默认的资源类型 —— 每次任务都在隔离的 worktree 里 checkout
```json
{
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/owner/repo",
"default_branch_hint": "main"
}
}
```
`default_branch_hint` 可选 —— 填了之后,守护进程会把它写进元 skill告诉智能体该基于哪个分支干活。
## 资源类型:`local_directory`
对于那些不适合每次任务重新 clone 的仓库 —— 几十 GB 的游戏项目、超大 monorepo、或者本身就不想被多次复制的目录 —— 项目可以改为指向 **某台 [守护进程](/daemon-runtimes) 机器上的一个已有目录**。智能体会 **直接在这个目录里运行**,不 clone、不复制、不创建 worktree。
```json
{
"resource_type": "local_directory",
"resource_ref": {
"local_path": "/Users/me/code/big-game",
"daemon_id": "0001234e-…",
"label": "主开发目录"
}
}
```
跟 `github_repo` 相比的取舍是有意的:只有绑定的那台守护进程会使用这个本地目录执行任务,而且 **同一个目录上的任务串行执行**,不再并行。换来的是:你的现有 checkout、当前分支、未提交的脏改动一切照旧 —— Multica 不会重新 clone。
### 什么时候选 `local_directory`、什么时候继续用 `github_repo`
| 关注点 | `github_repo`worktree 模式) | `local_directory` |
| --- | --- | --- |
| 每次任务的 checkout 成本 | 重新 clone + worktree | 0 —— 智能体原地干活 |
| 同一仓库的并发 | 任意条任务并行 | 同一目录每次一条 |
| 分支 / 脏改动 | 每条任务从默认分支拉一个干净分支 | 用目录当前的状态 |
| 可在哪台机器跑 | 任意守护进程 | 仅绑定的那一台 |
| 磁盘占用 | 每条任务一个 worktree | 0 —— 用你已有的目录 |
下面两种场景下推荐选 `local_directory`
1. **重新 clone 的成本过高** —— 几十 GB 的游戏 checkout、带大量 LFS 资源的 monorepo、或者任何场景下 `git clone` 的耗时会盖过实际工作量。你拿并发能力换"零 clone"的运行。
2. **改动很细碎,需要频繁在本地 review** —— 你在反复打磨某个组件,几分钟就要切回编辑器看一眼智能体改了什么;与其每次去 `~/multica_workspaces/` 翻一个新 worktree不如让它就在你已有的 checkout 里干活。
两种情况下你接受的取舍是同一个:**当前版本没有任何文件级的写入锁**。"同一目录上的任务串行执行"这一道闸是唯一防止两个 issue 的智能体同时改同一份文件的保护。如果你把两个 issue 的智能体都指向同一个 `local_directory`,它们的任务会排队、而不是并行 —— 这是有意的。如果需要在同一份代码上真正的并行,请继续用 `github_repo`。
### 添加本地目录
文件夹选择器 **只在桌面端** 提供 —— 浏览器没法读 OS 路径,所以网页端不显示"添加本地目录"按钮。桌面端的流程是:
1. 打开项目 → **Resources资源** 面板。
2. 点击 **Add local directory添加本地目录**,系统会弹出原生文件夹选择器。
3. 选一个文件夹。这个路径会被绑定到 **当前这台桌面端注册的守护进程** —— 资源记录里同时保存路径和该守护进程的 ID。
在桌面端,当本机的守护进程离线、或者这个项目已经在本机绑定了一个 `local_directory` 时,按钮 **会保留显示但置灰**,并在下方给出一行说明 —— 让你看得到为什么暂时不能用。(网页端则是直接隐藏整个按钮,因为网页端本来就没有文件夹选择器。)要把另一台机器上的目录绑过来,需要在那台机器装上桌面端、并在那边添加资源。
也可以用 CLI即便环境是纯网页端也行只要你自己提供守护进程 ID
```bash
multica project resource add <project-id> \
--type local_directory \
--local-path /Users/me/code/big-game \
--daemon-id <daemon-uuid> \
--ref-label "主开发目录" # 可选
multica project resource update <project-id> <resource-id> \
--local-path /Users/me/code/big-game-new
```
`--daemon-id` 可以通过 `multica daemon list` 拿到。CLI 也接受 `--ref '<json>'` 这种通用形式,直接传 payload。
### 路径规则
挂资源时和每次任务启动时,守护进程都会校验一次路径(服务器只负责存 JSON不做校验。任何一条不满足的任务会以一个有类型的错误失败**不会动你的目录**
- 必须是 **绝对路径**。
- 必须 **存在**,并且是 **目录**(不能是普通文件、设备节点、或者指向文件的 symlink
- 守护进程进程必须能 **读 + 写**。
- 不能是系统根目录或整个用户配置区 —— `/`、`/Users`、`/home`、`/root`、`/etc`、`/tmp`、`/var`、`/usr`、`/opt`、`/Users/Shared`、你自己的 `$HOME`、任意 Windows 盘根(`C:\`、`D:\`…),以及 `C:\Users` / `C:\ProgramData` / `C:\Program Files` / `C:\Program Files (x86)` / `C:\Windows`。
- 指向上述任何路径的 symlink 也会被拒macOS 的 canonical 别名(比如手动输入 `/private/tmp`)和 `/tmp` 受同样的限制。
黑名单故意做得激进 —— 选你自己的 home 目录就意味着 Multica 的运行时文件会出现在你账号的根下,这不会是任何人想要的结果。请选一个子目录(一般就是你具体的项目 checkout
### 每台守护进程每个项目最多一条
同一项目上 **每台守护进程最多只能绑一个 `local_directory`**。在同一台机器上想再加一条会被 API 用 `409` 拒绝;桌面端的按钮在达到上限时会自动隐藏,并在 tooltip 里说明原因。
不同的守护进程互不影响 —— 一个共享的项目可以为每个队友的机器各绑一个 `local_directory`,把同一个项目挂到不同主机上的不同目录。守护进程领任务时会挑跟自己 ID 匹配的那一行,其它的忽略。
### 混用资源类型,以及多个 `local_directory` 资源
实际会碰到两种跨资源的组合:
- **同一个项目同时挂 `github_repo` 和 `local_directory`。** 在拥有匹配 `local_directory` 绑定的那台守护进程上,本地目录 **优先**:智能体跑在你的目录里,这次任务不会为项目的 `github_repo` 创建或使用 worktree。每工作区的仓库缓存可能仍会照常 sync这层是和具体任务无关的后台行为。`github_repo` 的 URL 仍然会出现在 `.multica/project/resources.json` 和智能体的 `## Repositories` 段里供参考,但智能体真正在编辑的工作树是你的本地目录,不是 worktree。在 **没有** 匹配 `local_directory` 行的其他守护进程上(不同机器、或者那位队友还没挂本地目录),任务按原本的 `github_repo` worktree 流程走。本质上 `local_directory` 是某台守护进程对 worktree 路径的一个覆盖。
- **同一个项目挂两个 `local_directory`。** 每个 `local_directory` 只能绑一台守护进程,所以这只会发生在两台不同机器之间(同一台机器上想加第二条会被 API 当场拒掉,见上一节)。任务的去向由智能体 runtime 绑定决定,不是由"哪台守护进程有本地目录"决定 —— 任务会落到承载这条智能体 runtime 的那台守护进程上,由它挑出匹配自己 ID 的那一行、忽略其他行。这里没有负载均衡:如果你想让某台机器跑某条任务,请把任务派给绑在那台机器 runtime 上的智能体。
如果某台守护进程没有匹配这个项目的 `local_directory` 行,**不会**因为别处有人绑了就被拦下 —— 它的任务会照常走项目里的其他资源(一般就是 `github_repo` 那条 fallback 路径)。`local_directory` 只对它绑定的那台守护进程生效。
### 任务在本地目录里如何运行
当一条任务被分发到某个 issue、并且该项目在领任务的守护进程上绑了 `local_directory` 时,守护进程会:
1. 再次校验路径(按上面那套规则)。
2. 以 symlink 解析后的真实路径为 key 取一把"目录锁" —— 即便两条路径走不同路由(一条经过 symlink一条直接指向同一个文件夹也会被串行化。
3. 把智能体的 `CLAUDE.md` / `AGENTS.md`(以及 `.multica/project/resources.json`**写进你的目录**。智能体就在那里工作,跟你自己打开这个文件夹后启动它是一样的体验。
4. Multica 自己的运行时产物(`output/`、`logs/`、`.gc_meta.json`)放在 **你目录之外** 的一个独立 envRoot 里。
如果第一条任务还在跑、第二条针对同一目录的任务又来了,第二条会停在 **Waiting for local directory等待本地目录释放** 状态。这个状态在所有相关 UI 都看得见 —— 聊天里的任务状态条、智能体横幅、执行记录、活动指示器;等待中的任务会被计入该智能体的"排队"占用。取消等待中的任务会立即释放它的槽位;取消正在跑的那条会让下一条立即顶上。
等待没有超时 —— 一条等待中的任务会一直等到锁释放、或者被用户/智能体取消为止。
### Multica 会写、不会动什么
- **会写入** `CLAUDE.md` / `AGENTS.md`(或对应 provider 的等价文件)以及 `.multica/project/resources.json` 到目录根 —— 这样智能体才有自己的元 skill 和资源清单。如果不想把这些提交到 git请加进 `.gitignore`。
- **会写入** 智能体决定做出的所有代码改动 —— 跟你自己在本地跑这个智能体没有区别。
- **永远不会物理删除** 你的目录或里面任何内容。垃圾回收对路径是有判断的:对 `local_directory` 的 envRoot它只会清自己挂在 `workspacesRoot` 下的 `output/` 与 `logs/`**完全不碰** 你的目录。
### v1 阶段的限制(后续会逐步收敛)
第一版有意比 `github_repo` 留了更多锋利的边。下面这份清单会随着后续工作逐步缩短;这里列出的是 **今天**的真实行为:
- **不会自动切分支。** 智能体跑在你当前 checkout 的分支上。如果分支选择重要,请在分发任务前自己切换好。
- **不会保护脏工作区,也不会自动 commit。** 未提交的改动智能体看得到、可能就地修改,不会被 stash。请把这个目录当作真实的工作区来对待重要的运行前自己先 commit。
- **不会自动开 PR。** 任务结束后,改动就停在它实际做出来的分支上 —— Multica 不会 push、不会开 PR。需要 push、需要 PR 的话请自己来。
- **`waiting_local_directory` 只显示状态,不显示是谁占着。** 这个徽标只告诉你"任务在等",不会告诉你具体是哪条任务、哪个路径正在持有目录。
这些都列在 local-directory 工作的"智能体任务生命周期"后续项里;在那之前,请把 `local_directory` 当作"智能体在你的目录里跑,跟你自己跑没区别"来用。
## 在创建项目时挂仓库
在 **网页端** 或 **桌面端**,打开 *新建项目* 时Status / Priority / Lead 旁边多了一个 **Repos** 标签。选已绑到工作区的仓库(或者粘贴一个临时 URL项目创建的瞬间它们就以 `github_repo` 资源的形式挂上去。
通过 **CLI**
```bash
# 创建 + 挂资源一步到位。资源是在同一个事务里挂上的 ——
# 任何一个资源不合法都会让整个 create 回滚,绝不会出现
# "项目建好了但资源只挂了一半"的状态。
multica project create \
--title "Agent UX 2026" \
--repo https://github.com/multica-ai/multica
# 后续管理资源
multica project resource list <project-id>
multica project resource add <project-id> --type github_repo --url <url>
multica project resource remove <project-id> <resource-id>
# 任何服务器认识的 resource_type 都可以用这个通用入口 ——
# 加新类型时不需要改 CLI
multica project resource add <project-id> \
--type notion_page \
--ref '{"page_id":"…","title":"…"}'
```
`--repo` 可以重复指定;每个值会被当作一条单独的 `github_repo` 资源挂上去。
## 运行时智能体看到什么
当守护进程为一个项目内的 issue 启动智能体时,会发生两件事:
### 1. `.multica/project/resources.json`
API 响应的结构化透传,写进智能体的工作目录里:
```json
{
"project_id": "…",
"project_title": "Agent UX 2026",
"resources": [
{
"id": "…",
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/multica-ai/multica",
"default_branch_hint": "main"
}
}
]
}
```
Skill、辅助脚本、或者智能体自己想拿到本次运行**精确的**资源列表时,解析这个文件即可。
### 2. 元 skill prompt 里的 "Project Context" 段
智能体的 `CLAUDE.md` / `AGENTS.md`(按 provider 不同)里多出一段人类可读的摘要:
```
## Project Context
This issue belongs to **Agent UX 2026**.
Project resources (also written to `.multica/project/resources.json`):
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
Resources are pointers — open them only when relevant to the task. For
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
```
这段文字故意做得很精简:完整 payload 已经写到磁盘上了prompt 只是给智能体一个定位 —— 让它知道项目存在、知道挂了什么。
### 失败时
资源拉取是 **best-effort**。如果 API 调失败了prompt 里的项目段会省掉、文件也不写,但任务照常启动 —— 智能体永远不会因为缺少项目上下文而卡住。
## 加一种新资源类型
抽象的全部目的就是让加新类型变便宜。完整流程:
1. **服务端校验器**`server/internal/handler/project_resource.go`)—— 在 `validateAndNormalizeResourceRef` 里加一个 case解析并标准化新的 payload。
2. **守护进程元 skill 格式化器**`server/internal/daemon/execenv/runtime_config.go`)—— 在 `formatProjectResource` 里加一个 case让智能体 prompt 把新类型渲染成一条可读的列表项。
3. **TypeScript 类型**`packages/core/types/project.ts`)—— 给 `ProjectResourceType` 加值,并加上对应的 payload 接口。
4. **UI 渲染**`packages/views/projects/components/project-resources-section.tsx`)—— 在 `ResourceRow` 里加一个 case 渲染新类型。
**不需要 schema 迁移、不需要新的 sqlc query、不需要新的 endpoint、也不需要改 CLI** —— CLI 的通用 `--ref '<json>'` 选项接受校验器认识的任何 payload第 0 天就能用。(之后如果想加按类型的 CLI 快捷参数,可以加,但不是必须。)
同一张 `project_resource` 表、同一套 CRUD 调用,处理所有类型。
## 工作区仓库 vs. 项目仓库
智能体看到的仓库列表(`CLAUDE.md` / `AGENTS.md` 里的 `## Repositories` 段)由守护进程的领任务逻辑按以下优先级决定:
- **项目挂了至少一条 `github_repo` 资源** → 仅显示项目挂的仓库。工作区绑定的仓库会被隐藏,避免智能体猜哪一个属于这个 issue。
- **项目没挂 `github_repo` 资源(或 issue 根本不在项目里)** → 回落到工作区的仓库列表,跟以前一样。
这样可以把智能体的工作集压紧:项目把仓库说清楚了,那就是权威答案。而 `.multica/project/resources.json` 里始终带着完整列表,需要查看全部资源的 skill 仍然能拿到。
守护进程在 checkout 这一侧也是配套的:当任务带着项目级别的 `github_repo` URL 进来时,这些 URL 会被合并进每工作区的白名单,并在智能体启动前同步进本地仓库缓存。所以即便某个项目仓库 URL 没被绑到工作区,`multica repo checkout` 也能正常处理它,不会以"未配置"为由拒绝。白名单的划分只是内部实现:工作区绑定的 URL 和任务级别的 URL 分开记录,工作区仓库列表的刷新不会顺手吊销某条项目 URL。
## 当前**不**做的事
- **跨项目共享**。每条资源现在只能挂在一个项目上。
- **按 skill 划定资源可见性**。所有资源对智能体本次运行里的每个 skill 都可见;按类型过滤是后续工作。
- **缓存 / 同步**。`github_repo` 目前只是元数据 —— checkout 仍然按需走 `multica repo checkout`。Notion / Google Docs 之类的文档文本缓存会随对应类型一起到位。
这些是有意的留白 —— 第一版的目标是用最小的活动部件去验证这个抽象。

View File

@@ -1,11 +1,11 @@
---
title: AI coding tools matrix
description: Multica supports 11 AI coding tools; they implement the same interface, but the capability details diverge significantly.
description: Multica supports 12 AI coding tools; they implement the same interface, but the capability details diverge significantly.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica ships with built-in support for **11 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
Multica ships with built-in support for **12 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create).
@@ -13,6 +13,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
| Tool | Vendor | Session resumption | MCP | Skill injection path | Model selection |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Managed inside the Antigravity CLI itself |
| **Claude Code** | Anthropic | ✅ | **✅ (the only one that actually uses it)** | `.claude/skills/` | Static + flag |
| **Codex** | OpenAI | ⚠️ Code exists but unreachable | ❌ | `$CODEX_HOME/skills/` | Static |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
@@ -27,6 +28,10 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
## What each tool is for
### Antigravity
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. There is no `--model` flag — model selection lives inside the Antigravity CLI settings, so Multica disables the per-agent model picker for this provider. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
### Claude Code
From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 11 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
@@ -77,7 +82,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
| Status | Tools | Meaning |
|---|---|---|
| ✅ Really works | Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
| ✅ Really works | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
| ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** |
| ❌ None | Gemini | The CLI has no resume mechanism |
@@ -85,7 +90,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
## MCP configuration: only Claude Code actually reads it
**Of the 11 tools, only Claude Code actually consumes `mcp_config`**. The other 10 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
**Of the 12 tools, only Claude Code actually consumes `mcp_config`**. The other 11 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
<Callout type="warning">
If you set `mcp_config` in an agent configuration but pick a tool other than Claude Code, your MCP servers have **no effect** on that agent. MCP integration currently covers Claude Code only.
@@ -105,6 +110,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
| Kiro CLI | `.kiro/skills/` | ✅ Native |
| OpenCode | `.opencode/skills/` | ✅ Native |
| Pi | `.pi/skills/` | ✅ Native |
| Antigravity | `.agents/skills/` | ✅ Native (inherits Gemini CLI's workspace layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)) |
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
| OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback |
@@ -118,3 +124,4 @@ For creating and using skills, see [Skills](/skills).
- [Creating and configuring agents](/agents-create) — pick a tool for your agent
- [Tasks](/tasks) — task lifecycle and session-resumption mechanics
- [Daemon and runtimes](/daemon-runtimes) — where the tools run and how they connect to Multica
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 12 supported tools

View File

@@ -1,11 +1,11 @@
---
title: AI 编程工具对照
description: Multica 支持 11 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
description: Multica 支持 12 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
创建智能体时挑选工具的指引见 [创建和配置智能体](/agents-create)。
@@ -13,6 +13,7 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
| 工具 | 厂商 | 会话恢复 | MCP | Skill 注入路径 | 模型选择 |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅(`--conversation <id>`| ❌ | `.agents/skills/` | 由 Antigravity CLI 自己管理 |
| **Claude Code** | Anthropic | ✅ | **✅(唯一真用)** | `.claude/skills/` | 静态 + flag |
| **Codex** | OpenAI | ⚠️ 代码存在但不可达 | ❌ | `$CODEX_HOME/skills/` | 静态 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
@@ -27,9 +28,13 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
## 每款工具的定位
### Antigravity
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。CLI 没有 `--model` flag——模型选择保存在 Antigravity 自己的设置里,因此 Multica 禁用了这款工具的模型选择控件。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
### Claude Code
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **11 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **12 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
### Codex
@@ -77,7 +82,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| 状态 | 工具 | 含义 |
|---|---|---|
| ✅ 真用 | Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id会从上次上下文接着继续 |
| ✅ 真用 | Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id会从上次上下文接着继续 |
| ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到Codex 静默回落、Cursor session id 不回传)—— **当作不支持** |
| ❌ 无 | Gemini | CLI 无 resume 机制 |
@@ -85,7 +90,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
## MCP 配置:只有 Claude Code 真的读
**11 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 10 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
**12 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 11 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
<Callout type="warning">
如果你在智能体配置里设置了 `mcp_config`,但选了 Claude Code 之外的工具,你的 MCP server 对这个智能体**没有效果**。目前的 MCP 集成只覆盖 Claude Code。
@@ -105,6 +110,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
| OpenCode | `.opencode/skills/` | ✅ 原生 |
| Pi | `.pi/skills/` | ✅ 原生 |
| Antigravity | `.agents/skills/` | ✅ 原生(沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration)|
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
| OpenClaw | `.agent_context/skills/` | ⚠️ 通用 fallback |

View File

@@ -82,17 +82,31 @@ Two delivery backends are supported — pick whichever fits your network:
**Option B — SMTP relay (internal networks / on-premise):**
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set.
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set, so verification and invite mail stays on the internal relay. Port 465 (SMTPS / implicit TLS) is not currently supported — use 25 or 587.
For **anonymous Exchange internal relay (port 25)** — the host is trusted by IP and submits without credentials:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`.
For **authenticated submission (port 587, STARTTLS)** — the relay requires a service account; STARTTLS is upgraded automatically when advertised:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for private CA / self-signed
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`. On restart, the backend prints which provider it picked (`EmailService: SMTP relay …` / `Resend API` / `DEV mode`) — credentials are never logged, so this line is safe to share when asking for help.
For more auth configuration (OAuth, signup allowlist) and the full SMTP variable reference, see [Auth setup](/auth-setup) and [Environment variables → Email](/environment-variables#email-configuration).

View File

@@ -81,17 +81,31 @@ make selfhost
**Option B — SMTP relay内网/自部署):**
适合内网无法访问 `api.resend.com`或已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend。
适合内网无法访问 `api.resend.com`,或已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend,验证码和邀请邮件不会走外部 provider。**暂不支持** 465SMTPS / 隐式 TLS请使用 25 或 587
**匿名 Exchange 内部 relay端口 25** —— 主机按 IP 被信任,不需要凭据:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。
**认证提交(端口 587STARTTLS** —— relay 需要 service account服务端 advertise STARTTLS 时自动升级:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。重启时 backend 会打印当前选择的 provider`EmailService: SMTP relay …` / `Resend API` / `DEV mode`),密码不会被记录,所以这行截图给同事是安全的。
更多 auth 配置OAuth、注册白名单以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。

View File

@@ -31,7 +31,7 @@ Both individual files and whole skill packs have size caps (single-file cap arou
Once imported, a skill has to be **attached to a specific agent** to take effect. One agent can have multiple skills attached, and one skill can be attached to multiple agents.
After attaching, the agent picks up its skills the next time it starts a task — each AI coding tool has its own skill discovery path (Claude Code uses `.claude/skills/`, Cursor uses `.cursor/skills/`, etc.), and Multica drops files in the right place automatically. **However, three tools (Gemini, Hermes, OpenClaw) currently use the generic fallback path `.agent_context/skills/` — whether these tools actually read skills from that path depends on the tool itself.** Full path mapping and the native-discovery vs. fallback distinction is in [AI coding tools comparison → Where skill files go](/providers#where-skill-files-go).
After attaching, the agent picks up its skills the next time it starts a task — each AI coding tool has its own skill discovery path (Claude Code uses `.claude/skills/`, Cursor uses `.cursor/skills/`, Antigravity uses `.agents/skills/`, etc.), and Multica drops files in the right place automatically. **However, three tools (Gemini, Hermes, OpenClaw) currently use the generic fallback path `.agent_context/skills/` — whether these tools actually read skills from that path depends on the tool itself.** Full path mapping and the native-discovery vs. fallback distinction is in [AI coding tools comparison → Where skill files go](/providers#where-skill-files-go).
After you edit a skill's contents, **only newly created tasks pick up the new version** — tasks already running continue with the old skill.
@@ -64,4 +64,4 @@ By now you know what an agent is, how to create one, and how to attach skills. T
- [Daemon and runtimes](/daemon-runtimes) — where agents actually run, and how to tell online from offline
- [Executing tasks](/tasks) — the full lifecycle of one "agent work session"
- [AI coding tools comparison](/providers) — full comparison of all 11 tools (including each one's skill injection path)
- [AI coding tools comparison](/providers) — full comparison of all 12 tools (including each one's skill injection path)

View File

@@ -31,7 +31,7 @@ Multica 支持两种 Skill 来源:
Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能体能挂多个 Skill一个 Skill 也能挂到多个智能体。
挂上之后,智能体下次开工时会自动拿到挂着的 Skill——不同 AI 编程工具有各自的 Skill 发现路径Claude Code 是 `.claude/skills/`、Cursor 是 `.cursor/skills/` 等Multica 会自动放到对的位置。**但有 3 款工具Gemini / Hermes / OpenClaw当前走的是通用 fallback 路径 `.agent_context/skills/`——这些工具能否真的从这里读到 skill取决于工具本身是否支持**。完整路径对照和原生发现 vs fallback 的区分见 [AI 编程工具对照 → skill 文件该放哪儿](/providers#skill-文件该放哪儿)。
挂上之后,智能体下次开工时会自动拿到挂着的 Skill——不同 AI 编程工具有各自的 Skill 发现路径Claude Code 是 `.claude/skills/`、Cursor 是 `.cursor/skills/`、Antigravity 是 `.agents/skills/`Multica 会自动放到对的位置。**但有 3 款工具Gemini / Hermes / OpenClaw当前走的是通用 fallback 路径 `.agent_context/skills/`——这些工具能否真的从这里读到 skill取决于工具本身是否支持**。完整路径对照和原生发现 vs fallback 的区分见 [AI 编程工具对照 → skill 文件该放哪儿](/providers#skill-文件该放哪儿)。
修改 Skill 的内容后,**只有之后新创建的任务会拿到新版本**——正在跑的任务继续用旧版 Skill。
@@ -64,4 +64,4 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
- [守护进程与运行时](/daemon-runtimes) —— 智能体到底跑在哪、怎么判断在线 / 离线
- [执行任务](/tasks) —— 一次"智能体工作"的完整生命周期
- [AI 编程工具对照](/providers) —— 11 款工具的完整对比(含每款的 Skill 注入路径)
- [AI 编程工具对照](/providers) —— 12 款工具的完整对比(含每款的 Skill 注入路径)

View File

@@ -105,7 +105,7 @@ Multica pins the session ID **twice** during a task: once at the start (when the
But **which AI coding tools actually support this** varies a lot:
- ✅ **Real support** — Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ✅ **Real support** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ⚠️ **Code exists but unusable** — Codex, Cursor
- ❌ **No support** — Gemini
@@ -113,5 +113,5 @@ See [Providers Matrix → Session resumption](/providers#session-resumption-who-
## Next
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools (including the exact session-resumption status)
- [Providers Matrix](/providers) — capability differences across the 12 AI coding tools (including the exact session-resumption status)
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task

View File

@@ -105,7 +105,7 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始AI
但**哪些 AI 编程工具真的支持**差别很大:
- ✅ **真支持**——Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
- ✅ **真支持**——Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
- ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor
- ❌ **不支持**——Gemini
@@ -113,5 +113,5 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始AI
## 下一步
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
- [Providers Matrix](/providers) —— 12 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Autopilots](/autopilots) —— 触发执行任务的四种方式

View File

@@ -89,6 +89,20 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
**Symptom**: after submitting an email during sign-in or invite acceptance, neither the inbox nor the spam folder has the verification code.
**First, confirm which provider the server thinks is active.** At startup the backend prints one of:
- `EmailService: SMTP relay <host>:<port> from=<addr>` — using SMTP (`SMTP_HOST` non-empty wins over Resend)
- `EmailService: Resend API from=<addr>` — using Resend
- `EmailService: DEV mode — codes printed to stdout …` — no provider configured
```bash
docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"
```
If the line you expected isn't there, the environment didn't reach the process — check `.env` and `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`. Credentials are never logged on this startup line.
### When Resend is the active provider
**Likely causes**:
1. **`RESEND_API_KEY` not set** — the server silently falls back and **writes the code to its own stdout** without error. Easy to trip over in production
@@ -108,6 +122,34 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
- Domain not verified → run the DNS verification flow in the Resend console (add SPF / DKIM records)
- In an emergency (internal testing) → copy the code printed under `[DEV]` from the server logs
### When SMTP is the active provider
The SMTP path wraps every failure with the stage it failed at, so the server logs already tell you where the relay rejected the session. Grep for `"failed to send verification email"` / `"failed to send invitation email"` and check the wrapped error:
| Logged error | What it means | How to fix |
|---|---|---|
| `smtp dial <host>:<port>: dial tcp …: connect: connection refused` / `i/o timeout` | The backend container can't reach the relay — wrong host, wrong port, firewall, or the relay isn't listening | Verify `SMTP_HOST` / `SMTP_PORT` resolve from inside the container (`docker compose -f docker-compose.selfhost.yml exec backend nslookup <host>` and `nc -vz <host> <port>`); open the firewall from the host running Multica to the relay |
| `smtp starttls: x509: certificate signed by unknown authority` (or `certificate is not valid for any names`) | The relay uses a private CA / self-signed cert and the container's trust store rejects it | Either install the CA into the container, or set `SMTP_TLS_INSECURE=true` only after confirming the relay is reachable on a trusted segment |
| `smtp auth: 535 5.7.8 Authentication credentials invalid` (or `534`/`530`) | `SMTP_USERNAME` / `SMTP_PASSWORD` are wrong, or the relay requires a different auth mechanism than `PLAIN` | Re-confirm the service-account credentials with your mail admin; for Exchange anonymous internal relay leave both empty (`SMTP_USERNAME=`, `SMTP_PASSWORD=`) |
| `smtp MAIL FROM: 550 5.7.1 Client does not have permissions to send as this sender` | The relay won't accept `RESEND_FROM_EMAIL` as the envelope sender — typical Exchange "anonymous users not allowed" or DMARC alignment issue | Set `RESEND_FROM_EMAIL` to a domain the relay accepts; on Exchange, grant the source IP `ms-Exch-SMTP-Accept-Any-Sender` on the receive connector |
| `smtp RCPT TO <addr>: 550 5.7.1 Unable to relay` | The relay's receive connector doesn't allow your subnet to relay to external recipients (most common for anonymous internal relays talking to outside domains) | Either restrict invites to internal recipients, or add the Multica host's subnet to the Exchange "Anonymous Users → Relay" permission list |
| `smtp DATA` / `smtp write body` / `smtp end data` | Session was accepted but the relay dropped the body — usually message-size limits, content filtering, or a connection reset mid-stream | Check the relay's logs for the same `Message-ID` (logged as `<unixnano>@<host>`); raise the message size limit if needed |
`MAIL FROM`, `RCPT TO`, and `DATA` errors are always logged with the relay's response code so you can match them against Exchange / Postfix logs on the other side. Verification codes and invite tokens are **never** included in the wrapped error.
**How to diagnose**:
- Grep `"EmailService: SMTP relay"` once at startup, then `"failed to send"` for runtime failures
- From inside the backend container, sanity-check connectivity: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'`
- Confirm the env reached the process: `docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_` (password will be in the output — only run on a trusted shell)
**How to fix**:
- Wrong host / port → adjust `SMTP_HOST` / `SMTP_PORT` and restart the backend; for the supported relay modes see [Auth setup → Option B: SMTP relay](/auth-setup)
- Cert mismatch → install the relay's CA into the container, or temporarily `SMTP_TLS_INSECURE=true` on a trusted segment
- Auth failure → re-check credentials; for anonymous internal relay leave `SMTP_USERNAME` and `SMTP_PASSWORD` empty
- `Unable to relay` → either restrict to internal recipients or grant the Multica host's IP relay permission on the Exchange receive connector
## Fixed local test code doesn't work
**Symptom**: on a self-hosted instance, you try to sign in with a fixed local test code such as `888888` and it's rejected with `invalid or expired code`.

View File

@@ -89,6 +89,20 @@ multica issue show <issue-id> # 看 task 历史
**症状**:登录或邀请时提交邮箱后,收件箱(和垃圾邮件)里都没有验证码邮件。
**先确认 server 自己认为在用哪个 provider。** 启动时 backend 会打印这三种之一:
- `EmailService: SMTP relay <host>:<port> from=<addr>` —— 走 SMTP`SMTP_HOST` 非空时优先级高于 Resend
- `EmailService: Resend API from=<addr>` —— 走 Resend
- `EmailService: DEV mode — codes printed to stdout …` —— 没配任何 provider
```bash
docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"
```
如果应该出现的那行没出现,说明环境变量没进到进程 —— 检查 `.env` 和 `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`。这行启动日志里**不会**打印任何密码。
### Resend 是当前 provider
**可能原因**
1. **`RESEND_API_KEY` 没配** —— server 会静默回落,**把验证码打到自己的 stdout 里**,不报错。生产部署很容易踩
@@ -108,6 +122,34 @@ multica issue show <issue-id> # 看 task 历史
- 域名没验证 → Resend console 里走 DNS 验证流程(加 SPF / DKIM 记录)
- 紧急情况下(如内部测试)→ 从 server 日志里抄 `[DEV]` 打印出的验证码
### SMTP 是当前 provider
SMTP 路径把每个失败都按阶段包装好,所以 server 日志已经告诉你 relay 在哪一步拒绝了会话。搜 `"failed to send verification email"` / `"failed to send invitation email"`,看里面包的具体错误:
| 错误日志 | 含义 | 怎么修 |
|---|---|---|
| `smtp dial <host>:<port>: dial tcp …: connect: connection refused` / `i/o timeout` | backend 容器连不上 relay —— host / port 错、防火墙挡了、或者 relay 没开 | 在容器里确认能解析和连通:`docker compose -f docker-compose.selfhost.yml exec backend nslookup <host>` 以及 `nc -vz <host> <port>`;放行从 Multica 主机到 relay 的网络 |
| `smtp starttls: x509: certificate signed by unknown authority`(或 `certificate is not valid for any names` | relay 用了私有 CA / 自签证书,容器的信任库不接受 | 要么把 CA 装进容器,要么在确认 relay 走的是可信网段后设 `SMTP_TLS_INSECURE=true` |
| `smtp auth: 535 5.7.8 Authentication credentials invalid`(或 `534`/`530` | `SMTP_USERNAME` / `SMTP_PASSWORD` 不对,或 relay 不接受 `PLAIN` 认证 | 找邮件管理员复核 service account 凭据Exchange 匿名内部 relay 应当把两者都留空 |
| `smtp MAIL FROM: 550 5.7.1 Client does not have permissions to send as this sender` | relay 不接受 `RESEND_FROM_EMAIL` 作为信封发件人 —— Exchange 常见 "anonymous users not allowed" 或 DMARC 对齐问题 | 把 `RESEND_FROM_EMAIL` 改成 relay 接受的域名Exchange 上给来源 IP 授 `ms-Exch-SMTP-Accept-Any-Sender` |
| `smtp RCPT TO <addr>: 550 5.7.1 Unable to relay` | relay 的 receive connector 不允许这个子网把邮件中继到外部收件人(匿名内部 relay 发给外部域时常见) | 邀请仅限内部域,或者把 Multica 主机的子网加进 Exchange "Anonymous Users → Relay" 权限列表 |
| `smtp DATA` / `smtp write body` / `smtp end data` | 会话被接受但 body 被丢 —— 通常是消息大小限制、内容过滤、或中途断连 | 在 relay 端按同一个 `Message-ID`(日志里是 `<unixnano>@<host>`)找上下文;必要时调大消息大小限制 |
`MAIL FROM` / `RCPT TO` / `DATA` 的错误日志里都带着 relay 返回的状态码,可以和 Exchange / Postfix 那边的日志对齐。验证码和邀请 token **不会**出现在这些包装的错误里。
**怎么查**
- 启动时搜 `"EmailService: SMTP relay"` 一次,运行时搜 `"failed to send"` 看具体阶段
- 在 backend 容器里测连通:`docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'`
- 确认环境变量真进到了进程:`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_`(这条会带出密码,仅在可信终端运行)
**怎么修**
- host / port 不对 → 改 `SMTP_HOST` / `SMTP_PORT` 后重启 backend支持的 relay 模式见 [登录与注册配置 → Option BSMTP relay](/auth-setup)
- 证书校验失败 → 把 relay 的 CA 装进容器,或在可信网段上临时 `SMTP_TLS_INSECURE=true`
- 认证失败 → 复核凭据;匿名内部 relay 应把 `SMTP_USERNAME` 和 `SMTP_PASSWORD` 都留空
- `Unable to relay` → 邀请仅限内部域,或在 Exchange receive connector 上给 Multica 主机授中继权限
## 固定本地测试验证码登不进去
**症状**:自部署实例,想用 `888888` 这类固定本地测试验证码登录,但被拒 `invalid or expired code`。

View File

@@ -21,6 +21,12 @@ android/
!data/
!data/**
# Re-ignore macOS metadata that the broader `!data/**` rule above would
# otherwise pull back in. The `.DS_Store` line at the top of this file is
# overridden by the negation, so we restate it here in last-match-wins
# position.
data/**/.DS_Store
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli

View File

@@ -30,6 +30,7 @@ const PAST_STATUS_ORDER: Record<AgentTask["status"], number> = {
completed: 2,
queued: 99,
dispatched: 99,
waiting_local_directory: 99,
running: 99,
};

View File

@@ -14,7 +14,7 @@
* — both surfaces produce canonical `[@name](mention://type/id)` markdown
* recognised by util.ParseMentions on the server.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
@@ -93,18 +93,16 @@ export default function NewIssueModal() {
createIssue,
]);
const headerRight = useMemo(() => {
function HeaderRight() {
return (
<SubmitIssueButton
disabled={!canSubmit}
loading={isSubmitting}
onPress={onSubmit}
/>
);
}
return HeaderRight;
}, [canSubmit, isSubmitting, onSubmit]);
const headerRight = useCallback(
() => (
<SubmitIssueButton
disabled={!canSubmit}
loading={isSubmitting}
onPress={onSubmit}
/>
),
[canSubmit, isSubmitting, onSubmit],
);
return (
<>

View File

@@ -133,6 +133,7 @@ function fallbackSummary(task: AgentTask): string {
const STATUS_LABEL: Record<AgentTask["status"], string> = {
queued: "Queued",
dispatched: "Starting",
waiting_local_directory: "Waiting for directory",
running: "Running",
completed: "Done",
failed: "Failed",
@@ -142,6 +143,7 @@ const STATUS_LABEL: Record<AgentTask["status"], string> = {
const STATUS_CLASS: Record<AgentTask["status"], string> = {
queued: "text-muted-foreground",
dispatched: "text-brand",
waiting_local_directory: "text-muted-foreground",
running: "text-brand",
completed: "text-muted-foreground",
failed: "text-destructive",

View File

@@ -74,7 +74,7 @@ function Text({
TextVariantProps & {
asChild?: boolean;
}) {
const textClass = React.useContext(TextClassContext);
const textClass = React.use(TextClassContext);
const Component = asChild ? Slot : RNText;
return (
<Component

View File

@@ -29,7 +29,7 @@
*/
import {
createContext,
useContext,
use,
useEffect,
useRef,
useState,
@@ -58,7 +58,7 @@ const RealtimeContext = createContext<WSClient | null>(null);
* (cold start, between workspace switches, signed out). Consumers must
* guard with `if (!ws) return` in their effect. */
export function useWSClient(): WSClient | null {
return useContext(RealtimeContext);
return use(RealtimeContext);
}
export function RealtimeProvider({ children }: { children: React.ReactNode }) {

View File

@@ -9,7 +9,7 @@
* `![]()` URL while rendering a comment and pass the array through so
* a left/right swipe walks the gallery.
*/
import { createContext, useContext, useState, type ReactNode } from "react";
import { createContext, use, useState, type ReactNode } from "react";
import ImageView from "react-native-image-viewing";
interface LightboxApi {
@@ -24,7 +24,7 @@ const LightboxContext = createContext<LightboxApi>({
});
export function useLightbox(): LightboxApi {
return useContext(LightboxContext);
return use(LightboxContext);
}
export function LightboxProvider({ children }: { children: ReactNode }) {

View File

@@ -0,0 +1,10 @@
import { BillingTestPage } from "@multica/views/billing";
// Account-level test page for the cloud-billing API surface. Despite
// living under [workspaceSlug] — that's where the dashboard layout
// requires every page to sit — none of the data here is workspace-
// scoped. The slug just keeps the route inside the authenticated
// shell.
export default function BillingRoute() {
return <BillingTestPage />;
}

View File

@@ -24,6 +24,7 @@ export function FAQSection() {
{t.faq.items.map((faq, i) => (
<div key={i}>
<button
type="button"
onClick={() => setOpenIndex(openIndex === i ? null : i)}
className="flex w-full items-start justify-between gap-4 py-6 text-left"
>

View File

@@ -238,6 +238,7 @@ function TeammatesVisual() {
<div className="relative">
<PropRow label="Status">
<button
type="button"
className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"
onClick={() => { setStatusOpen(!statusOpen); setPriorityOpen(false); }}
>
@@ -249,6 +250,7 @@ function TeammatesVisual() {
<div className="absolute left-0 top-full z-10 mt-1 w-44 overflow-hidden rounded-md border bg-popover shadow-md">
{statusCycle.map((s) => (
<button
type="button"
key={s}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors",
@@ -269,6 +271,7 @@ function TeammatesVisual() {
<div className="relative">
<PropRow label="Priority">
<button
type="button"
className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"
onClick={() => { setPriorityOpen(!priorityOpen); setStatusOpen(false); }}
>
@@ -280,6 +283,7 @@ function TeammatesVisual() {
<div className="absolute left-0 top-full z-10 mt-1 w-44 overflow-hidden rounded-md border bg-popover shadow-md">
{priorityCycle.map((p) => (
<button
type="button"
key={p}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors",
@@ -299,6 +303,7 @@ function TeammatesVisual() {
{/* Assignee — clickable to toggle picker */}
<PropRow label="Assignee">
<button
type="button"
className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"
onClick={() => { setPickerOpen(!pickerOpen); setStatusOpen(false); setPriorityOpen(false); }}
>
@@ -323,6 +328,7 @@ function TeammatesVisual() {
</div>
<div className="p-1">
<button
type="button"
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors",
!assignee.type && "bg-accent",
@@ -340,6 +346,7 @@ function TeammatesVisual() {
<div className="p-1 pt-0">
{allAssignees.filter((a) => a.type === "member").map((m) => (
<button
type="button"
key={m.id}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs hover:bg-accent transition-colors",
@@ -359,6 +366,7 @@ function TeammatesVisual() {
<div className="p-1 pt-0">
{allAssignees.filter((a) => a.type === "agent").map((a) => (
<button
type="button"
key={a.id}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs hover:bg-accent transition-colors",
@@ -446,6 +454,7 @@ function AutonomousVisual() {
if (item.type === "thinking") {
return (
<button
type="button"
key={i}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-info/5 transition-colors"
onClick={() => setExpanded(isExpanded ? null : i)}
@@ -460,6 +469,7 @@ function AutonomousVisual() {
if (item.type === "tool_use") {
return (
<button
type="button"
key={i}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-info/5 transition-colors"
onClick={() => setExpanded(isExpanded ? null : i)}
@@ -474,6 +484,7 @@ function AutonomousVisual() {
/* tool_result */
return (
<button
type="button"
key={i}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-accent/50 transition-colors"
onClick={() => setExpanded(isExpanded ? null : i)}
@@ -540,13 +551,14 @@ function SkillsVisual() {
<div className="w-[200px] shrink-0 border-r flex flex-col">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-xs font-semibold">Skills</span>
<button className="rounded p-0.5 text-muted-foreground hover:bg-accent transition-colors">
<button type="button" className="rounded p-0.5 text-muted-foreground hover:bg-accent transition-colors">
<Sparkles className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-hidden divide-y">
{mockSkills.map((skill, i) => (
<button
type="button"
key={skill.name}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors",
@@ -585,6 +597,7 @@ function SkillsVisual() {
<div className="py-1">
{mockFileTree.map((f) => (
<button
type="button"
key={f.name}
className={cn(
"flex w-full items-center gap-1.5 py-1 text-xs transition-colors",
@@ -806,6 +819,7 @@ function RuntimesVisual() {
<div className="flex-1 overflow-hidden">
{mockRuntimeList.map((rt, i) => (
<button
type="button"
key={rt.name}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-2.5 transition-colors",
@@ -860,6 +874,7 @@ function RuntimesVisual() {
<div className="flex items-center gap-1">
{(["7d", "30d", "90d"] as const).map((range) => (
<button
type="button"
key={range}
onClick={() => setTimeRange(range)}
className={cn(
@@ -996,6 +1011,7 @@ export function FeaturesSection() {
<div className="sticky top-28 flex flex-col gap-0 py-28">
{features.map((f, i) => (
<button
type="button"
key={f.label}
onClick={() => scrollToPanel(i)}
className={cn(

View File

@@ -100,6 +100,7 @@ export function LandingFooter() {
<div className="flex items-center">
{locales.map((l, i) => (
<button
type="button"
key={l}
onClick={() => setLocale(l)}
className={cn(

View File

@@ -2,8 +2,8 @@
import {
createContext,
use,
useCallback,
useContext,
useMemo,
useState,
useTransition,
@@ -73,7 +73,7 @@ export function LocaleProvider({
}
export function useLocale() {
const ctx = useContext(LocaleContext);
const ctx = use(LocaleContext);
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
return ctx;
}

View File

@@ -101,7 +101,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
label: "RUNTIMES",
title: "One dashboard for all your compute",
description:
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects 11 supported coding tools on your machine.",
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects 12 supported coding tools on your machine.",
cards: [
{
title: "Unified runtime panel",
@@ -116,7 +116,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{
title: "Auto-detection on first run",
description:
"Multica scans for 11 supported coding tools \u2014 Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi \u2014 and registers a runtime for each one it finds.",
"Multica scans for 12 supported coding tools \u2014 Antigravity, Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi \u2014 and registers a runtime for each one it finds.",
},
],
},
@@ -136,7 +136,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{
title: "Install the CLI & connect your machine",
description:
"Run multica setup \u2014 it walks you through OAuth, starts the daemon, and scans for the 11 supported coding tools (Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi). Whichever ones you already have installed get registered as runtimes automatically.",
"Run multica setup \u2014 it walks you through OAuth, starts the daemon, and scans for the 12 supported coding tools (Antigravity, Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi). Whichever ones you already have installed get registered as runtimes automatically.",
},
{
title: "Create your first agent",
@@ -192,7 +192,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{
question: "What coding agents does Multica support?",
answer:
"Multica supports 11 coding tools out of the box: Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since it's open source, you can also add your own backends.",
"Multica supports 12 coding tools out of the box: Antigravity, Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since it's open source, you can also add your own backends.",
},
{
question: "Do I need to self-host, or is there a cloud version?",
@@ -292,6 +292,59 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.11",
date: "2026-05-28",
title: "Antigravity CLI Support",
changes: [],
features: [
"Antigravity CLI is now a supported coding runtime",
"Agent settings now include MCP configuration for Hermes, Kimi, and Kiro",
"Self-hosted admins can turn off self-service workspace creation",
"Desktop local runtimes can renew access tokens before they expire",
],
improvements: [
"Helm charts can be published to GHCR, and email setup docs are clearer",
"Task transcripts show shorter, safer working-folder labels",
"New Issues stay at the top in manual boards, and deleted Issues stay out of recents",
"Local runtime machines are grouped by device name",
],
fixes: [
"Terminal task completion retries brief callback failures",
"Local-directory runs preserve existing CLAUDE.md, AGENTS.md, and GEMINI.md files",
"Windows Pi runs keep multi-line prompts intact",
"Provider logos render consistently",
"Daemon cleanup skips incomplete parent-task metadata",
],
},
{
version: "0.3.10",
date: "2026-05-27",
title: "Local Working Directories",
changes: [],
features: [
"Projects can now use a local working directory on Desktop, so tasks can run in an existing folder while Multica shows when another task is waiting for that directory",
"Autopilot webhook triggers can now filter incoming events and actions before work starts, with docs linked directly from the setup flow",
"Swimlane views can group Issues by parent Issue, project, or assignee, making large boards easier to slice by how the team plans work",
"Comments now support selecting multiple attachments and keeping, removing, or replacing attachments while editing",
],
improvements: [
"Chinese product copy is more consistent across navigation, settings, search, and runtime screens",
"The frontend codebase received accessibility and React cleanup across common screens, and mobile checks now run only when mobile code changes",
"CLI list output is cleaner for automated readers, and squad lists now show member counts when available",
],
fixes: [
"Swimlane lanes no longer appear empty when nested Issues load beyond the first page",
"Creating a sub-Issue with an intelligent agent now preserves the parent Issue relationship",
"Scheduled autopilot titles and descriptions now use the trigger's configured timezone",
"Comment editors avoid duplicate live-update flashes, keep text after failed sends, and apply mention behavior consistently when edited",
"Code blocks without a detected language now render their text instead of an empty block",
"Desktop and web avatars now resolve relative upload paths correctly",
"Codex, Cursor, and Hermes runtime handling received fixes for Desktop discovery, command arguments, usage attribution, and clearer stalled-run diagnostics",
"Private intelligent agents can no longer be triggered by plain comments from users who cannot access them",
"Several UI details were cleaned up across GitHub settings, project creation, board sorting, and agent skill screens",
],
},
{
version: "0.3.9",
date: "2026-05-26",

View File

@@ -101,7 +101,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
label: "\u8fd0\u884c\u65f6",
title: "\u4e00\u4e2a\u63a7\u5236\u53f0\u7ba1\u7406\u6240\u6709\u7b97\u529b",
description:
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u3002",
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 12 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u3002",
cards: [
{
title: "\u7edf\u4e00\u8fd0\u884c\u65f6\u9762\u677f",
@@ -116,7 +116,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{
title: "\u9996\u6b21\u542f\u52a8\u81ea\u52a8\u6ce8\u518c",
description:
"Multica \u626b\u63cf\u672c\u673a\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u2014\u2014Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u2014\u2014\u5e76\u4e3a\u6bcf\u6b3e\u5df2\u5b89\u88c5\u7684\u5de5\u5177\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002",
"Multica \u626b\u63cf\u672c\u673a\u7684 12 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u2014\u2014Antigravity\u3001Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u2014\u2014\u5e76\u4e3a\u6bcf\u6b3e\u5df2\u5b89\u88c5\u7684\u5de5\u5177\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002",
},
],
},
@@ -136,7 +136,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
description:
"运行 multica setup——它会引导你完成 OAuth 登录、启动守护进程、并扫描 11 款支持的 AI 编程工具Claude Code、Codex、Cursor、Copilot、Gemini、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi。本机已安装的工具会被自动注册成运行时。",
"运行 multica setup——它会引导你完成 OAuth 登录、启动守护进程、并扫描 12 款支持的 AI 编程工具(Antigravity、Claude Code、Codex、Cursor、Copilot、Gemini、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi。本机已安装的工具会被自动注册成运行时。",
},
{
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a 智能体",
@@ -192,7 +192,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 智能体\uff1f",
answer:
"Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 11 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aClaude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
"Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 12 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aAntigravity\u3001Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
},
{
question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f",
@@ -292,6 +292,59 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.11",
date: "2026-05-28",
title: "Antigravity CLI 支持",
changes: [],
features: [
"Antigravity CLI 现在可作为编码运行环境使用",
"智能体详情页新增 MCP 配置,可用于 Hermes、Kimi 和 Kiro",
"自托管管理员可以关闭自助创建工作区",
"桌面端本机运行环境会在访问令牌过期前自动续期",
],
improvements: [
"Helm Chart 可发布到 GHCR邮件配置文档更清晰",
"任务记录会显示更短、更安全的工作目录",
"手动排序时,新 Issue 会留在列顶部,已删除 Issue 不再回到最近列表",
"本机运行环境会按设备名合并同一台机器",
],
fixes: [
"任务完成回传遇到短暂错误时会重试",
"本地目录运行不会覆盖已有的 CLAUDE.md、AGENTS.md 或 GEMINI.md",
"Windows 上的 Pi 会保留多行提示词",
"运行环境 Logo 显示更稳定",
"本机运行服务清理时会跳过不完整父级信息",
],
},
{
version: "0.3.10",
date: "2026-05-27",
title: "本地工作目录",
changes: [],
features: [
"项目现在可以在桌面端绑定本地工作目录,智能体任务会直接在现有文件夹中运行,并在目录被占用时显示等待状态",
"自动任务的 Webhook 触发器可以按事件和动作先过滤请求,配置界面也可以直接打开对应文档",
"泳道视图可以按父 Issue、项目或负责人分组大型看板更容易按团队计划方式查看",
"评论现在支持一次选择多个附件,也可以在编辑时保留、移除或替换附件",
],
improvements: [
"中文界面文案在导航、设置、搜索和运行时页面里更一致",
"常用界面做了无障碍和 React 清理,移动端检查也只在移动端代码变化时运行",
"命令行列表输出对自动化读取更安静,小队列表也会显示可用的成员数量",
],
fixes: [
"泳道里超过第一页才加载到的嵌套 Issue 不再显示为空",
"从子 Issue 入口切到用智能体创建时,会继续保留父 Issue 关系",
"定时自动任务生成标题和描述时,会按触发器配置的时区显示日期",
"评论输入框不再因为实时更新闪出重复内容,发送失败后会保留文字,编辑评论时也会正确处理提及",
"没有识别出语言的代码块也会正常显示文本,不再出现空白代码块",
"桌面端和网页端的头像现在可以正确加载相对路径上传文件",
"Codex、Cursor 和 Hermes 运行处理修复了桌面端发现、命令参数、用量归属和卡住诊断提示",
"无权限访问私有智能体的用户,不能再用普通评论触发它们",
"GitHub 设置、项目创建、看板排序和智能体技能页面清理了若干界面细节",
],
},
{
version: "0.3.9",
date: "2026-05-26",

View File

@@ -1,3 +1,4 @@
{{- $backendImageTag := default .Chart.AppVersion .Values.images.backend.tag -}}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -52,7 +53,7 @@ spec:
spec:
containers:
- name: backend
image: "{{ .Values.images.backend.repository }}:{{ .Values.images.backend.tag }}"
image: "{{ .Values.images.backend.repository }}:{{ $backendImageTag }}"
imagePullPolicy: {{ .Values.images.backend.pullPolicy }}
ports:
- containerPort: 8080

View File

@@ -16,6 +16,7 @@ data:
ALLOW_SIGNUP: {{ .Values.backend.config.allowSignup | quote }}
ALLOWED_EMAILS: {{ .Values.backend.config.allowedEmails | quote }}
ALLOWED_EMAIL_DOMAINS: {{ .Values.backend.config.allowedEmailDomains | quote }}
DISABLE_WORKSPACE_CREATION: {{ .Values.backend.config.disableWorkspaceCreation | quote }}
GOOGLE_CLIENT_ID: {{ .Values.backend.config.googleClientId | quote }}
GOOGLE_REDIRECT_URI: {{ .Values.backend.config.googleRedirectUri | quote }}
S3_BUCKET: {{ .Values.backend.config.s3Bucket | quote }}

View File

@@ -1,3 +1,4 @@
{{- $frontendImageTag := default .Chart.AppVersion .Values.images.frontend.tag -}}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -20,7 +21,7 @@ spec:
spec:
containers:
- name: frontend
image: "{{ .Values.images.frontend.repository }}:{{ .Values.images.frontend.tag }}"
image: "{{ .Values.images.frontend.repository }}:{{ $frontendImageTag }}"
imagePullPolicy: {{ .Values.images.frontend.pullPolicy }}
ports:
- containerPort: 3000

View File

@@ -4,11 +4,14 @@
images:
backend:
repository: ghcr.io/multica-ai/multica-backend
tag: latest
# Empty defaults to Chart.appVersion. Released OCI charts set appVersion to
# the Git tag (for example v0.3.5), so installs use matching app images.
tag: ""
pullPolicy: IfNotPresent
frontend:
repository: ghcr.io/multica-ai/multica-web
tag: latest
# Empty defaults to Chart.appVersion. Set explicitly to override.
tag: ""
pullPolicy: IfNotPresent
postgres:
repository: pgvector/pgvector
@@ -69,6 +72,10 @@ backend:
allowSignup: true
allowedEmails: ""
allowedEmailDomains: ""
# Self-host gate (#3433): set true to make POST /api/workspaces 403 for
# every caller. Bootstrap the workspace with this false, then flip to
# true so users can only join via invitation.
disableWorkspaceCreation: false
googleClientId: ""
googleRedirectUri: http://multica.dev.lan/auth/callback
s3Bucket: ""

View File

@@ -72,6 +72,7 @@ services:
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
DISABLE_WORKSPACE_CREATION: ${DISABLE_WORKSPACE_CREATION:-}
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
# Public URL the API is reachable at from the open internet, no

View File

@@ -171,6 +171,21 @@ describe("deriveWorkloadDetail", () => {
expect(r.queuedCount).toBe(2);
});
it("counts waiting_local_directory as queued (daemon parked on a path lock)", () => {
// waiting_local_directory is the daemon-side "blocked on a busy
// local_directory" hold state. It is still on the agent's plate —
// the chip must not flip to idle just because the run phase hasn't
// started yet — and is grouped with queued/dispatched in the
// presence verdict so the user sees "Queued · 1" rather than
// "Idle".
const r = deriveWorkloadDetail([
makeTask({ status: "waiting_local_directory" }),
]);
expect(r.workload).toBe("queued");
expect(r.runningCount).toBe(0);
expect(r.queuedCount).toBe(1);
});
it("returns working when running coexists with queued (overflow)", () => {
// Capacity-saturated agent: still running, but with a queue building.
// The chip says "Working" with the queue expressed as a `+Nq` badge.

View File

@@ -62,7 +62,14 @@ export function deriveWorkloadDetail(tasks: readonly AgentTask[]): WorkloadDetai
for (const t of tasks) {
if (t.status === "running") {
runningCount += 1;
} else if (t.status === "queued" || t.status === "dispatched") {
} else if (
t.status === "queued" ||
t.status === "dispatched" ||
// The daemon parked this task on a busy local_directory path. It's
// still on the agent's plate (counts toward "queued" presence), but
// it hasn't reached the run phase yet.
t.status === "waiting_local_directory"
) {
queuedCount += 1;
}
// Terminal statuses (completed / failed / cancelled) intentionally

View File

@@ -7,3 +7,4 @@ export * from "./use-workspace-presence-prefetch";
export * from "./constants";
export * from "./visibility-label";
export * from "./use-workspace-agent-availability";
export * from "./mcp-support";

View File

@@ -0,0 +1,11 @@
// The set of runtime providers whose backend reads `agent.mcp_config` and
// forwards MCP servers to the underlying CLI. The MCP config tab is hidden
// for every other provider so a user can't save a value the runtime will
// silently ignore. Keep this list in sync with the backends in
// `server/pkg/agent/` that read `ExecOptions.McpConfig`.
const MCP_SUPPORTED_PROVIDERS = new Set(["claude", "codex"]);
export function providerSupportsMcpConfig(provider: string | undefined | null): boolean {
if (!provider) return false;
return MCP_SUPPORTED_PROVIDERS.has(provider);
}

View File

@@ -152,7 +152,7 @@ describe("ApiClient", () => {
expect(headers["X-Client-OS"]).toBeUndefined();
});
it("uses the Cloud Runtime node API contract and forwards bootstrap PAT on create", async () => {
it("uses the Cloud Runtime node API contract", async () => {
const node = {
id: "node-1",
owner_id: "user-1",
@@ -195,7 +195,6 @@ describe("ApiClient", () => {
expect(listCall[0]).toBe(
"https://api.example.test/api/cloud-runtime/nodes?limit=20&offset=5",
);
expect((listCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBeUndefined();
expect(createCall[0]).toBe(
"https://api.example.test/api/cloud-runtime/nodes",
);
@@ -206,7 +205,6 @@ describe("ApiClient", () => {
name: "gpu-dev-01",
}),
});
expect((createCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBeUndefined();
});
it("falls back when Cloud Runtime node responses drift", async () => {

View File

@@ -70,6 +70,7 @@ import type {
ListProjectsResponse,
ProjectResource,
CreateProjectResourceRequest,
UpdateProjectResourceRequest,
ListProjectResourcesResponse,
Label,
CreateLabelRequest,
@@ -101,6 +102,15 @@ import type {
Squad,
SquadMember,
SquadMemberStatusListResponse,
BillingBalance,
BillingTransactionsPage,
BillingBatchesPage,
BillingTopupsPage,
BillingPriceTier,
CreateBillingCheckoutSessionRequest,
CreateBillingCheckoutSessionResponse,
BillingCheckoutSessionStatus,
CreateBillingPortalSessionResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import type {
@@ -154,6 +164,22 @@ import {
TimelineEntriesSchema,
UserSchema,
WebhookDeliveryResponseSchema,
BillingBalanceSchema,
BillingTransactionsPageSchema,
BillingBatchesPageSchema,
BillingTopupsPageSchema,
BillingPriceTierListSchema,
CreateBillingCheckoutSessionResponseSchema,
BillingCheckoutSessionStatusSchema,
CreateBillingPortalSessionResponseSchema,
EMPTY_BILLING_BALANCE,
EMPTY_BILLING_TRANSACTIONS_PAGE,
EMPTY_BILLING_BATCHES_PAGE,
EMPTY_BILLING_TOPUPS_PAGE,
EMPTY_BILLING_PRICE_TIER_LIST,
EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -525,6 +551,7 @@ export class ApiClient {
squad_id?: string;
prompt: string;
project_id?: string | null;
parent_issue_id?: string | null;
}): Promise<{ task_id: string }> {
return this.fetch("/api/issues/quick-create", {
method: "POST",
@@ -557,6 +584,19 @@ export class ApiClient {
});
}
/** Batched variant — returns children for multiple parents in one request.
* Avoids an N-request fan-out in Swimlane (one per visible parent lane).
* parentIds must be non-empty; pass a sorted, deduplicated list so the
* React Query cache key is stable across renders. */
async listChildrenByParents(parentIds: string[]): Promise<{ issues: Issue[] }> {
const raw = await this.fetch<unknown>(
`/api/issues/children?parent_ids=${parentIds.join(",")}`,
);
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
endpoint: "GET /api/issues/children",
});
}
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
return this.fetch("/api/issues/child-progress");
}
@@ -849,6 +889,135 @@ export class ApiClient {
});
}
// ---------------------------------------------------------------------
// Cloud Billing — proxies to multica-cloud /api/v1/billing/*. The
// multica-api server stamps X-User-ID and forwards bytes; everything
// here is upstream-shaped. See packages/core/types/billing.ts for the
// response field documentation.
// ---------------------------------------------------------------------
async getCloudBillingBalance(): Promise<BillingBalance> {
const raw = await this.fetch<unknown>("/api/cloud-billing/balance");
return parseWithFallback(raw, BillingBalanceSchema, EMPTY_BILLING_BALANCE, {
endpoint: "GET /api/cloud-billing/balance",
});
}
async listCloudBillingTransactions(
params?: { page?: number; page_size?: number },
): Promise<BillingTransactionsPage> {
const search = new URLSearchParams();
if (params?.page !== undefined) search.set("page", String(params.page));
if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
const query = search.toString();
const raw = await this.fetch<unknown>(
`/api/cloud-billing/transactions${query ? `?${query}` : ""}`,
);
return parseWithFallback(
raw,
BillingTransactionsPageSchema,
EMPTY_BILLING_TRANSACTIONS_PAGE,
{ endpoint: "GET /api/cloud-billing/transactions" },
);
}
async listCloudBillingBatches(
params?: { page?: number; page_size?: number },
): Promise<BillingBatchesPage> {
const search = new URLSearchParams();
if (params?.page !== undefined) search.set("page", String(params.page));
if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
const query = search.toString();
const raw = await this.fetch<unknown>(
`/api/cloud-billing/batches${query ? `?${query}` : ""}`,
);
return parseWithFallback(
raw,
BillingBatchesPageSchema,
EMPTY_BILLING_BATCHES_PAGE,
{ endpoint: "GET /api/cloud-billing/batches" },
);
}
async listCloudBillingTopups(
params?: { page?: number; page_size?: number },
): Promise<BillingTopupsPage> {
const search = new URLSearchParams();
if (params?.page !== undefined) search.set("page", String(params.page));
if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
const query = search.toString();
const raw = await this.fetch<unknown>(
`/api/cloud-billing/topups${query ? `?${query}` : ""}`,
);
return parseWithFallback(
raw,
BillingTopupsPageSchema,
EMPTY_BILLING_TOPUPS_PAGE,
{ endpoint: "GET /api/cloud-billing/topups" },
);
}
async listCloudBillingPriceTiers(): Promise<BillingPriceTier[]> {
const raw = await this.fetch<unknown>("/api/cloud-billing/price-tiers");
return parseWithFallback(
raw,
BillingPriceTierListSchema,
EMPTY_BILLING_PRICE_TIER_LIST,
{ endpoint: "GET /api/cloud-billing/price-tiers" },
);
}
async createCloudBillingCheckoutSession(
data: CreateBillingCheckoutSessionRequest,
): Promise<CreateBillingCheckoutSessionResponse> {
const res = await this.fetchRaw("/api/cloud-billing/checkout-sessions", {
method: "POST",
body: JSON.stringify(data),
extraHeaders: { "Content-Type": "application/json" },
});
const raw = (await res.json()) as unknown;
return parseWithFallback(
raw,
CreateBillingCheckoutSessionResponseSchema,
EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
{ endpoint: "POST /api/cloud-billing/checkout-sessions" },
);
}
async getCloudBillingCheckoutSession(
sessionId: string,
): Promise<BillingCheckoutSessionStatus> {
// Stripe session ids are `cs_<base62>` so they're URL-safe by
// construction; encodeURIComponent is paranoia for the case where a
// future Stripe format change adds a non-alphanumeric character. The
// server has its own allow-list rejection for unsafe ids.
const raw = await this.fetch<unknown>(
`/api/cloud-billing/checkout-sessions/${encodeURIComponent(sessionId)}`,
);
return parseWithFallback(
raw,
BillingCheckoutSessionStatusSchema,
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
{ endpoint: "GET /api/cloud-billing/checkout-sessions/{sessionId}" },
);
}
async createCloudBillingPortalSession(): Promise<CreateBillingPortalSessionResponse> {
const res = await this.fetchRaw("/api/cloud-billing/portal-sessions", {
method: "POST",
// Body is intentionally absent — the upstream endpoint requires no
// payload today. fetchRaw with no body skips the Content-Type
// default; that's fine because there's nothing to declare.
});
const raw = (await res.json()) as unknown;
return parseWithFallback(
raw,
CreateBillingPortalSessionResponseSchema,
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
{ endpoint: "POST /api/cloud-billing/portal-sessions" },
);
}
async deleteRuntime(runtimeId: string): Promise<void> {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
@@ -1197,6 +1366,9 @@ export class ApiClient {
posthog_key?: string;
posthog_host?: string;
analytics_environment?: string;
// Self-host gate (#3433). Optional because older servers omit the field
// entirely; consumers must default to false.
workspace_creation_disabled?: boolean;
}> {
return this.fetch("/api/config");
}
@@ -1549,6 +1721,17 @@ export class ApiClient {
});
}
async updateProjectResource(
projectId: string,
resourceId: string,
data: UpdateProjectResourceRequest,
): Promise<ProjectResource> {
return this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteProjectResource(
projectId: string,
resourceId: string,

View File

@@ -4,7 +4,15 @@ import type {
AgentTemplate,
AgentTemplateSummary,
Attachment,
BillingBalance,
BillingBatchesPage,
BillingCheckoutSessionStatus,
BillingPriceTier,
BillingTopupsPage,
BillingTransactionsPage,
CreateAgentFromTemplateResponse,
CreateBillingCheckoutSessionResponse,
CreateBillingPortalSessionResponse,
GroupedIssuesResponse,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
@@ -655,3 +663,173 @@ export const EMPTY_USER: User = {
created_at: "",
updated_at: "",
};
// ---------------------------------------------------------------------------
// Billing schemas (cloud-billing proxy surface)
//
// All billing JSON we receive comes from multica-cloud verbatim — we proxy
// the bytes without re-shaping. These schemas use `loose()` so a future
// non-breaking field addition on the cloud side doesn't crash us; required
// fields are still strictly enforced. EMPTY_* constants supply the
// fallback parseWithFallback uses when the upstream response is malformed
// or unparseable.
export const BillingBalanceSchema = z.object({
owner_id: z.string(),
balance_micro: z.number(),
balance_credit: z.number(),
updated_at: z.string(),
}).loose();
export const EMPTY_BILLING_BALANCE: BillingBalance = {
owner_id: "",
balance_micro: 0,
balance_credit: 0,
updated_at: "",
};
// `tx_type` and `source` are kept as plain strings here; the cloud doc
// enumerates the canonical values but the frontend display tolerates
// unknown ones gracefully. Strict enums would crash the page on a future
// addition (e.g. a new `topup` source kind).
export const BillingTransactionSchema = z.object({
id: z.string(),
owner_id: z.string(),
idempotency_key: z.string().default(""),
tx_type: z.string(),
source: z.string(),
amount_micro: z.number(),
balance_after: z.number(),
reference_id: z.string().default(""),
description: z.string().default(""),
metadata: z.record(z.string(), z.unknown()).default({}),
created_at: z.string(),
}).loose();
export const BillingTransactionsPageSchema = z.object({
items: z.array(BillingTransactionSchema).default([]),
total: z.number().default(0),
page: z.number().default(1),
page_size: z.number().default(20),
}).loose();
export const EMPTY_BILLING_TRANSACTIONS_PAGE: BillingTransactionsPage = {
items: [],
total: 0,
page: 1,
page_size: 20,
};
export const BillingBatchSchema = z.object({
id: z.string(),
owner_id: z.string(),
source_tx_id: z.string().default(""),
source_type: z.string(),
total_micro: z.number(),
remaining_micro: z.number(),
// Cloud either omits the key (never expires) or sends a string
// timestamp. Null is also tolerated since some serializers emit
// explicit nulls for absent timestamps.
expires_at: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
}).loose();
export const BillingBatchesPageSchema = z.object({
items: z.array(BillingBatchSchema).default([]),
total: z.number().default(0),
page: z.number().default(1),
page_size: z.number().default(20),
}).loose();
export const EMPTY_BILLING_BATCHES_PAGE: BillingBatchesPage = {
items: [],
total: 0,
page: 1,
page_size: 20,
};
export const BillingTopupSchema = z.object({
id: z.string(),
owner_id: z.string(),
amount_cents: z.number(),
currency: z.string().default("usd"),
credits: z.number(),
bonus_credits: z.number().default(0),
status: z.string(),
tier_id: z.string().default(""),
stripe_checkout_id: z.string().default(""),
// Only set after status reaches `credited` — leave optional rather
// than coerce to "" so a UI can branch on existence.
purchase_batch_id: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
}).loose();
export const BillingTopupsPageSchema = z.object({
items: z.array(BillingTopupSchema).default([]),
total: z.number().default(0),
page: z.number().default(1),
page_size: z.number().default(20),
}).loose();
export const EMPTY_BILLING_TOPUPS_PAGE: BillingTopupsPage = {
items: [],
total: 0,
page: 1,
page_size: 20,
};
export const BillingPriceTierSchema = z.object({
id: z.string(),
// Cloud doc says display_name falls back to id; tolerate empty too.
display_name: z.string().default(""),
amount_cents: z.number(),
credits: z.number(),
bonus_credits: z.number().optional(),
bonus_expires_in: z.string().optional(),
}).loose();
export const BillingPriceTierListSchema = z.array(BillingPriceTierSchema);
export const EMPTY_BILLING_PRICE_TIER_LIST: BillingPriceTier[] = [];
export const CreateBillingCheckoutSessionResponseSchema = z.object({
order_id: z.string(),
session_id: z.string(),
url: z.string(),
}).loose();
export const EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE: CreateBillingCheckoutSessionResponse = {
order_id: "",
session_id: "",
url: "",
};
export const BillingCheckoutSessionStatusSchema = z.object({
order_id: z.string(),
status: z.string(),
amount_cents: z.number(),
credits: z.number(),
bonus_credits: z.number().default(0),
currency: z.string().default("usd"),
tier_id: z.string().default(""),
}).loose();
export const EMPTY_BILLING_CHECKOUT_SESSION_STATUS: BillingCheckoutSessionStatus = {
order_id: "",
status: "pending",
amount_cents: 0,
credits: 0,
bonus_credits: 0,
currency: "usd",
tier_id: "",
};
export const CreateBillingPortalSessionResponseSchema = z.object({
url: z.string(),
}).loose();
export const EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE: CreateBillingPortalSessionResponse = {
url: "",
};

View File

@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./mutations";

View File

@@ -0,0 +1,79 @@
import { useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import type { CreateBillingCheckoutSessionRequest } from "../types";
import { billingKeys } from "./queries";
// Both mutations here trigger a hop OUT of the SPA — Stripe Checkout
// and Stripe Billing Portal are hosted pages. The mutation completes
// once the URL is in our hands; the caller is responsible for the
// `window.location.href = url` redirect (or in newer flows,
// `window.open` + tab-aware polling).
//
// We invalidate the topup list on settle so when the user returns
// from Stripe the new `pending` order shows up immediately. The
// balance and transactions are NOT invalidated here — they only flip
// after Stripe + the cloud webhook actually credit, which is a
// post-redirect concern.
export function useCreateCloudBillingCheckoutSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateBillingCheckoutSessionRequest) =>
api.createCloudBillingCheckoutSession(data),
onSettled: () => {
// The new pending topup row is visible to the topups list as
// soon as Cloud writes it. Invalidate so the user sees the new
// pending entry without a page refresh.
qc.invalidateQueries({ queryKey: [...billingKeys.all(), "topups"] });
},
});
}
export function useCreateCloudBillingPortalSession() {
return useMutation({
mutationFn: () => api.createCloudBillingPortalSession(),
// No cache invalidation — the portal opens, the user does whatever,
// and any state changes Stripe-side propagate back via webhook.
// The next React Query refetch picks them up at its own cadence.
});
}
/**
* useInvalidateBillingDataAfterCredit returns a callback that flushes
* the cached balance / transactions / batches / topups so the page
* re-fetches them. Used by the Stripe-success polling banner: once it
* detects the topup status flipped to a terminal value (credited /
* failed / canceled), the banner is the only query that's been
* polling — every other card on the page is still showing its
* pre-checkout snapshot. Without this invalidation the user sees
* "Final status: credited" while the balance card still displays the
* old number until they click refresh.
*
* Scope of the invalidation:
*
* - balance + transactions + batches: only ever change at the
* `credited` transition (the cloud writes the credit ledger and
* batch row in the same DB transaction as the wallet update).
* For `failed` / `canceled` they do NOT change, so technically we
* over-fetch in those cases — three extra cheap round-trips that
* simplify the call site and are negligible on a test page.
*
* - topups: changes on every terminal transition (the order row
* flips status), so it always needs invalidating.
*
* - the checkout-session query itself is intentionally NOT in this
* sweep. Its `refetchInterval` already returned `false` when
* status went terminal; refetching would just confirm the same
* value we already hold and wake the polling cycle back up for
* no benefit.
*/
export function useInvalidateBillingDataAfterCredit() {
const qc = useQueryClient();
return useCallback(() => {
qc.invalidateQueries({ queryKey: billingKeys.balance() });
qc.invalidateQueries({ queryKey: [...billingKeys.all(), "transactions"] });
qc.invalidateQueries({ queryKey: [...billingKeys.all(), "batches"] });
qc.invalidateQueries({ queryKey: [...billingKeys.all(), "topups"] });
}, [qc]);
}

View File

@@ -0,0 +1,108 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
// Billing data is account-level (single owner per X-User-ID), so the
// React Query keys are NOT scoped to a workspace — keying on workspace
// would force a refetch every time the user navigates to a different
// workspace, even though the backing data is identical.
//
// Query-level staleness: the cloud's billing module is the source of
// truth. Topup status flips from `pending` → `paid` → `credited` after
// Stripe + the cloud-side webhook handler do their thing, so a returning-
// from-Stripe page needs prompt freshness. We rely on the page-level
// hooks (refetchInterval / invalidate-on-mount) rather than baking a
// stale-time here, so consumers can tune polling per surface.
export const billingKeys = {
all: () => ["billing"] as const,
balance: () => [...billingKeys.all(), "balance"] as const,
transactions: (params?: { page?: number; page_size?: number }) =>
[...billingKeys.all(), "transactions", params ?? {}] as const,
batches: (params?: { page?: number; page_size?: number }) =>
[...billingKeys.all(), "batches", params ?? {}] as const,
topups: (params?: { page?: number; page_size?: number }) =>
[...billingKeys.all(), "topups", params ?? {}] as const,
priceTiers: () => [...billingKeys.all(), "price-tiers"] as const,
checkoutSession: (sessionId: string) =>
[...billingKeys.all(), "checkout-session", sessionId] as const,
};
export function billingBalanceOptions() {
return queryOptions({
queryKey: billingKeys.balance(),
queryFn: () => api.getCloudBillingBalance(),
// 30s stale-time: balance changes only when a topup credits or a
// deduction happens. For the test page the user's main interest is
// post-checkout state; we let the page invalidate explicitly.
staleTime: 30 * 1000,
});
}
export function billingTransactionsOptions(params?: {
page?: number;
page_size?: number;
}) {
return queryOptions({
queryKey: billingKeys.transactions(params),
queryFn: () => api.listCloudBillingTransactions(params),
staleTime: 30 * 1000,
});
}
export function billingBatchesOptions(params?: {
page?: number;
page_size?: number;
}) {
return queryOptions({
queryKey: billingKeys.batches(params),
queryFn: () => api.listCloudBillingBatches(params),
staleTime: 30 * 1000,
});
}
export function billingTopupsOptions(params?: {
page?: number;
page_size?: number;
}) {
return queryOptions({
queryKey: billingKeys.topups(params),
queryFn: () => api.listCloudBillingTopups(params),
staleTime: 30 * 1000,
});
}
export function billingPriceTiersOptions() {
return queryOptions({
queryKey: billingKeys.priceTiers(),
queryFn: () => api.listCloudBillingPriceTiers(),
// Price tiers come from server config and basically never change at
// runtime — once we've fetched once we can keep it for the whole
// session. 5 minutes is more than enough for a test page.
staleTime: 5 * 60 * 1000,
});
}
// Stripe-success-redirect polling: when the page loads with
// `?session_id=...` in the URL, the user just came back from Stripe and
// the topup is racing through `pending → paid → credited`. Poll until
// it's terminal so the UI can show a final outcome before the user
// closes the tab.
//
// Caller is expected to short-circuit by passing `enabled: !!sessionId`
// — that's why we don't gate inside queryOptions itself.
export function billingCheckoutSessionOptions(sessionId: string) {
return queryOptions({
queryKey: billingKeys.checkoutSession(sessionId),
queryFn: () => api.getCloudBillingCheckoutSession(sessionId),
// Refetch every 2s while we're still in a non-terminal state,
// stop once we land in `credited` / `failed` / `canceled`.
refetchInterval: (query) => {
const status = query.state.data?.status;
if (status === "credited" || status === "failed" || status === "canceled") {
return false;
}
return 2000;
},
staleTime: 0,
});
}

View File

@@ -5,17 +5,26 @@ interface ConfigState {
cdnDomain: string;
allowSignup: boolean;
googleClientId: string;
// Self-host gate (#3433): when true, every "Create workspace" affordance
// must be hidden. Defaults to false so unknown / older servers behave like
// the managed-cloud case.
workspaceCreationDisabled: boolean;
setCdnDomain: (domain: string) => void;
setAuthConfig: (config: { allowSignup: boolean; googleClientId?: string }) => void;
setAuthConfig: (config: {
allowSignup: boolean;
googleClientId?: string;
workspaceCreationDisabled?: boolean;
}) => void;
}
export const configStore = createStore<ConfigState>((set) => ({
cdnDomain: "",
allowSignup: true,
googleClientId: "",
workspaceCreationDisabled: false,
setCdnDomain: (domain) => set({ cdnDomain: domain }),
setAuthConfig: ({ allowSignup, googleClientId = "" }) =>
set({ allowSignup, googleClientId }),
setAuthConfig: ({ allowSignup, googleClientId = "", workspaceCreationDisabled = false }) =>
set({ allowSignup, googleClientId, workspaceCreationDisabled }),
}));
export function useConfigStore(): ConfigState;

View File

@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { createContext, use, type ReactNode } from "react";
import type { LocaleAdapter } from "./types";
const LocaleAdapterContext = createContext<LocaleAdapter | null>(null);
@@ -20,7 +20,7 @@ export function LocaleAdapterProvider({
}
export function useLocaleAdapter(): LocaleAdapter {
const ctx = useContext(LocaleAdapterContext);
const ctx = use(LocaleAdapterContext);
if (!ctx) {
throw new Error(
"useLocaleAdapter must be used within <LocaleAdapterProvider>",

View File

@@ -0,0 +1,50 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import { cleanupDeletedIssueCaches } from "./delete-cache";
import { issueKeys } from "./queries";
import { useRecentIssuesStore } from "./stores/recent-issues-store";
const WS_ID = "ws-a";
beforeEach(() => {
useRecentIssuesStore.setState({ byWorkspace: {} });
});
describe("cleanupDeletedIssueCaches — recent issues store", () => {
it("removes the deleted issue from the recent issues bucket", () => {
const { recordVisit } = useRecentIssuesStore.getState();
recordVisit(WS_ID, "issue-1");
recordVisit(WS_ID, "issue-2");
const qc = new QueryClient();
cleanupDeletedIssueCaches(qc, WS_ID, "issue-1");
const ids = useRecentIssuesStore
.getState()
.byWorkspace[WS_ID]?.map((e) => e.id);
expect(ids).toEqual(["issue-2"]);
});
it("does not touch the recent bucket of an unrelated workspace", () => {
const { recordVisit } = useRecentIssuesStore.getState();
recordVisit(WS_ID, "issue-1");
recordVisit("ws-b", "issue-1");
const qc = new QueryClient();
cleanupDeletedIssueCaches(qc, WS_ID, "issue-1");
const state = useRecentIssuesStore.getState().byWorkspace;
expect(state[WS_ID]).toBeUndefined();
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-1"]);
});
it("still removes the cached detail query for the deleted issue", () => {
const qc = new QueryClient();
qc.setQueryData(issueKeys.detail(WS_ID, "issue-1"), { id: "issue-1" });
cleanupDeletedIssueCaches(qc, WS_ID, "issue-1");
expect(qc.getQueryData(issueKeys.detail(WS_ID, "issue-1"))).toBeUndefined();
});
});

View File

@@ -9,6 +9,7 @@ import { labelKeys } from "../labels/queries";
import type { Issue, ListIssuesCache } from "../types";
import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers";
import { issueKeys } from "./queries";
import { useRecentIssuesStore } from "./stores/recent-issues-store";
export type DeletedIssueCacheMetadata = {
parentIssueIds: string[];
@@ -116,6 +117,7 @@ export function invalidateDeletedIssueParentCaches(
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
}
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
}
export function invalidateDeletedIssueDependentCaches(
@@ -171,4 +173,10 @@ export function cleanupDeletedIssueCaches(
// scheduled bar visible right now.
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
invalidateDeletedIssueDependentCaches(qc, wsId);
// Recent Issues store persists to localStorage and survives reloads, so a
// deleted id left behind keeps the Cmd+K command bar firing 404s on every
// open. Both the delete mutation and the WS delete event flow through here,
// so a single call covers self-delete and cross-client delete.
useRecentIssuesStore.getState().forgetIssue(wsId, issueId);
}

View File

@@ -305,6 +305,15 @@ export function useUpdateIssue() {
});
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
// Invalidate the batched-children cache only when the parent link
// actually changed. The WS path (ws-updaters.ts) invalidates
// unconditionally because it doesn't know what the server change
// touched; here onMutate already patched issueKeys.children(parent)
// optimistically, so we only need to flush when the parent relation
// itself moved.
if (ctx?.parentId || newParentId) {
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
}
},
});
}
@@ -611,13 +620,18 @@ export function useCreateComment(issueId: string) {
export function useUpdateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds: string[] }) =>
api.updateComment(commentId, content, attachmentIds),
onMutate: async ({ commentId, content }) => {
onMutate: async ({ commentId, content, attachmentIds }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
const kept = new Set(attachmentIds);
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
old?.map((e) =>
e.id === commentId
? { ...e, content, attachments: e.attachments?.filter((a) => kept.has(a.id)) }
: e,
),
);
return { prev };
},

View File

@@ -5,8 +5,10 @@ import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import type { Issue, ListIssuesParams, ListIssuesResponse } from "../types";
import {
CHILDREN_BY_PARENTS_CHUNK_SIZE,
PROJECT_GANTT_MAX_ISSUES,
PROJECT_GANTT_PAGE_LIMIT,
childrenByParentsOptions,
issueKeys,
projectGanttIssuesOptions,
} from "./queries";
@@ -45,6 +47,12 @@ function installFakeApi(listIssues: (params?: ListIssuesParams) => Promise<ListI
setApiInstance({ listIssues } as unknown as ApiClient);
}
function installFakeChildrenApi(
listChildrenByParents: (parentIds: string[]) => Promise<{ issues: Issue[] }>,
) {
setApiInstance({ listChildrenByParents } as unknown as ApiClient);
}
describe("projectGanttIssuesOptions", () => {
let qc: QueryClient;
@@ -130,3 +138,78 @@ describe("projectGanttIssuesOptions", () => {
expect(options.queryKey).toEqual(issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
});
describe("childrenByParentsOptions chunking", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("issues a single request when parentIds fit under the chunk size", async () => {
const parentIds = Array.from({ length: 50 }, (_, i) => `p-${i}`);
const listChildrenByParents = vi
.fn<(ids: string[]) => Promise<{ issues: Issue[] }>>()
.mockResolvedValue({ issues: [] });
installFakeChildrenApi(listChildrenByParents);
await qc.fetchQuery(childrenByParentsOptions(WS_ID, parentIds, qc));
expect(listChildrenByParents).toHaveBeenCalledTimes(1);
expect(listChildrenByParents).toHaveBeenCalledWith(parentIds);
});
it("chunks parentIds into multiple requests when over the server cap", async () => {
// 2.5 chunks worth of parents → 3 parallel requests.
const count = CHILDREN_BY_PARENTS_CHUNK_SIZE * 2 + 17;
const parentIds = Array.from({ length: count }, (_, i) => `p-${i}`);
const calls: string[][] = [];
const listChildrenByParents = vi
.fn<(ids: string[]) => Promise<{ issues: Issue[] }>>()
.mockImplementation(async (ids) => {
calls.push(ids);
return { issues: [] };
});
installFakeChildrenApi(listChildrenByParents);
await qc.fetchQuery(childrenByParentsOptions(WS_ID, parentIds, qc));
expect(listChildrenByParents).toHaveBeenCalledTimes(3);
expect(calls[0]).toHaveLength(CHILDREN_BY_PARENTS_CHUNK_SIZE);
expect(calls[1]).toHaveLength(CHILDREN_BY_PARENTS_CHUNK_SIZE);
expect(calls[2]).toHaveLength(17);
// Together the chunks must cover every input parent id.
expect(calls.flat().sort()).toEqual(parentIds.slice().sort());
});
it("merges children from all chunks into one grouped map", async () => {
const parentIds = Array.from(
{ length: CHILDREN_BY_PARENTS_CHUNK_SIZE + 1 },
(_, i) => `p-${i}`,
);
// First chunk returns a child of p-0, second chunk returns a child of
// the last parent id (which lives alone in chunk 2).
const lastId = parentIds[parentIds.length - 1]!;
const listChildrenByParents = vi
.fn<(ids: string[]) => Promise<{ issues: Issue[] }>>()
.mockImplementation(async (ids) => {
if (ids.includes(lastId)) {
return { issues: [{ ...makeIssue(99), parent_issue_id: lastId }] };
}
return { issues: [{ ...makeIssue(1), parent_issue_id: "p-0" }] };
});
installFakeChildrenApi(listChildrenByParents);
const grouped = await qc.fetchQuery(
childrenByParentsOptions(WS_ID, parentIds, qc),
);
expect(grouped.get("p-0")).toHaveLength(1);
expect(grouped.get(lastId)).toHaveLength(1);
});
});

View File

@@ -1,4 +1,4 @@
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
import { api } from "../api";
import type {
GroupedIssuesResponse,
@@ -56,6 +56,12 @@ export const issueKeys = {
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "children", id] as const,
/** Prefix for invalidating all batched-children queries in a workspace. */
childrenByParentsAll: (wsId: string) =>
[...issueKeys.all(wsId), "children-by-parents"] as const,
/** Full key — includes sorted parent ids for cache stability. */
childrenByParents: (wsId: string, parentIds: readonly string[]) =>
[...issueKeys.childrenByParentsAll(wsId), parentIds] as const,
childProgress: (wsId: string) =>
[...issueKeys.all(wsId), "child-progress"] as const,
/** Full-issue timeline (single TanStack Query, no cursor). */
@@ -384,6 +390,74 @@ export function childIssuesOptions(wsId: string, id: string) {
});
}
/**
* Server cap on parent_ids per `GET /api/issues/children` request — must
* match `listChildrenByParentsLimit` in server/internal/handler/issue.go.
* Exceeding it returns 400, so the client chunks larger requests.
*/
export const CHILDREN_BY_PARENTS_CHUNK_SIZE = 200;
/**
* Batched variant of {@link childIssuesOptions}: fetches children for all
* given parents in `GET /api/issues/children?parent_ids=…` requests, chunked
* to {@link CHILDREN_BY_PARENTS_CHUNK_SIZE} parents each. The queryFn also
* hydrates each parent's per-parent issueKeys.children cache so other
* surfaces (issue-detail sub-issues panel, set-parent modal) hit the primed
* cache instead of re-fetching. Hydration happens in queryFn (not a
* useEffect) to avoid the setQueryData → re-render → effect loop.
*
* Used by SwimLaneView to resolve parent lanes without an N-request fan-out.
* parentIds must be sorted + deduplicated by the caller for a stable cache key.
*/
async function fetchAndHydrateChildrenByParents(
qc: QueryClient,
wsId: string,
parentIds: readonly string[],
) {
// Chunk to respect the server cap (parallel, since chunks are independent).
const chunks: string[][] = [];
for (let i = 0; i < parentIds.length; i += CHILDREN_BY_PARENTS_CHUNK_SIZE) {
chunks.push([...parentIds.slice(i, i + CHILDREN_BY_PARENTS_CHUNK_SIZE)]);
}
const responses = await Promise.all(chunks.map((c) => api.listChildrenByParents(c)));
const grouped = new Map<string, Issue[]>();
for (const response of responses) {
for (const issue of response.issues) {
if (!issue.parent_issue_id) continue;
const bucket = grouped.get(issue.parent_issue_id);
if (bucket) {
bucket.push(issue);
} else {
grouped.set(issue.parent_issue_id, [issue]);
}
}
}
for (const [parentId, children] of grouped) {
// Only hydrate if the per-parent cache is empty — don't overwrite a
// fresher result that another query (e.g. issue-detail) may have written.
// This relies on useUpdateIssue.onMutate writing into the per-parent
// cache (not creating an empty one) — if that contract changes, batch
// hydration here would silently stop seeding new lanes.
const existing = qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId));
if (!existing || existing.length === 0) {
qc.setQueryData(issueKeys.children(wsId, parentId), children);
}
}
return grouped;
}
export function childrenByParentsOptions(
wsId: string,
parentIds: readonly string[],
qc: QueryClient,
) {
return queryOptions({
queryKey: issueKeys.childrenByParents(wsId, parentIds),
queryFn: () => fetchAndHydrateChildrenByParents(qc, wsId, parentIds),
enabled: parentIds.length > 0,
});
}
/**
* Single-fetch timeline options. The endpoint returns the full ordered set of
* comments + activities for an issue (server caps at 2000 as a safety net).

View File

@@ -35,6 +35,65 @@ describe("useRecentIssuesStore.recordVisit", () => {
});
});
describe("useRecentIssuesStore.forgetIssue", () => {
it("removes a single id from the workspace bucket", () => {
const { recordVisit, forgetIssue } = useRecentIssuesStore.getState();
recordVisit("ws-a", "issue-1");
recordVisit("ws-a", "issue-2");
recordVisit("ws-a", "issue-3");
forgetIssue("ws-a", "issue-2");
const ids = useRecentIssuesStore
.getState()
.byWorkspace["ws-a"]?.map((e) => e.id);
expect(ids).toEqual(["issue-3", "issue-1"]);
});
it("drops the bucket entirely when the last id is removed", () => {
const { recordVisit, forgetIssue } = useRecentIssuesStore.getState();
recordVisit("ws-a", "issue-1");
recordVisit("ws-b", "issue-2");
forgetIssue("ws-a", "issue-1");
const state = useRecentIssuesStore.getState().byWorkspace;
expect(state["ws-a"]).toBeUndefined();
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-2"]);
});
it("does not touch other workspaces' buckets", () => {
const { recordVisit, forgetIssue } = useRecentIssuesStore.getState();
recordVisit("ws-a", "issue-1");
recordVisit("ws-b", "issue-1");
forgetIssue("ws-a", "issue-1");
const state = useRecentIssuesStore.getState().byWorkspace;
expect(state["ws-a"]).toBeUndefined();
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-1"]);
});
it("is a no-op when the id is not in the bucket", () => {
const { recordVisit, forgetIssue } = useRecentIssuesStore.getState();
recordVisit("ws-a", "issue-1");
const before = useRecentIssuesStore.getState().byWorkspace;
forgetIssue("ws-a", "issue-missing");
expect(useRecentIssuesStore.getState().byWorkspace).toBe(before);
});
it("is a no-op when the workspace has no bucket", () => {
const { forgetIssue } = useRecentIssuesStore.getState();
const before = useRecentIssuesStore.getState().byWorkspace;
forgetIssue("ws-missing", "issue-1");
expect(useRecentIssuesStore.getState().byWorkspace).toBe(before);
});
});
describe("useRecentIssuesStore.pruneWorkspaces", () => {
it("drops buckets for workspaces not in the active set", () => {
const { recordVisit, pruneWorkspaces } = useRecentIssuesStore.getState();

View File

@@ -16,6 +16,7 @@ export interface RecentIssueEntry {
interface RecentIssuesState {
byWorkspace: Record<string, RecentIssueEntry[]>;
recordVisit: (wsId: string, id: string) => void;
forgetIssue: (wsId: string, id: string) => void;
pruneWorkspaces: (activeWsIds: string[]) => void;
}
@@ -62,6 +63,20 @@ export const useRecentIssuesStore = create<RecentIssuesState>()(
return { byWorkspace: nextByWorkspace };
}),
forgetIssue: (wsId, id) =>
set((state) => {
const bucket = state.byWorkspace[wsId];
if (!bucket) return state;
const nextBucket = bucket.filter((entry) => entry.id !== id);
if (nextBucket.length === bucket.length) return state;
if (nextBucket.length === 0) {
const { [wsId]: _, ...rest } = state.byWorkspace;
return { byWorkspace: rest };
}
return {
byWorkspace: { ...state.byWorkspace, [wsId]: nextBucket },
};
}),
pruneWorkspaces: (activeWsIds) =>
set((state) => {
const allow = new Set(activeWsIds);

View File

@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext } from "react";
import { createContext, use } from "react";
import { useStore, type StoreApi } from "zustand";
import type { IssueViewState } from "./view-store";
@@ -21,14 +21,14 @@ export function ViewStoreProvider({
}
export function useViewStore<T>(selector: (state: IssueViewState) => T): T {
const store = useContext(ViewStoreContext);
const store = use(ViewStoreContext);
if (!store)
throw new Error("useViewStore must be used within ViewStoreProvider");
return useStore(store, selector);
}
export function useViewStoreApi(): StoreApi<IssueViewState> {
const store = useContext(ViewStoreContext);
const store = use(ViewStoreContext);
if (!store)
throw new Error("useViewStoreApi must be used within ViewStoreProvider");
return store;

View File

@@ -12,9 +12,12 @@ import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list" | "gantt" | "swimlane";
export type GanttZoom = "day" | "week" | "month";
export type IssueGrouping = "status" | "assignee";
export type SwimlaneGrouping = "parent" | "project" | "assignee";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
export const SWIMLANE_GROUPINGS: SwimlaneGrouping[] = ["parent", "project", "assignee"];
export interface CardProperties {
priority: boolean;
description: boolean;
@@ -79,8 +82,15 @@ export interface IssueViewState {
listCollapsedStatuses: IssueStatus[];
ganttZoom: GanttZoom;
ganttShowCompleted: boolean;
swimlaneOrder: string[];
collapsedSwimlanes: string[];
/** Active swimlane grouping dimension. */
swimlaneGrouping: SwimlaneGrouping;
/** Persisted lane order, keyed by grouping. Entries are raw lane ids
* (parent issue id, project id, or `<assigneeType>:<assigneeId>`). */
swimlaneOrders: Record<SwimlaneGrouping, string[]>;
/** Persisted collapsed lanes, keyed by grouping. Same id space as
* `swimlaneOrders`, plus the sentinel `"none"` for the pinned
* no-X lane and `"__orphans__"` for the parent-grouping fallback. */
collapsedSwimlanes: Record<SwimlaneGrouping, string[]>;
setViewMode: (mode: ViewMode) => void;
setGanttZoom: (zoom: GanttZoom) => void;
toggleGanttShowCompleted: () => void;
@@ -101,7 +111,10 @@ export interface IssueViewState {
setSortDirection: (dir: SortDirection) => void;
toggleCardProperty: (key: keyof CardProperties) => void;
toggleListCollapsed: (status: IssueStatus) => void;
setSwimlaneGrouping: (grouping: SwimlaneGrouping) => void;
/** Update the lane order for the currently active swimlane grouping. */
setSwimlaneOrder: (order: string[]) => void;
/** Toggle a lane key in the currently active swimlane grouping. */
toggleSwimlaneCollapsed: (key: string) => void;
}
@@ -132,8 +145,9 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
listCollapsedStatuses: [],
ganttZoom: "week",
ganttShowCompleted: false,
swimlaneOrder: [],
collapsedSwimlanes: [],
swimlaneGrouping: "assignee",
swimlaneOrders: { parent: [], project: [], assignee: [] },
collapsedSwimlanes: { parent: [], project: [], assignee: [] },
setViewMode: (mode) => set({ viewMode: mode }),
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
@@ -239,13 +253,22 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
? state.listCollapsedStatuses.filter((s) => s !== status)
: [...state.listCollapsedStatuses, status],
})),
setSwimlaneOrder: (order) => set({ swimlaneOrder: order }),
toggleSwimlaneCollapsed: (key) =>
setSwimlaneGrouping: (grouping) => set({ swimlaneGrouping: grouping }),
setSwimlaneOrder: (order) =>
set((state) => ({
collapsedSwimlanes: state.collapsedSwimlanes.includes(key)
? state.collapsedSwimlanes.filter((k) => k !== key)
: [...state.collapsedSwimlanes, key],
swimlaneOrders: { ...state.swimlaneOrders, [state.swimlaneGrouping]: order },
})),
toggleSwimlaneCollapsed: (key) =>
set((state) => {
const grouping = state.swimlaneGrouping;
const current = state.collapsedSwimlanes[grouping];
const next = current.includes(key)
? current.filter((k) => k !== key)
: [...current, key];
return {
collapsedSwimlanes: { ...state.collapsedSwimlanes, [grouping]: next },
};
}),
});
export const viewStorePersistOptions = (name: string) => ({
@@ -272,7 +295,8 @@ export const viewStorePersistOptions = (name: string) => ({
listCollapsedStatuses: state.listCollapsedStatuses,
ganttZoom: state.ganttZoom,
ganttShowCompleted: state.ganttShowCompleted,
swimlaneOrder: state.swimlaneOrder,
swimlaneGrouping: state.swimlaneGrouping,
swimlaneOrders: state.swimlaneOrders,
collapsedSwimlanes: state.collapsedSwimlanes,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
@@ -293,6 +317,13 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
current: T,
): T {
const p = (persisted ?? {}) as Partial<T>;
// `collapsedSwimlanes` changed shape from `string[]` to
// `Record<SwimlaneGrouping, string[]>`. A snapshot saved in the old
// shape would otherwise overwrite the default record with an array
// and crash on first read — fall back to the default when the
// persisted value isn't a plain object.
const isRecord = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v);
return {
...current,
...p,
@@ -300,6 +331,12 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
...current.cardProperties,
...(p.cardProperties ?? {}),
},
swimlaneOrders: isRecord(p.swimlaneOrders)
? { ...current.swimlaneOrders, ...p.swimlaneOrders }
: current.swimlaneOrders,
collapsedSwimlanes: isRecord(p.collapsedSwimlanes)
? { ...current.collapsedSwimlanes, ...p.collapsedSwimlanes }
: current.collapsedSwimlanes,
};
}

View File

@@ -89,6 +89,7 @@ export function onIssueUpdated(
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
}
}

View File

@@ -24,6 +24,7 @@
"./workspace/queries": "./workspace/queries.ts",
"./workspace/mutations": "./workspace/mutations.ts",
"./workspace/hooks": "./workspace/hooks.ts",
"./workspace/avatar-url": "./workspace/avatar-url.ts",
"./issues": "./issues/index.ts",
"./issues/queries": "./issues/queries.ts",
"./issues/mutations": "./issues/mutations.ts",
@@ -74,6 +75,9 @@
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",
"./billing": "./billing/index.ts",
"./billing/queries": "./billing/queries.ts",
"./billing/mutations": "./billing/mutations.ts",
"./github": "./github/index.ts",
"./github/queries": "./github/queries.ts",
"./feedback": "./feedback/index.ts",

View File

@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { createContext, use, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Workspace } from "../types";
import { workspaceListOptions } from "../workspace/queries";
@@ -35,7 +35,7 @@ export function WorkspaceSlugProvider({
/** Current workspace slug from URL, or null outside workspace-scoped routes. */
export function useWorkspaceSlug(): string | null {
return useContext(WorkspaceSlugContext);
return use(WorkspaceSlugContext);
}
/** Same as useWorkspaceSlug, but throws if called outside a workspace route. */

View File

@@ -53,6 +53,9 @@ export function AuthInitializer({
configStore.getState().setAuthConfig({
allowSignup: cfg.allow_signup,
googleClientId: cfg.google_client_id,
// Old servers omit this field — treat that as "creation allowed"
// (the managed-cloud default) rather than blocking the UI.
workspaceCreationDisabled: cfg.workspace_creation_disabled === true,
});
if (cfg.posthog_key) {
initAnalytics({

View File

@@ -6,5 +6,6 @@ export {
projectResourceKeys,
projectResourcesOptions,
useCreateProjectResource,
useUpdateProjectResource,
useDeleteProjectResource,
} from "./resource-queries";

View File

@@ -5,6 +5,7 @@ import type {
CreateProjectResourceRequest,
ListProjectResourcesResponse,
ProjectResource,
UpdateProjectResourceRequest,
} from "../types";
export const projectResourceKeys = {
@@ -46,6 +47,38 @@ export function useCreateProjectResource(wsId: string, projectId: string) {
});
}
export function useUpdateProjectResource(wsId: string, projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
resourceId,
data,
}: {
resourceId: string;
data: UpdateProjectResourceRequest;
}) => api.updateProjectResource(projectId, resourceId, data),
onSuccess: (updated) => {
qc.setQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
(old) =>
old
? {
...old,
resources: old.resources.map((r) =>
r.id === updated.id ? updated : r,
),
}
: old,
);
},
onSettled: () => {
qc.invalidateQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
},
});
}
export function useDeleteProjectResource(wsId: string, projectId: string) {
const qc = useQueryClient();
return useMutation({

View File

@@ -2,7 +2,7 @@
import {
createContext,
useContext,
use,
useEffect,
useState,
useCallback,
@@ -144,7 +144,7 @@ export function WSProvider({
}
export function useWS() {
const ctx = useContext(WSContext);
const ctx = use(WSContext);
if (!ctx) throw new Error("useWS must be used within WSProvider");
return ctx;
}

View File

@@ -62,6 +62,8 @@ import type {
TaskMessagePayload,
TaskQueuedPayload,
TaskDispatchPayload,
TaskRunningPayload,
TaskWaitingLocalDirectoryPayload,
TaskCompletedPayload,
TaskFailedPayload,
TaskCancelledPayload,
@@ -785,6 +787,43 @@ export function useRealtimeSync(
);
});
// task:running fires when the daemon transitions a previously-parked task
// (waiting_local_directory) back into the run phase. The dispatch→running
// path is collapsed in the handler above, so this handler exists mainly to
// clear a stale `waiting_local_directory` pill — without it, the pill
// would stay parked even after the daemon resumed work.
const unsubTaskRunning = ws.on("task:running", (p) => {
const payload = p as TaskRunningPayload;
if (!payload.chat_session_id) return;
qc.setQueryData<ChatPendingTask>(
chatKeys.pendingTask(payload.chat_session_id),
(old) => {
if (!old || old.task_id !== payload.task_id) return old;
return { ...old, status: "running" };
},
);
});
// task:waiting_local_directory fires when the daemon dequeues a task but
// can't acquire the local_directory path lock — another task on this
// daemon is in the same directory. Write the status so TaskStatusPill
// can render the "Waiting for local directory" stage instead of pinning
// a stale "Starting / Thinking" frame.
const unsubTaskWaitingLocalDir = ws.on(
"task:waiting_local_directory",
(p) => {
const payload = p as TaskWaitingLocalDirectoryPayload;
if (!payload.chat_session_id) return;
qc.setQueryData<ChatPendingTask>(
chatKeys.pendingTask(payload.chat_session_id),
(old) => {
if (!old || old.task_id !== payload.task_id) return old;
return { ...old, status: "waiting_local_directory" };
},
);
},
);
// task:cancelled reaches us when:
// 1. handleStop already cleared the cache locally (this is a no-op confirm)
// 2. another tab / admin / system cancels — this is the only path that
@@ -926,6 +965,8 @@ export function useRealtimeSync(
unsubChatDone();
unsubTaskQueued();
unsubTaskDispatch();
unsubTaskRunning();
unsubTaskWaitingLocalDir();
unsubTaskCancelled();
unsubTaskCompleted();
unsubTaskFailed();

View File

@@ -70,7 +70,19 @@ export interface AgentTask {
// autopilot-spawned. Check chat_session_id / autopilot_run_id to tell
// which source produced it.
issue_id: string;
status: "queued" | "dispatched" | "running" | "completed" | "failed" | "cancelled";
// `waiting_local_directory` is the daemon-emitted hold state for the
// local_directory flow: a task that has been dispatched but is parked
// because another task currently owns the same on-disk path lock.
// Treated as an active (non-terminal) state alongside queued/dispatched/
// running by every consumer that buckets tasks into "active vs done".
status:
| "queued"
| "dispatched"
| "waiting_local_directory"
| "running"
| "completed"
| "failed"
| "cancelled";
priority: number;
dispatched_at: string | null;
started_at: string | null;
@@ -108,9 +120,24 @@ export interface AgentTask {
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
/**
* Local working directory pinned for this task by the daemon. Empty until
* the daemon reports a work_dir (typically once execution starts).
* the daemon reports a work_dir (typically once execution starts). This is
* the canonical absolute path the agent runs in; UI surfaces should prefer
* `relative_work_dir` to avoid leaking the user's home directory.
*/
work_dir?: string;
/**
* Privacy-safe display form of `work_dir`, derived on the server. For
* standard tasks the daemon's workspaces root has been stripped off
* (`<wsUUID>/<taskShort>/workdir`); for local_directory tasks where the
* path lives outside that layout, the server strips recognised home
* prefixes (`/Users/<name>/`, `/home/<name>/`, `<drive>:/Users/<name>/`)
* and otherwise falls back to the basename so neither the home directory
* nor the username leak into the UI. Older backends omit the field —
* render it conditionally and never render `work_dir` raw (not even in
* a tooltip / `title` / `aria-label`, since the goal is that screen
* shares and screenshots also stay safe).
*/
relative_work_dir?: string;
}
export interface Agent {
@@ -141,6 +168,27 @@ export interface Agent {
* alongside `has_custom_env`. Treat `undefined` as zero. MUL-2600.
*/
custom_env_key_count?: number;
/**
* MCP server configuration forwarded to the runtime CLI (Claude's
* `--mcp-config`). The shape is opaque to the platform — whatever
* JSON the CLI accepts, the daemon writes to disk verbatim. `null`
* (or the field omitted on legacy backends) means no config; the
* daemon falls back to the CLI's own default. MUL-2764.
*
* When the caller can't see secrets (an agent actor, or a non-owner
* non-admin), the server replaces the value with `null` and sets
* `mcp_config_redacted` to true so the UI can render a "configured
* but hidden" state without exposing potentially sensitive fields.
*/
mcp_config?: unknown | null;
/**
* True when the server stripped `mcp_config` from this response
* because the caller lacks permission to see secrets. The UI uses
* this to distinguish "no config" (`mcp_config === null &&
* !mcp_config_redacted`) from "config exists but you can't see it".
* Older backends omit this field; treat `undefined` as false.
*/
mcp_config_redacted?: boolean;
visibility: AgentVisibility;
status: AgentStatus;
max_concurrent_tasks: number;
@@ -283,6 +331,15 @@ export interface UpdateAgentRequest {
* MUL-2600.
*/
custom_args?: string[];
/**
* MCP server configuration. Tri-state semantics (MUL-2764):
* - field omitted → no change
* - `null` → clear the column; the daemon falls back to the CLI's
* built-in default at launch
* - object → replace the stored JSON verbatim; the platform does
* not validate the shape (MCP CLI accepts whatever it accepts)
*/
mcp_config?: unknown | null;
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;

View File

@@ -41,6 +41,11 @@ export interface Autopilot {
updated_at: string;
}
export interface WebhookEventFilter {
event: string;
actions?: string[];
}
export interface AutopilotTrigger {
id: string;
autopilot_id: string;
@@ -59,6 +64,9 @@ export interface AutopilotTrigger {
// webhook_path when this is missing.
webhook_url?: string | null;
label: string | null;
// event_filters is only present for webhook triggers. Null/empty means
// "accept all events".
event_filters?: WebhookEventFilter[] | null;
last_fired_at: string | null;
created_at: string;
updated_at: string;
@@ -110,6 +118,8 @@ export interface CreateAutopilotTriggerRequest {
cron_expression?: string;
timezone?: string;
label?: string;
// event_filters is only meaningful for webhook triggers.
event_filters?: WebhookEventFilter[];
}
export interface UpdateAutopilotTriggerRequest {
@@ -117,6 +127,8 @@ export interface UpdateAutopilotTriggerRequest {
cron_expression?: string;
timezone?: string;
label?: string;
// event_filters is only meaningful for webhook triggers.
event_filters?: WebhookEventFilter[] | null;
}
export interface ListAutopilotsResponse {

View File

@@ -0,0 +1,182 @@
// Mirrors the multica-cloud Billing module response shapes
// (multica-cloud/docs/api/billing.md). These types are the contract our
// frontend consumes via /api/cloud-billing/* — multica-api itself does
// not own the schema, it just proxies bytes. Keep field names verbatim
// with what the cloud sends.
//
// Unit convention (from the cloud doc):
// - micro-credit (BIGINT): internal storage unit; 1 credit = 1_000_000 micro
// - credit: user-facing display unit; 1 USD = 1000 credit
// Always show users `*_credit` fields when present; only do math on
// `*_micro` to avoid float drift.
// GET /balance
export interface BillingBalance {
owner_id: string;
balance_micro: number;
balance_credit: number;
updated_at: string;
}
// `tx_type` values per the cloud doc's enum. Exported as a union for
// reference / display switches; the actual interface field below is
// typed as plain `string` so a future cloud-side enum widening doesn't
// crash the parser. Frontend should switch on these values when known
// and fall back to a generic display otherwise.
export type BillingTxType =
| "topup"
| "deduction"
| "refund"
| "expire"
| "adjustment";
// `source` values per the cloud doc's enum. Same loosening rationale
// as BillingTxType above.
export type BillingTxSource =
| "gateway"
| "fleet"
| "topup"
| "refund"
| "admin"
| "system";
export interface BillingTransaction {
id: string;
owner_id: string;
idempotency_key: string;
// tx_type / source are widened to string here even though the cloud
// doc enumerates a fixed set; see comment on BillingTxType. UIs that
// care should switch on the value and gracefully default.
tx_type: string;
source: string;
// amount_micro is positive for credits, negative for deductions.
amount_micro: number;
balance_after: number;
reference_id: string;
description: string;
metadata: Record<string, unknown>;
created_at: string;
}
export interface BillingTransactionsPage {
items: BillingTransaction[];
total: number;
page: number;
page_size: number;
}
// `source_type` of a credit batch. `purchase` = paid topup,
// `bonus` = subscription / promo gift, `adjustment` = ops fix.
// Exported as a union for documentation; the field is widened to
// string in BillingBatch (same rationale as BillingTxType).
export type BillingBatchSourceType = "purchase" | "bonus" | "adjustment";
export interface BillingBatch {
id: string;
owner_id: string;
source_tx_id: string;
// Widened to string; see BillingBatchSourceType comment above.
source_type: string;
total_micro: number;
remaining_micro: number;
// expires_at omitted means the batch never expires. Bonus batches
// typically carry an expiry; purchase batches typically don't.
expires_at?: string | null;
created_at: string;
updated_at: string;
}
export interface BillingBatchesPage {
items: BillingBatch[];
total: number;
page: number;
page_size: number;
}
// Topup order lifecycle. `pending` = checkout open, `paid` = Stripe
// confirmed payment but credit not yet booked, `credited` = wallet
// updated, `failed`/`canceled` = terminal failures. Exported as a
// union for documentation; the field is widened to string in
// BillingTopup / BillingCheckoutSessionStatus.
export type BillingTopupStatus =
| "pending"
| "paid"
| "credited"
| "failed"
| "canceled";
export interface BillingTopup {
id: string;
owner_id: string;
amount_cents: number;
currency: string;
credits: number;
bonus_credits: number;
// Widened to string; see BillingTopupStatus comment.
status: string;
tier_id: string;
stripe_checkout_id: string;
// Set when status reaches `credited` and the purchase batch was
// minted; before that the field is empty.
purchase_batch_id?: string;
created_at: string;
updated_at: string;
}
export interface BillingTopupsPage {
items: BillingTopup[];
total: number;
page: number;
page_size: number;
}
// GET /price-tiers — returns server-authoritative purchasable tiers.
// Frontend should NEVER hard-code amount/credits; the upstream is the
// source of truth so prices can be updated without a frontend ship.
export interface BillingPriceTier {
id: string;
display_name: string;
amount_cents: number;
credits: number;
// Both bonus fields are optional: when omitted, no bonus is granted.
// When `bonus_expires_in` is omitted but `bonus_credits` is present,
// the bonus credits never expire.
bonus_credits?: number;
// Go time.Duration string, e.g. "720h0m0s" = 30 days.
bonus_expires_in?: string;
}
// POST /checkout-sessions
export interface CreateBillingCheckoutSessionRequest {
tier_id: string;
// Optional caller-provided email; only used by Stripe Checkout when
// the owner doesn't yet have a Stripe customer record. Pass it when
// we already know the user's email and want it pre-filled.
customer_email?: string;
}
export interface CreateBillingCheckoutSessionResponse {
order_id: string;
session_id: string;
// Stripe-hosted Checkout URL. Frontend redirects the browser here.
url: string;
}
// GET /checkout-sessions/{session_id} — frontend polls this after
// returning from Stripe with `?session_id=...` to surface the credit
// status before the user navigates away.
export interface BillingCheckoutSessionStatus {
order_id: string;
// Widened to string; see BillingTopupStatus comment.
status: string;
amount_cents: number;
credits: number;
bonus_credits: number;
currency: string;
tier_id: string;
}
// POST /portal-sessions
export interface CreateBillingPortalSessionResponse {
url: string;
}

View File

@@ -23,6 +23,8 @@ export type WSEventType =
| "agent:restored"
| "task:queued"
| "task:dispatch"
| "task:running"
| "task:waiting_local_directory"
| "task:progress"
| "task:completed"
| "task:failed"
@@ -239,6 +241,28 @@ export interface TaskDispatchPayload {
chat_session_id?: string;
}
export interface TaskRunningPayload {
task_id: string;
agent_id: string;
issue_id: string;
chat_session_id?: string;
status: string;
}
// task:waiting_local_directory fires when the daemon dequeues a task but
// can't immediately acquire the on-disk path lock — another task on this
// daemon is already executing in the same local_directory. The optional
// `wait_reason` mirrors the server-side hint (path / holder task id), but
// is not yet surfaced end-to-end; the UI today only reads the status.
export interface TaskWaitingLocalDirectoryPayload {
task_id: string;
agent_id: string;
issue_id: string;
chat_session_id?: string;
status: string;
wait_reason?: string;
}
export interface TaskCompletedPayload {
task_id: string;
agent_id: string;
@@ -385,6 +409,8 @@ export interface WSEventPayloadMap {
"agent:restored": AgentRestoredPayload;
"task:queued": TaskQueuedPayload;
"task:dispatch": TaskDispatchPayload;
"task:running": TaskRunningPayload;
"task:waiting_local_directory": TaskWaitingLocalDirectoryPayload;
"task:completed": TaskCompletedPayload;
"task:failed": TaskFailedPayload;
"task:message": TaskMessagePayload;

View File

@@ -76,8 +76,11 @@ export type {
ListProjectsResponse,
ProjectResource,
ProjectResourceType,
ProjectResourceRef,
GithubRepoResourceRef,
LocalDirectoryResourceRef,
CreateProjectResourceRequest,
UpdateProjectResourceRequest,
ListProjectResourcesResponse,
} from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
@@ -100,6 +103,7 @@ export type {
AutopilotRun,
AutopilotRunStatus,
AutopilotRunSource,
WebhookEventFilter,
CreateAutopilotRequest,
UpdateAutopilotRequest,
CreateAutopilotTriggerRequest,
@@ -130,3 +134,21 @@ export type {
SquadMemberStatus,
SquadMemberStatusListResponse,
} from "./squad";
export type {
BillingBalance,
BillingTransaction,
BillingTransactionsPage,
BillingTxType,
BillingTxSource,
BillingBatch,
BillingBatchesPage,
BillingBatchSourceType,
BillingTopup,
BillingTopupsPage,
BillingTopupStatus,
BillingPriceTier,
CreateBillingCheckoutSessionRequest,
CreateBillingCheckoutSessionResponse,
BillingCheckoutSessionStatus,
CreateBillingPortalSessionResponse,
} from "./billing";

Some files were not shown because too many files have changed in this diff Show More