Commit Graph

932 Commits

Author SHA1 Message Date
Naiyuan Qing
4ae4722ef0 fix(comments): preserve direct parent on replies (#3579)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 08:28:15 +08:00
fengchangguo-star
2cf8107fc8 feat(email): support implicit TLS (SMTPS/465) for SMTP relay (MUL-2768) (#3340)
* feat(email): support implicit TLS (SMTPS/465) for SMTP relay

The SMTP relay previously only did opportunistic STARTTLS: it dialed
plaintext and upgraded if the server advertised STARTTLS. Providers that
only offer implicit TLS on port 465 and do not advertise STARTTLS (e.g.
Aliyun enterprise mail) could not be used as a relay at all.

Add an SMTP_TLS env var:
  - unset / starttls (default): unchanged STARTTLS-upgrade behavior.
  - implicit / smtps / ssl: dial with tls.DialWithDialer (SMTPS).
Implicit TLS is auto-enabled when SMTP_PORT=465 and SMTP_TLS is unset, so
the common case works with no extra config. The startup log line now
reports the negotiated mode (starttls / implicit-tls).

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

* feat(email): plumb SMTP_TLS through selfhost compose, warn on unknown values

The backend reads SMTP_TLS but docker-compose.selfhost.yml never forwarded
it, so SMTP_TLS=implicit on a non-standard port (or an explicit starttls
override on 465) silently did nothing inside the container. Add it to the
backend.environment block.

Also log a one-line warning when SMTP_TLS is set to an unrecognized value
(e.g. "tls"/"true"/"on"), which would otherwise fall through to STARTTLS
and fail to dial a 465 SMTPS port with no startup hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(email): cover SMTP_TLS precedence and alias resolution

Table-driven test over NewEmailService asserting the implicit-TLS decision:
465 auto-enables implicit; explicit starttls on 465 overrides auto-detect;
implicit/smtps/ssl aliases (case-insensitive, whitespace-trimmed) force SMTPS
on any port; unknown values fall back to starttls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs: document SMTPS / SMTP_TLS support, drop "465 unsupported"

Port 465 implicit TLS is now supported, so the five places that said it was
unsupported are wrong. Replace those sentences, add an SMTP_TLS row to the
environment-variables tables (EN + ZH), and add a copy-pasteable SMTPS env
block to the auth-setup pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: guofengchang <guofengchang@cumulon.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:15:04 +08:00
Ivan Vinokurov
9aa8ba0191 fix(runtimes): self-host daemon setup URLs (MUL-2804) (#3474)
Expose self-host daemon setup URLs from /api/config at runtime so the Add computer dialog renders the operator's own server/app domains, while Multica Cloud defaults stay unchanged.

Fixes #3013.
2026-05-30 18:13:02 +08:00
feifeigood
382cdd6a0b feat(agent): consume OpenCode mcp_config via OPENCODE_CONFIG_CONTENT (#3098)
Closes the runtime-side gap of #2106: previously `agent.mcp_config` was
honored only by Claude Code (via `--mcp-config <file>`); for OpenCode the
field was accepted by the API but silently ignored at execution time.

## Approach

OpenCode has no `--mcp-config` flag. Project the agent's `mcp_config`
into OpenCode via OPENCODE_CONFIG_CONTENT — OpenCode's general
inline-config injection environment variable, which accepts any subset
of OpenCode's config schema (model / agent / mode / plugin / mcp / …)
and merges at "local" scope after the project-config loop. MCP is the
only field this PR projects through that channel; if a future Multica
field needs the same channel it would assemble a combined config slice
before the env append.

The env-var route was deliberate. An earlier draft of this PR wrote
the translated MCP servers into <workdir>/opencode.json and removed
the file on cleanup; review (#3098) flagged that the task workdir is
reused across turns for the same (agent, issue), and any agent- or
user-written model / tools / permission settings in opencode.json
must survive across runs. OPENCODE_CONFIG_CONTENT avoids the workdir
entirely — nothing is written to disk, no cleanup is needed, and the
env entry dies with the spawned process.

OPENCODE_CONFIG_CONTENT was added to OpenCode in v1.4.10 (2025-09); the
official @opencode-ai/sdk uses the same env var to inject runtime
config, so the surface is stable. Verified empirically against
OpenCode 1.15.6 in our K8s runtime: `opencode debug config` returns
the injected mcp slice deep-merged with the user's global config,
and <workdir>/opencode.json is observably untouched.

## Translation surface

`agent.mcp_config` accepts two shapes for portability:

- Claude-style `{"mcpServers": {name: {url|command, ...}}}` is
  translated into OpenCode's native form: `type: "local"|"remote"`,
  `command` coerced to a string array, `env` renamed to `environment`.
- Native OpenCode `{"mcp": {name: ...}}` accepts the three shapes
  OpenCode's schema permits and is strict-decoded against each:
    - McpLocalConfig:  `{type:"local", command:[…], environment?, enabled?, timeout?}`
    - McpRemoteConfig: `{type:"remote", url:"…", headers?, oauth?, enabled?, timeout?}`
    - bare override:   `{enabled: bool}` (toggle a server inherited
                        from global / project config without redefining it)
  Decoding uses `json.DisallowUnknownFields` so any field outside the
  matching schema is rejected — matching OpenCode's
  `additionalProperties: false`. Without this, a malformed payload
  (e.g. `command: "node"` instead of `command: ["node"]`) would reach
  OpenCode verbatim and either silently disable the server or crash
  the CLI at startup.

Field-level checks the strict decoder doesn't catch:
  - `timeout` must be a positive integer (rejects 0, negative, fractional)
  - `oauth` must be either an object (validated against McpOAuthConfig)
    or the literal `false`; primitives and `true` are rejected as ambiguous
  - `oauth.callbackPort` must be in 1..65535 when set

## Precedence

Go's os/exec dedups `cmd.Env` by key keeping the LAST occurrence
(Go 1.9+). Appending OPENCODE_CONFIG_CONTENT after `buildEnv(b.cfg.Env)`
guarantees the daemon's value wins over any value the user happened
to put in `agent.custom_env` — which matches the intended semantics
(`mcp_config` is the authoritative daemon-managed field; `custom_env`
is the escape hatch). When that override happens we surface a warning
log so accidental clobbers are debuggable.

## Limitation (out of scope, accepted in review)

OpenCode also deep-merges its **global** config
(`~/.config/opencode/opencode.json`) into every session and exposes no
flag to disable that. Operators who want strict per-agent isolation
from the global layer can set:

```jsonc
// agent.custom_env on the platform
{ "XDG_CONFIG_HOME": "/tmp/opencode-isolated" }
```

…pointing at any directory without an `opencode/` subdir. OpenCode then
reads no global config and only honors what the daemon injects via
OPENCODE_CONFIG_CONTENT. Verified with `opencode debug config`.

## Changes

server/pkg/agent/opencode_mcp.go (new):
  - buildOpenCodeMCPConfigContent — translates raw mcp_config into the
    JSON string OpenCode accepts via OPENCODE_CONFIG_CONTENT, returns
    "" when there's nothing to inject so the caller can skip the env
    entry (avoids clobbering anything the user put in
    agent.custom_env.OPENCODE_CONFIG_CONTENT)
  - translateMCPConfigForOpenCode + helpers — Claude-style → OpenCode
    native shape
  - validateOpenCodeNativeMCPEntry + opencodeMCPLocal /
    opencodeMCPRemote / opencodeMCPEnabledOnly / opencodeMCPOAuth
    typed structs — strict-decode native-shape entries against the
    schema (DisallowUnknownFields), plus targeted post-decode
    assertions for timeout / oauth / callbackPort

server/pkg/agent/opencode.go:
  - 12 lines of env injection in Execute(), placed AFTER buildEnv so
    the daemon's value wins via os/exec dedup
  - warning log when agent.custom_env duplicates the same key
  - no on-disk state, no rollback closure, no post-run cleanup —
    OPENCODE_CONFIG_CONTENT lives only in the spawned process env

server/pkg/agent/opencode_mcp_test.go (new):
  - TestBuildOpenCodeMCPConfigContent_{Empty,Remote,Local,Native}
  - TestBuildOpenCodeMCPConfigContent_NativeAcceptsAllSchemaFields —
    covers each native variant round-tripping every optional field
    (local with env+timeout+enabled; remote with headers+oauth-object+
    timeout+enabled; remote with oauth: false; bare {enabled} override)
  - TestBuildOpenCodeMCPConfigContent_RejectsMalformedNative — 31-case
    table covering every constraint on Bohan-J's review: command must
    be a string array, environment / headers values must be strings,
    oauth must be an object or false, timeout must be a positive
    integer, additionalProperties: false (per-shape allow-list checked
    via DisallowUnknownFields)
  - TestOpencodeBackendInjectsMCPConfigViaEnv — E2E happy path; fake
    opencode binary captures $OPENCODE_CONFIG_CONTENT, asserts the
    translated mcp slice is present AND <workdir>/opencode.json was
    NOT written
  - TestOpencodeBackendOmitsMCPEnvWhenEmpty — empty mcp_config does
    NOT inject the env, preserving any value the user set in
    agent.custom_env
  - TestOpencodeBackendOverridesUserOpenCodeConfigContent — daemon
    value wins via os/exec dedup keep-last

apps/docs/content/docs/providers.{en,zh}.mdx:
  - flip OpenCode's MCP cell from  to 
  - reword the "MCP configuration: only Claude Code actually reads it"
    section so OpenCode is included; describe each tool's mechanism
    (Claude → `--mcp-config`, OpenCode → OPENCODE_CONFIG_CONTENT)

apps/docs/content/docs/install-agent-runtime.{en,zh}.mdx:
  - update the Claude Code blurb (no longer "the only one")
  - expand the OpenCode blurb to mention mcp_config support
  - fix the now-broken /providers anchor

Refs #2106 (TS types and per-agent UI for mcp_config are separate
follow-ups, not in this PR).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:08:21 +08:00
Naiyuan Qing
973a43923f fix(comments): revert since-delta to issue-wide, steer to parent thread first (#3535)
#3509/#3523 scoped the comment-trigger since-delta count to the triggering
thread, so an agent resuming a busy issue only saw "+N in this thread" and
lost visibility of new comments in other threads. Revert the count to
issue-wide (every thread), keeping the trigger-comment + agent-own
exclusions, and reshape the warm-path hint to:

  - report the issue-wide new-comment volume,
  - steer the agent to read the triggering (parent) thread FIRST
    (`--thread <trigger> --since`, or `--tail 30` for full context),
  - demote the issue-wide `--since` catch-up to an only-if-needed fallback
    ("don't read them all blindly").

Also fixes the now-stale "scoped to the triggering thread" wording in the
resumed-session no-delta hint (it's issue-wide zero now).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 20:13:23 +08:00
Multica Eve
d1c7d478e1 MUL-2785: clarify thread-scoped comment delta (#3523)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 17:16:58 +08:00
LinYushen
e024348c1f fix(cli/login): accept mcn_ Cloud Node PATs alongside mul_ (MUL-2815) (#3518)
* fix(cli/login): accept mcn_ Cloud Node PATs alongside mul_ (MUL-2815)

multica login --token rejected anything not starting with mul_, so
users with a Multica Cloud Node PAT (mcn_ prefix) hit
"invalid token format: must start with mul_" even though the server
middleware verifies both kinds.

Replace the inline literal check with validateLoginTokenPrefix(), backed
by a small loginTokenPrefixes list ({mul_, auth.CloudPATPrefix}) so the
accepted set has one source of truth. Add unit-test coverage so adding
a new prefix in future is an obvious one-line edit.

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

* fix(cli/login): mention mcn_ Cloud Node PATs in --token help and comments

Follow-up to 47e423c4: the login command now accepts mcn_ tokens but the
help string and surrounding comments still only documented mul_, so a user
running 'multica login --help' couldn't tell that mcn_ was supported.
Update the --token help string and the cobra Args / NoOptDefVal comments
to list both mul_... and mcn_... prefixes.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 15:55:09 +08:00
김보경/DAXTF
5aa4fb7487 MUL-2760: feat(i18n): add Korean locale support (#3369)
* feat: add korean locale support

* feat(i18n): localize Korean landing page

* fix(i18n): refine Korean landing copy

* fix(i18n): refine Korean translations

* fix(i18n): translate Korean landing subpages

* fix(i18n): route Korean landing docs links

* fix(i18n): add Korean use case content

* fix(i18n): polish Korean locale copy

* fix(i18n): improve Korean landing copy

* fix(onboarding): persist Korean helper artifacts

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

* fix(web): add use case locale fallback

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

* Align Korean pull requests wording

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

* fix(i18n): dedupe docs href helper

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

* fix(i18n): localize changelog dates

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

* fix(docs): prerender Korean fallback pages

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

* fix(docs): align fallback hreflang metadata

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

* fix(i18n): preserve Chinese CJK font fallback order

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

* chore(onboarding): update localized comment wording

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

* test(i18n): harden CJK font fallback assertions

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

* fix(docs): keep Chinese font fallbacks first

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

* test(i18n): harden locale fallback coverage

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 15:16:22 +08:00
Multica Eve
9616d78e47 MUL-2785: optimize resumed comment reads (#3509)
* feat(comments): skip default thread read on resumed comment sessions

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

* fix(comments): scope since delta to trigger thread

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

* chore(comments): address thread delta review nits

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-29 14:57:14 +08:00
Bohan Jiang
75b5be3f8e feat(comments): roots-only thread stats + summary projection for comment list (MUL-2809) (#3505)
* feat(comments): roots-only thread stats + summary projection for comment list

Enrich the roots_only read so each root carries reply_count (recursive
descendant count) and last_activity_at (MAX created_at over the subtree),
letting an agent triage which thread to open without fetching any replies.

Add an orthogonal summary=true projection (--summary) that clips each
returned comment's content to a fixed budget and sets content_truncated,
so an agent can scan a list cheaply before pulling a full body. It composes
with every read mode (default, since, thread, recent, roots_only).

New response fields are optional (omitempty) and only populated for the
agent-facing query params, so the default response shape is unchanged for
the desktop/web and existing CLI callers.

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

* test(comments): cover roots_only + summary composition end-to-end

The summary projection composing with roots_only is the spec's headline
"table of contents" read, but it was only exercised at the CLI param-
forwarding level — no handler test asserted that a roots_only response
both clips content AND keeps reply_count / last_activity_at. A refactor
moving the clip into a per-mode branch would silently break that
composition with no failing test.

Add TestListComments_RootsOnlySummaryComposes: a long root + a reply,
read via roots_only=true&summary=true, asserting the root is clipped
(content_truncated=true) while its subtree stats still surface.

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

* refactor(comments): address review nits on roots stats + summary

- ListRootComments[Since]ForIssue: scope the recursive membership walk to a
  selected_roots CTE (the @row_limit page, with the @since cut applied up front)
  so stats are only computed over the subtrees of the roots actually returned,
  instead of every thread in the issue.
- summarizeContent: scan by rune and stop at the budget+1th rune instead of
  allocating a full []rune for the whole body, so a pathologically long comment
  costs only the budget under summary mode. Add a multi-byte (CJK) test to lock
  rune-boundary clipping.

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-29 12:59:53 +08:00
Bohan Jiang
ca1ea5716e fix(server/child-done): trigger agent parent assignee on child done (MUL-2808) (#3507)
Remove the agent-path self-trigger guard in triggerChildDoneAgent so a child going done wakes its parent agent even when the same agent owns both — a serial sub-task handoff across two different issues, not a loop. Runaway re-triggering stays bounded by HasPendingTaskForIssueAndAgent. Squad path unchanged. Closes #3374.
2026-05-29 12:49:07 +08:00
Fangfei
c730e906b9 feat(cli): add roots-only issue comment listing (MUL-2805) (#3288) 2026-05-29 12:03:38 +08:00
Naiyuan Qing
3187bbf90c feat(comments): re-add since-delta + cold-start thread read + parent-root write normalization (#3494)
* 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>

* feat(comments): collapse reply parent_id to thread root on write

Comment threads are a 2-level model (root + flat replies, like Linear/Slack),
enforced today only by the UI and the agent path — the CreateComment handler
stored whatever parent_id it was handed, and the agent-side flatten walked just
one level, so a reply-to-a-reply could land at depth 3+. Add GetThreadRoot (a
recursive walk to the parent_id=NULL root) and run both write paths
(handler.CreateComment, service.createAgentComment) through it, so every stored
reply's parent_id IS its thread root. Readers can now treat parent_id as the
thread root without re-walking. The agent-drift guard still compares the raw
parent_id to the trigger comment before normalization.

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

* feat(comments): cold-start reads triggering thread, warm keeps --thread pointer

The since-delta rework dropped the thread-first read on the COLD path: a
first-time agent fell back to the flat `comment list` dump (oldest-first, cap
2000), burying the trigger's context in ancient chatter. Point cold start at the
triggering conversation instead via a shared BuildColdCommentsHint
(`--thread <trigger> --tail 30` + a --recent pointer for cross-thread
background). On the WARM path, --since is a pure time delta and can miss the
triggering thread's pre-anchor history, so BuildNewCommentsHint now also emits a
--thread pointer. Both surfaces (per-turn prompt + CLAUDE.md workflow) render via
the shared helpers so they cannot drift (PR #2816 rule).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:38:37 +08:00
Bohan Jiang
e1745d09ea MUL-2797 feat(agent): add Claude Opus 4.8 to model catalog & pricing (#3492)
Claude Code now ships Opus 4.8 (claude-opus-4-8). Add it to the three
places that enumerate Claude models so the picker, thinking-level
catalog, and usage cost estimates all recognize it:

- claudeStaticModels(): list Claude Opus 4.8 (Sonnet 4.6 stays default)
- claudeModelEffortAllow: Opus supports the full low..max set incl. xhigh
- MODEL_PRICING: $5/$25 in, $0.50 cache read, $6.25 5m cache write —
  same current-gen Opus tier as 4.5/4.6/4.7, confirmed against
  platform.claude.com/docs/en/about-claude/pricing

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 10:28:30 +08:00
Bohan Jiang
270fb6aa73 MUL-2792 fix(agent): preserve skills in update/archive/restore response (#3464)
* MUL-2792 fix(agent): preserve skills in update/archive/restore response (#3459)

agentToResponse always initialises Skills as []; the mutation handlers
relied on the caller to refresh it, but only GetAgent and ListAgents
actually did. UpdateAgent / ArchiveAgent / RestoreAgent therefore
returned "skills": [] regardless of what the agent_skill junction table
contained.

The DB write path was never wrong — skills weren't actually deleted —
but the misleading response (and its matching agent:status / archived /
restored WS broadcast) scared users into manually re-running
`agent skills set` and risked scripted clients writing the empty set
back as truth.

Extract the existing GetAgent skill-reload block into attachAgentSkills
and call it from the three buggy handlers. Add regression tests that
attach skills, hit each mutation endpoint, and assert both the response
and the junction table.

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

* fix(agent): attach skills before env/template broadcasts (#3459)

Two follow-up sites flagged in PR #3464 review that shared the same
"agentToResponse zeroes Skills, callers forget to reload" pattern as the
mutation handlers:

- agent_env.go: the agent:status broadcast after UpdateAgentEnv used a
  bare agentToResponse, so subscribers saw skills wiped on every env
  rotation. HTTP body is AgentEnvResponse so the response itself is
  unaffected, but the WS event still misleads any cache that ingests it.
- agent_template.go: CreateAgentFromTemplate attaches imported and extra
  skills inside the tx, then builds the response/agent:created broadcast
  without reloading them — so callers (and any client tracking the
  create event) see the freshly created agent as skill-less despite the
  template having just imported them.

Both call sites now reuse attachAgentSkills introduced for UpdateAgent.

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 19:06:57 +08:00
Bohan Jiang
fa076d38f2 MUL-2778 feat(agent): wire mcp_config through OpenClaw runtime (#3450)
* MUL-2778 feat(agent): wire mcp_config through OpenClaw runtime

The MCP config tab (#3419) lets admins save mcp_config on an agent, and
recent work (#3439) plumbed it through the three ACP runtimes. OpenClaw
still ignored the field, leaving the Tab silently inert for any
OpenClaw-backed agent.

Translate the agent's Claude-style `{"mcpServers": {...}}` into the
per-task OpenClaw wrapper's `mcp.servers` block — OpenClaw resolves MCP
via its own config schema rather than ExecOptions, so the existing
OPENCLAW_CONFIG_PATH preparer is the right seam. Fail closed on
malformed JSON / entries missing `command` or `url`, matching the
fail-closed posture the preparer already uses for the agents.list step.
Null / absent mcp_config leaves the wrapper free of an `mcp` key so the
user's global mcp.servers flows through untouched; an explicit empty
managed set (`{}` / `{"mcpServers":{}}`) is honoured as "admin saved no
servers" mirroring `hasManagedCodexMcpConfig`.

Strict-mode replacement (drop user-only servers entirely) would require
OpenClaw to do a per-key replace rather than a deep merge at
`mcp.servers`; the comment documents that caveat rather than relying on
undocumented behaviour.

Also adds `openclaw` to `MCP_SUPPORTED_PROVIDERS` so the MCP Tab
actually surfaces in the agent overview pane, and pins the new
visibility case with a renderPane test.

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

* MUL-2778 fix(agent): make openclaw mcp_config strict-replace via sanitized snapshot

Elon flagged on #3450 that the previous wiring let user-only mcp.servers
leak through the wrapper's `$include` of the live user config: deep-merge
at `mcp.servers` keeps user-only names, and the strict-empty case
(`{ "mcpServers": {} }`) silently inherited user globals.

Switch the strict-replace path to write a sanitized snapshot of the
user's fully resolved config (via `openclaw config get --json`) with the
`mcp` block stripped, then have the wrapper `$include` the snapshot
instead of the live user file. With the user's `mcp` gone from the
$include resolution, the wrapper's `mcp.servers` is the only definition
the embedded OpenClaw sees — managed only, including the explicit empty
set.

The snapshot lives in envRoot at 0o600 alongside the wrapper so the GC
reaper sweeps it with the rest of the task scratch, and no extra
OPENCLAW_INCLUDE_ROOTS entry is needed (same-dir $include).

Fail-closed on `config get --json` errors so the daemon never silently
falls back to the leaky $include path. The inherit branch (null
mcp_config) still uses the live user file directly — no extra CLI
roundtrip and no snapshot is written.

New tests pin the contract Elon's review required:

- TestPrepareOpenclawConfigStrictReplacesUserMcpServers: user has
  global_one + shared, managed has shared + managed_only → wrapper has
  exactly {shared (managed value), managed_only}; global_one does NOT
  leak; snapshot file has the user's `mcp` stripped while preserving
  gateway / providers / API keys.
- TestPrepareOpenclawConfigStrictEmptyManagedSetDropsUserMcp: empty
  managed set drops user's global_one (both `{}` and
  `{"mcpServers":{}}` cases).
- TestPrepareOpenclawConfigNullMcpConfigKeepsUserInclude: null path
  inherits the live user config, writes no snapshot, makes no extra CLI
  call.
- TestPrepareOpenclawConfigFailsClosedOnResolvedConfigError: errors
  during `config get --json` surface; no stale wrapper or snapshot.
- TestPrepareOpenclawConfigManagedSetFreshInstall: fresh install with
  managed mcp_config skips the snapshot dance entirely.

Also tightens en + zh-Hans MCP Tab copy to mention OpenClaw goes via the
per-task wrapper, and to use OpenClaw's own `transport` field rather
than Claude's `type` for HTTP/SSE entries.

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

* MUL-2778 fix(agent): narrow openclaw snapshot strip to mcp.servers only

Elon's third-round must-fix: the previous strict-replace snapshot deleted
the entire `mcp` block, which wiped out non-server settings under `mcp`
like `sessionIdleTtlMs`. Those are documented OpenClaw config keys
(https://docs.openclaw.ai/gateway/configuration-reference#mcp) outside
the MCP Tab's scope — the agent's saved mcp_config only manages server
definitions, so other `mcp.*` tuning the user set must survive.

Replace the blanket `delete(resolved, "mcp")` with a stripUserMcpServers
helper that:

- deletes only `mcp.servers` when `mcp` is an object
- drops the parent `mcp` key only when the object is empty after the
  strip (so we don't emit `mcp: {}` placeholders)
- leaves non-object `mcp` values untouched (we only know how to strip
  servers from the documented shape)

Pinned with TestPrepareOpenclawConfigStrictPreservesNonServerMcpKeys:
user resolved has both `mcp.sessionIdleTtlMs: 300000` and
`mcp.servers.global_one`; after the strict path runs the snapshot
keeps the TTL and drops the servers map, and the wrapper's
`mcp.servers` is exactly the managed set with no leak.

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 18:43:02 +08:00
Naiyuan Qing
d90732750f Revert "feat(comments): since-delta new-comment hint + default-on comment ses…" (#3455)
This reverts commit 5e78e5100a.
2026-05-28 17:52:59 +08:00
Bohan Jiang
ee4ec3b76d MUL-2784 fix(daemon): cleanup sidecar tree (.agent_context / .multica / provider skills) after local_directory tasks (#3444)
* fix(daemon): cleanup .agent_context / .multica / provider skill sidecars after local_directory tasks (MUL-2784)

PR #3438 (MUL-2753) only restored CLAUDE.md / AGENTS.md / GEMINI.md to
their pre-task bytes; the sidecar tree writeContextFiles seeds
(.agent_context/, .multica/, .claude/skills/, .github/skills/,
.opencode/skills/, skills/, .pi/skills/, .cursor/skills/,
.kimi/skills/, .kiro/skills/, .agents/skills/, fallback
.agent_context/skills/) was explicitly deferred to this follow-up. In
local_directory mode the agent's workdir is the user's repo, so each
task accumulates one more layer of those directories in the user's
tree.

Plan A: track every file/dir Prepare creates inside workDir in a
sidecarManifest written to envRoot/.multica_sidecar_manifest.json
(daemon scratch — never in the user's workdir). On local_directory
teardown CleanupSidecars walks the manifest, removes the recorded
files, then rmdir-iterates the recorded directories in reverse.
Pre-existing files and directories are deliberately NOT recorded, so
a user-installed .claude/skills/my-own-skill/ sibling — or any
unrelated file the user keeps under .claude/, .github/, etc. — is
preserved bit-for-bit. Non-empty rmdir fails ENOTEMPTY and is
silently skipped, which is the signal that the user owns the
directory.

Daemon wiring lives next to the existing CleanupRuntimeConfig defer
in runTask: runtime brief first, sidecars second. Cloud-mode runs
still write a manifest for symmetry but never trigger the cleanup
(the GC loop wipes envRoot wholesale).

Tests (sidecar_manifest_test.go) cover the round-trip invariant per
the issue's acceptance criteria:

- empty workdir → Prepare → Cleanup → empty workdir, byte-exact, for
  every file-based provider (claude, codex, copilot, opencode,
  openclaw, hermes, pi, cursor, kimi, kiro, antigravity, gemini),
- user's .claude/skills/my-own-skill/ (and equivalents per
  provider) survives Cleanup intact,
- unrelated user files under .claude/, .github/, etc. survive,
- three repeated cycles do not accumulate any orphan state,
- project_resources branch (.multica/project/resources.json) is
  also reversible,
- recordWriteFile refuses to record pre-existing files,
- recordMkdirAll refuses to record pre-existing dirs,
- Cleanup is a no-op when the manifest file is missing.

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

* fix(daemon): refuse to overwrite pre-existing sidecar paths; pick collision-free skill slugs (MUL-2784 review)

Addresses PR #3444 review (Elon):

**Must-fix #1**: recordWriteFile used to overwrite pre-existing target
files unconditionally and only skip the manifest record. That destroys
user bytes at write time AND leaves the corrupted contents in place at
cleanup time — the byte-exact contract the issue requires is violated
on both halves. Fixed by making recordWriteFile detect any pre-existing
entry (regular file, symlink, directory) via Lstat and return a
sentinel errPathPreExists without touching the path. The user's bytes
are preserved verbatim.

For per-skill collisions (user's .claude/skills/issue-review/ vs
Multica's "Issue Review"), writeSkillFiles now allocates a
collision-free sibling slug via allocateCollisionFreeSkillDir: first
attempt is the natural slug, then `<base>-multica`,
`<base>-multica-2`, …, bounded at 64 attempts. Provider-native
discovery still picks the skill up (every subdir under skillsParent is
a distinct skill) and the user's path stays bit-for-bit intact.

For Multica-only namespace files (.agent_context/issue_context.md,
.multica/project/resources.json), the writer swallows errPathPreExists
and continues — the runtime brief already carries every fact those
files would, so a collision degrades to brief-only mode rather than
destroying user content.

**Must-fix #2**: Added byte-exact collision matrix tests covering
every file-based provider (claude / codex / copilot / opencode /
openclaw / hermes / pi / cursor / kimi / kiro / antigravity / gemini):

- TestPrepareThenCleanupSidecarsSameSlugCollisionPerProvider: seeds
  user's `<provider>/skills/issue-review/SKILL.md` plus a private
  notes.md sibling, runs Prepare → Inject → Cleanup, asserts
  workdir snapshot is byte-identical to seed.
- TestPrepareThenCleanupSidecarsIssueContextCollisionPerProvider:
  seeds user's `.agent_context/issue_context.md`, asserts round-trip
  preserves it.
- TestPrepareThenCleanupSidecarsProjectResourcesCollisionPerProvider:
  same for `.multica/project/resources.json`.
- TestPrepareThenCleanupSidecarsMultiSkillCollisionFreeAllocation:
  end-to-end check that the Multica skill lands at the
  collision-free sibling and Cleanup removes only the Multica side.
- TestAllocateCollisionFreeSkillDir: directed unit test pinning the
  slug-bumping sequence.
- TestRecordWriteFileRefusesToOverwritePreExistingFile (was
  TestRecordWriteFileSkipsPreExistingFile): flipped to assert the
  user's bytes survive and errPathPreExists is returned.
- TestRecordWriteFileRefusesToOverwriteSymlinkOrDir: covers the
  Lstat path for non-file entries.

**Should-fix**: CleanupSidecars used to swallow ANY non-ENOENT rmdir
error as "user content present," silently dropping real I/O failures
(EACCES, EPERM, EBUSY). Now it re-reads the directory after a failed
rmdir via the new dirHasEntries helper — non-empty → silently skip
(ENOTEMPTY, the intended branch); empty → genuine error, captured
into firstErr and surfaced. Plus directed tests:

- TestCleanupSidecarsSurfacesRealRmdirErrors
- TestDirHasEntries

Local verification:
- go test ./internal/daemon/execenv/... — all green
- go test ./internal/daemon/... — all green
- go vet ./... — clean

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

* fix(daemon): surface original rmdir error when post-rmdir ReadDir also fails (MUL-2784 review)

Addresses remaining PR #3444 review blocker (Elon): dirHasEntries used
to return true when ReadDir failed with anything other than ENOENT,
which made CleanupSidecars treat every locked / faulted directory as
ENOTEMPTY and silently drop the original rmdir error. The v1 fix from
the previous round closed the EACCES-on-empty-dir branch but missed
the case where the chmod also blocks ReadDir — exactly the failure
mode the review called out.

Helper change: dirHasEntries now returns (hasEntries, ok bool):

  - (false, true)  — dir exists and is empty (or missing, race-safe)
  - (true,  true)  — dir has user content (the ENOTEMPTY branch)
  - (_,     false) — ReadDir failed (EACCES, ENOTDIR, EIO, …); the
                     caller cannot tell ENOTEMPTY from a real error
                     and MUST surface the original rmdir error

CleanupSidecars switches on (ok, hasEntries):

  - !ok               → surface the ORIGINAL rmdir error (not the
                        ReadDir failure — that's diagnostic plumbing
                        and would distract from the root cause)
  - ok && hasEntries  → swallow silently (intended ENOTEMPTY branch;
                        preserve user content)
  - ok && !hasEntries → surface the rmdir error (empty dir + EACCES /
                        EPERM / EBUSY → genuine cleanup failure)

Tests:

  - TestDirHasEntries: extended with a regular-file sub-case (ReadDir
    returns ENOTDIR) asserting (false, false). The v1 helper returned
    (true) here, hiding the bug.
  - TestCleanupSidecarsSwallowsMissingAndNonEmptyDirs: renamed from
    TestCleanupSidecarsSurfacesRealRmdirErrors. The old name claimed
    to test the surfacing path but never actually exercised it.
  - TestCleanupSidecarsSurfacesEACCESOnEmptyRecordedDir: chmod parent
    to 0o555 so rmdir(recorded) fails EACCES while ReadDir(recorded)
    still succeeds (empty). Asserts firstErr is non-nil and references
    both the recorded path and the rmdir branch. Skipped when running
    as root (chmod is bypassed for uid 0).
  - TestCleanupSidecarsSurfacesEACCESWhenReadDirFailsToo: the must-fix
    case — chmod parent 0o555 AND chmod recorded 0o000 so BOTH rmdir
    and ReadDir fail. The surfaced error must be the ORIGINAL rmdir
    failure, not the ReadDir one. Skipped on uid 0.

Local verification:
- go test ./internal/daemon/execenv/... — all green
- go test ./internal/daemon/... — all green
- go vet ./... — clean

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 17:22:47 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
YOMXXX
7d24a8594a fix(comments): support edit-time attachment removal (#2965) 2026-05-27 09:48:59 +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
Bohan Jiang
298f54c819 fix(agents): gate on_comment trigger with private-agent visibility (MUL-2702) (#3302)
Closes #3300.

After #2359 added canAccessPrivateAgent to chat, @mention, ListAgents,
GetAgent, history, edit, delete and issue assignment, one trigger path
was missed: shouldEnqueueOnComment. Once an owner/admin assigned a
private agent to an issue, the agent's UUID was "welded" onto that
issue and any workspace member who could view the issue could dispatch
a new task to it by posting a plain (non-@mention) comment — bypassing
the visibility gate the #2359 work was supposed to enforce.

Mirror the @mention path: plumb (authorType, authorID) from
CreateComment into shouldEnqueueOnComment, load the assigned agent, and
gate it with canAccessPrivateAgent before enqueueing. Add a Go
regression test on the existing privateAgentTestFixture covering the
plain-member, agent-owner, workspace-owner and agent-to-agent cases.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 18:58:06 +08:00
Multica Eve
311cf4d998 fix(agent): surface Codex app-server no-progress diagnostics (MUL-2688)
Refs #3262.
2026-05-26 18:42:47 +08:00
Multica Eve
26ff52385b fix: attribute Hermes usage to current model (MUL-2696)
Fix Hermes ACP usage attribution to current model when agent.model is unset.

Also preserves cache-read token accounting and makes ACP model-list parsing more tolerant of snake_case payloads and Unknown display names.
2026-05-26 18:13:28 +08:00
Multica Eve
744b474199 revert(agent): remove per-agent local skill toggle (MUL-2603) (#3286)
* Revert "feat(agents): hide skills_local toggle for runtimes that don't honour it (MUL-2603) (#3276)"

This reverts commit 0b50c5a209.

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

* Revert "fix(agent): surface host OAuth token via env var on macOS isolation (MUL-2603) (#3267)"

This reverts commit a67bf81225.

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

* Revert "fix(agents): tighten skills-tab intro and drop redundant import hint (#3265)"

This reverts commit d8075a5775.

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

* Revert "fix(agent): mirror $HOME/.claude.json into isolated config dir (MUL-2661) (#3261)"

This reverts commit 40da88fc16.

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

* Revert "feat(agent): per-agent toggle to isolate host-machine skills (MUL-2603) (#3200)"

This reverts commit 960befa56f.

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

* Add migration cleanup for reverted agent skills toggle

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-26 17:00:01 +08:00
Bohan Jiang
ae11f290b4 fix(server): gate GitHub auto-close on closing keywords (MUL-2680) (#3281)
* fix(server): gate GitHub auto-close on closing keywords (MUL-2680)

Closes multica-ai/multica#3264. The PR webhook previously treated any
mention of an issue identifier in a PR title/body/branch as a close
intent, so a body of "Closes MUL-1. Follow up in MUL-2. Unblocks MUL-3."
would advance all three issues to done on merge. The auto-link layer
stays generous (mentions still link the PR), but advancing to done now
requires an explicit "Closes/Fixes/Resolves MUL-X" keyword adjacent to
the identifier in the title or body — bare title prefixes (`MUL-1: ...`)
and branch-name references no longer auto-complete.

MUL-2680

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

* fix(server): persist close_intent on issue↔PR link rows (MUL-2680)

The first take of MUL-2680 gated auto-advance on `closingIdents[id]` from
the current webhook event. That broke the multi-PR sibling case: a PR
declaring `Closes MUL-X` could merge first while a link-only sibling
stayed open, leaving the issue in_progress; when the sibling closed
later, its webhook carried no closing keyword and the handler skipped
re-evaluation, so the issue stayed stuck forever.

Move close intent from per-event state to per-link state:

- New `close_intent` column on `issue_pull_request` (migration 109),
  set monotonically — `LinkIssueToPullRequest` ORs the existing flag with
  the incoming one so a subsequent webhook re-fire without the keyword
  cannot clear it.
- New `GetIssuePullRequestCloseAggregate` query returns open-count and
  merged-with-close-intent-count for an issue. The auto-advance gate
  now reads from this persisted aggregate, which is event-agnostic: any
  terminal linked-PR event re-evaluates and the verdict only depends on
  accumulated DB state.
- Webhook handler links all mentioned identifiers first (writing
  close_intent for the ones declared with a keyword), then iterates the
  affected issues in a separate pass to re-evaluate. The 'only fires for
  keyword-declared identifiers in this event' gate is gone — replaced by
  `merged_with_close_intent_count > 0` against the link rows.

Regression test `TestWebhook_LinkOnlySiblingMergeAfterCloseKeywordPR`
walks the full open→merge→open→merge sequence Elon described and asserts
the issue advances on the link-only sibling's merge.

MUL-2680

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

* Fix GitHub close intent updates

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
2026-05-26 16:45:46 +08:00
Bohan Jiang
91506e7f7b refactor(cli): rename daemon status helper and align value column (MUL-2676) (#3275)
- Rename printDaemonStatusTable -> printDaemonStatusReport. The helper
  emits a key/value list, not a table; the old name implied a tabular
  layout that never existed and made the call site read wrong.

- Align the value column dynamically off the widest key. Previously the
  spacing was hard-coded so the static rows (Version/Agents/Workspaces)
  all landed at column 14, but the dynamic "Daemon [profile]" label
  could outgrow that and push only its own value rightward, breaking
  vertical alignment as soon as a profile was active.

- Add negative coverage for cli_version absent / empty (the real
  back-compat contract for older daemons paired with a newer CLI) and a
  test that asserts the value column lines up under a long profile
  label.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 15:34:33 +08:00