Commit Graph

169 Commits

Author SHA1 Message Date
Bohan Jiang
f9c193e06b fix: fail closed on agent task auth tokens (#4142)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:34:35 +08:00
YOMXXX
34d4cd3a28 feat(openclaw): support connecting to existing OpenClaw gateway (#3260) [MUL-3158] (#3664)
* feat(openclaw): support connecting to existing OpenClaw gateway (#3260)

When the daemon host is a lightweight dev machine or CI coordinator, the
heavy agent work (LLM inference, code execution, tool use) often belongs
on a more powerful remote server already running an OpenClaw gateway.
Multica historically hard-coded `openclaw agent --local`, forcing every
turn to execute in-process on the daemon host.

This change adds an opt-in gateway routing mode controlled per-agent via
`runtime_config`:

  {
    "mode": "gateway",
    "gateway": { "host": "...", "port": 18789, "token": "...", "tls": false }
  }

- Backend: ExecOptions gains OpenclawMode + OpenclawGateway; buildOpenclawArgs
  drops `--local` when mode == "gateway". Per-task openclaw-config.json
  wrapper pins gateway.{host,port,auth.{mode,token},tls} so users do not
  need to edit the daemon host's `~/.openclaw/openclaw.json` to point at
  a different endpoint.
- Daemon: AgentData carries the raw runtime_config; decoding is fail-soft
  (malformed JSON falls back to local mode rather than blocking dispatch).
- API: gateway.token is masked to "***" on every GET; PATCH replays the
  sentinel back, and the update handler restores the persisted token so
  the round-trip never destroys the secret. Defense-in-depth masking on
  WS broadcasts, plus String/MarshalJSON masking on the in-memory struct
  to block stray `%+v` / json.Marshal leaks.
- UI: openclaw-only "Routing" tab on the agent detail page with mode
  selector + structured endpoint form. Token uses a "saved — submit a
  new value to rotate" UX and matching backend preserve hook.

Empty `runtime_config` keeps the historical embedded behaviour, so
existing agents are unaffected.

* fix(openclaw): address #3664 review — drop dead gateway field, gate pin on mode

Per Bohan-J's review:

- Remove the dead ExecOptions.OpenclawGateway field (+ its String/MarshalJSON and
  the daemon.go construction block). It carried the plaintext bearer token but was
  never read — buildOpenclawArgs only consumes OpenclawMode and the live gateway
  path runs through execenv.OpenclawGatewayPin — so this narrows the secret's
  footprint.
- Gate the gateway pin on mode=="gateway" in decodeOpenclawRuntimeConfig: a
  {"mode":"local","gateway":{...,"token"}} payload no longer writes the token into
  the 0o600 per-task wrapper that --local makes openclaw ignore.
- Warn on an unrecognized non-empty mode (e.g. "gatway") instead of silently
  falling back to local.
- Run preserveMaskedGatewayToken in CreateAgent too, so a literal "***" at create
  time can't persist as a real bearer token.
- Document the gateway host:port trust boundary (SSRF note for shared daemon hosts).

Adds regression tests for the local-mode pin drop and the unknown-mode warning.
2026-06-13 15:33:28 +08:00
Bohan Jiang
f415099c4a MUL-3263: support managed MCP config for Cursor (#4081)
* feat: support managed MCP config for Cursor

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

* fix: address Cursor MCP review feedback

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

* docs: include Cursor in skills MCP support

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 02:07:00 +08:00
Bohan Jiang
c8ab73d38d MUL-3244: Bind quick-create attachments to created issues (#4062)
* fix: bind quick-create attachments to created issues

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

* test: use real image markdown in quick-create attachment test

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 16:45:38 +08:00
Liu Guanzhong
4594c776e1 feat(agent): add CodeBuddy as first-class CLI backend (#3186)
* feat(agent): add codebuddyBackend struct and buildCodebuddyArgs

Introduces the codebuddy agent backend skeleton with args builder
that mirrors claudeBackend's protocol flags (stream-json, bypass
permissions, blocked args filtering) for the codebuddy CLI fork.

* feat(agent): implement codebuddyBackend.Execute with stream-json parsing

* feat(agent): wire codebuddy into New() factory and launchHeaders

* feat(agent): add codebuddy dynamic model discovery from --help

* feat(agent): add codebuddy thinking/effort discovery and providerThinkingEnums

* feat(daemon): add codebuddy CLI probe, env vars, and args support

* fix(agent): use len(models)==0 for default model instead of loop index

* fix(agent): increase codebuddy --help timeout to 35s for slow CLI startup

* fix(agent): address codebuddy PR review feedback

- Wire codebuddy into execenv: reuse claude's CLAUDE.md, .claude/skills,
  and ~/.claude/skills paths since CodeBuddy is a Claude Code fork
- Replace hardcoded 20-min timeout with runContext for zero-timeout =
  no-deadline semantics matching all other backends
- Restore runContext regression tests lost in rebase merge
- Mirror claude.go execution model: concurrent stdin write to prevent
  pipe deadlock, sync.Once for stdin closure, keep stdin open for
  control_request auto-approval mid-run
- Add control_request handling with auto-approve behavior
- Add RequestID/Request fields to codebuddySDKMessage
- Add codebuddy to metrics knownRuntimeProviders
- Add codebuddy to provider-logo.tsx (reuses ClaudeLogo)
- Consolidate --help discovery: shared codebuddyHelpOutput cache
  eliminates duplicate cold-start invocations

---------

Co-authored-by: krislliu <krislliu@tencent.com>
2026-06-12 15:22:16 +08:00
Multica Eve
9439a85aa6 MUL-3242: fix daemon workdir provisioning race
Fixes GitHub issue #3999 by moving the daemon StartTask transition behind workdir provisioning and extending the active env-root guard through completion metadata writes.
2026-06-12 15:14:27 +08:00
Bohan Jiang
8151f60c6c fix(daemon): drop stale resume session when workdir is not reused (#4027)
CLI backends key their session stores to the cwd (Claude Code looks
sessions up under ~/.claude/projects/<encoded-cwd>/), so a prior session
id can only resolve when the task runs in the exact workdir the session
was recorded against. When the prior workdir no longer exists (GC'd
after the issue went done, daemon reinstall, manual cleanup),
execenv.Reuse falls back to a fresh Prepare but the stale session id was
still passed to the backend: claude exited within a second and the run
failed before doing any work — permanently, because the failed run
records no session_id and the next claim serves the same stale pointer
again.

Gate ResumeSessionID on the workdir actually being reused, and correct
PriorSessionResumed so the runtime brief uses the cold-path wording when
the session is dropped.

Fixes multica-ai/multica#3854 (MUL-3221)

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 13:07:44 +08:00
Bohan Jiang
24b162cdbc feat(daemon): surface the real task initiator to the agent runtime (MUL-2645) (#3899)
* feat(daemon): surface the real task initiator to the agent runtime (MUL-2645)

In a multi-person workspace the agent runtime only ever saw the runtime
OWNER identity: the brief's `## Requesting User` is sourced from
runtime.OwnerID and the task-scoped token is owner-bound, so every
requester (whoever commented, @mentioned, or chatted) appeared to the
agent as the owner. Agents that route by initiator for permission,
privacy, or audit all misjudged.

Resolve the real task initiator at claim time and surface it distinctly
from the owner:
- comment / mention trigger -> triggering comment's author (member or agent)
- chat task -> chat session creator (sessions are creator-only)
- on-assign / autopilot / quick-create -> no attributable initiator (omitted)

Adds initiator_{type,id,name,email} to the claim response, the daemon
Task, and TaskContextForEnv, rendered into the brief as a new
`## Task Initiator` section. The section documents the privacy boundary:
the agent's credentials stay owner-scoped, so this is an attested
identity for the agent's own routing/privacy logic, not act-as. No DB
migration — both paths are derivable from existing rows.

Tests: brief rendering (member/agent/omit/sanitize) + email guard unit
tests, and claim-handler tests for the comment and chat paths.

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

* fix(chat): store real sender as task initiator, not chat_session creator (MUL-2645)

Review fix (Niko, PR #3899). v1 resolved the chat task initiator from
chat_session.creator_id at claim time. That is correct for web chat and
Lark p2p (creator == sender), but WRONG for Lark group chats: the group
session creator is deliberately the installer (stable identity across
member churn), not the message sender. So in a Lark group, every member
who triggered the agent showed up in the brief as the installer/owner —
the exact bug this issue is about, still live at that entry point.

Capture the real sender at enqueue time instead of deriving it from the
session creator at claim time:

- migration 117: agent_task_queue.initiator_user_id (FK user, ON DELETE
  SET NULL); NULL for non-chat and pre-migration rows.
- EnqueueChatTask now takes an explicit initiatorUserID. Web chat passes
  the authenticated request user; the Lark dispatcher threads the inbound
  sender (binding.MulticaUserID) through scheduleRun -> flushChatRun. The
  debouncer keeps the latest scheduled flush per session, so in a multi-
  sender silence window the LATEST sender wins (documented + tested).
- claim handler resolves the initiator from task.initiator_user_id and
  drops the creator_id fallback entirely.

The Lark group session creator stays the installer (unchanged) — only the
task initiator is corrected, keeping the two concepts cleanly separate.

Tests: dispatcher group regression (initiator = sender, not installer),
latest-sender-wins, p2p initiator assertion; the chat claim handler test
now sets creator != initiator and asserts the stored sender wins.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 19:29:57 +08:00
0xMomo
ef75f80d9d fix(daemon): clean stale agent branches during repo gc (MUL-2550) (#3039)
* fix(daemon): 清理陈旧 agent 分支

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

* fix(daemon): 串行化 bare repo gc

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

* test(daemon): adapt health repo cache mock

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

* fix(daemon): gate gc maintenance on stale-branch deletion

Address review feedback on the bare-repo GC change:

- Only run `reflog expire` + `git gc --prune=30.days` when we actually
  deleted a stale agent branch this cycle. Previously the heavy step
  ran every GC tick on every cached repo even when there was nothing
  to reclaim, turning a stale-ref cleanup into a periodic full-repo
  maintenance job under the per-repo lock.
- Split git command timeouts: `gc --prune=30.days` now gets a
  10-minute budget instead of sharing the 30s ceiling that was scoped
  for the original `worktree prune` call. Light commands stay at 30s.
- Drop the redundant `gc --auto` — `gc --prune=30.days` already
  performs the maintenance `gc --auto` would have triggered.
- Narrow the agent-namespace ref query from `refs/heads/agent` to
  `refs/heads/agent/` so the pattern can't surface a literal
  `agent` branch outside the daemon namespace.

Tests:
- New TestPruneWorktree_IgnoresLiteralAgentBranch pins the trailing-
  slash narrowing.
- New TestPruneWorktree_SkipsMaintenanceWhenNothingDeleted uses an
  unreachable, backdated loose object as a sentinel to verify that
  `gc --prune` runs only when a stale agent branch was reaped.

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

---------

Co-authored-by: 0xNini Code Dev <agent@multica.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: 0xNini <0xnini@iMac-Pro.local>
Co-authored-by: J <j@multica.ai>
2026-06-08 15:25:14 +08:00
Bohan Jiang
3808049361 fix(codex): set semantic thread names (#3887)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 14:53:31 +08:00
NanamiKite
2e34016f1f fix(daemon): interrupt local agent on server-side terminal task states (#3878)
shouldInterruptAgent now treats every terminal task status (completed/failed/cancelled, via isAgentTaskTerminal) plus a 404 task-not-found as an interruption signal, so the daemon stops a local agent once the backend has finalized the task — e.g. the runtime offline sweeper flipping running -> failed during a disconnect/reconnect. Previously only `cancelled`/404 interrupted, so the agent ran to completion and its CompleteTask call failed against a non-running row, wasting compute and adding log noise.

Closes #3877
2026-06-08 14:00:30 +08:00
Wes
5e7587ad07 Optimize daemon runtime wakeups (#3859) 2026-06-08 12:51:13 +08:00
Xinmin Zeng
270d177475 fix: broken "Add a computer" command on Multica Cloud + two CLI amplifiers (MUL-3087) (#3817)
* fix(server): recognize official cloud by frontend host in daemon setup config

The 'Add a computer' dialog builds its command from /api/config's
daemon_server_url/daemon_app_url, falling back to 'multica setup' when
both are empty. The official cloud is meant to omit them, but the
omission only fired when MULTICA_PUBLIC_URL=https://api.multica.ai. When
that env is unset the server URL defaults to the frontend origin and the
old guard (which required serverURL host == api.multica.ai) didn't match,
so the dialog emitted 'multica setup self-host --server-url
https://multica.ai' — pointing the daemon backend at the frontend (no
/health, no WebSocket proxy).

Identify the official cloud by its frontend host alone (multica.ai /
app.multica.ai) so a missing or misconfigured MULTICA_PUBLIC_URL can no
longer leak the broken self-host command. Regression from #3474.

* fix(cli): probe before persisting self-host config to preserve auth on failure

setup self-host wrote a fresh CLIConfig{ServerURL, AppURL} (a full
overwrite that drops the saved token) and only then probed the server,
returning early on failure. A failed probe therefore logged the user out
and left them unconnected, with no recovery in the same command.

Probe first via persistSelfHostConfigIfReachable: an unreachable server
leaves the existing config — and its token — untouched (failed setup =
no-op). The prober is injected so both branches are unit-tested.

* fix(daemon): serve health before preflight so daemon start readiness is accurate

The CLI's 'daemon start' polls the health endpoint for 15s expecting
status=running, but the daemon only began serving health after
preflightAuth, whose initial workspace sync detects every configured
agent's version by exec'ing it (~20s cold with 8 agents). Health served
too late, so a perfectly healthy daemon printed 'may not have started
successfully'.

Start the health server right after resolveAuth (which still fails fast
on a missing token) and before the slow preflight, so readiness reflects
the daemon core being up rather than agent-version detection finishing.

* fix(daemon): gate /health readiness so daemon start can't report a false start

Serving health before preflightAuth fixed the false-negative (a healthy
daemon printed "may not have started"), but health still returned
status:"running" unconditionally — before preflight (PAT renew + workspace
sync + runtime registration) had completed. `daemon start` and the desktop
treat "running" as ready, so a slow or *failing* preflight could be
misreported as a started daemon: setup prints "connected", then the process
exits or hangs in agent-version detection with no runtime registered. That
is harder to diagnose than the original false-negative.

Split liveness from readiness: bind/serve the health port early (so callers
see a live "starting" daemon instead of connection-refused), but report
status:"starting" until d.ready is set after preflight, then "running".

- daemon.go: add d.ready (atomic.Bool); set it true after the background
  loops launch, before pollLoop.
- health.go: healthHandler reports "starting" until ready, else "running".
- cmd_daemon.go: `daemon start` waits for "running" with a deadline raised
  to 45s (covers cold-start agent detection) and a clearer "still starting"
  message; new daemonAlive() helper treats both "running" and "starting" as
  a live daemon, so the already-running guard, restart, and stop act on a
  starting daemon and don't double-spawn or race its listener; `daemon
  status` shows "starting" distinctly.

Older CLIs/desktop that only know "running" safely treat "starting" as
not-ready (status != "running"), so no boundary break.

Tests: health reports starting-then-running; daemonAlive truth table.
Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): handle daemon "starting" health status in lifecycle

The daemon now reports /health status:"starting" until preflight completes
(liveness/readiness split). That made "starting" a new external contract of
/health, but the Desktop daemon-manager only knew "running", so the readiness
fix would have moved the CLI's false-negative into a Desktop start regression:

- `daemon start` now blocks up to 45s waiting for readiness, but the Desktop
  spawned it via execFile({ timeout: 20_000 }). On a cold start (the ~20s agent
  detection this PR targets) Electron killed the CLI supervisor at 20s and
  reported a start failure, even though the detached daemon child kept booting —
  the UI flashed "stopped" then "running". Raise the timeout to 60s (must exceed
  the CLI's 45s startupTimeout).
- The Desktop treated only raw status === "running" as a live daemon, so a
  daemon that was still "starting" (booting on its own or started via the CLI)
  showed as "stopped", and startDaemon() would spawn a second one — which the new
  CLI rejects as "already running", surfacing as a start error.

Add daemonStatusAlive() (shared, pure, unit-tested) mirroring the Go daemonAlive()
and use it for liveness: fetchHealth() surfaces a daemon-reported "starting" as
state "starting" regardless of our own currentState; startDaemon()'s
already-running guard and the restart-on-user-switch guard treat "starting" as an
existing daemon. version-decision stays gated on "running" (readiness, not
liveness) — unchanged.

Verified: desktop typecheck, eslint, full vitest suite (193 tests) all pass.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 17:01:23 +08:00
Bohan Jiang
3708fb0f07 fix(daemon): inactivity-based agent run timeout, no wall-clock guillotine (MUL-3064)
Active long-running sessions are no longer killed by a fixed wall-clock deadline. Liveness is delegated to the idle watchdog (MULTICA_AGENT_IDLE_WATCHDOG, default 30m) with a larger in-flight-tool budget (MULTICA_AGENT_TOOL_WATCHDOG, default 2h). MULTICA_AGENT_TIMEOUT is an opt-in absolute cap (default 0 = no cap). The server-side 2.5h sweeper is unchanged as a coarse backstop.

Fixes #3745.
2026-06-05 15:06:07 +08:00
Naiyuan Qing
b9334dd59f fix: anchor comment triggers to thread roots (#3746)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 13:47:05 +08:00
Multica Eve
10afd1af1b feat(server): introduce pkg/taskfailure classifier and switch in-flight failure_reason writes (MUL-2946) (#3693)
Lift MUL-1949's offline backfill failure_reason taxonomy into a shared
in-flight classifier so the agent_task_queue.failure_reason column is
written with refined values (provider_auth_or_access, context_overflow,
provider_capacity_or_rate_limit, …) at write time rather than waiting on
SQL backfill to re-classify after the fact. PR1 of the Grafana board
plan in MUL-2328 — the upcoming PR2 reuses pkg/taskfailure.AllReasons()
to pre-warm the Prometheus failure_reason label set.

* server/pkg/taskfailure: new package with the canonical 21 Reason
  constants (7 platform-side + 14 agent_error.* sub-reasons),
  AllReasons() returning a defensive copy, IsAgentError() prefix check,
  and Classify(rawError) Reason mirroring the SQL CASE rules from
  MUL-1949 (db-boy's analysis). 100% statement coverage.
* server/internal/daemon/daemon.go: route the 'agent_error' coarse
  fallback paths (StartTask error, runTask early-return error, CompleteTask
  permanent rejection, reportTaskResult default branch) and the
  executeAndDrain default error case (chained after classifyPoisonedError)
  through taskfailure.Classify so blocked / timeout / unknown-status
  results all carry a refined reason on the wire.
* server/internal/service/task.go: FailTask classifies errMsg when the
  daemon-supplied failureReason is empty, eliminating the legacy
  COALESCE(.., 'agent_error') landing.
* server/internal/daemon/poisoned.go: alias FailureReasonIterationLimit
  and FailureReasonAPIInvalidRequest to the canonical taskfailure
  constants. agent_fallback_message and codex_semantic_inactivity are
  pre-existing operational reasons not in the canonical 21 — kept as
  literals for now and revisited in a follow-up PR.

Backfill SQL from MUL-1949 stays as the authoritative offline source of
truth; this PR keeps the in-flight classifier in lock-step with the SQL
CASE expression so historical and future rows share the same taxonomy.
No behavior change for the platform-side reasons (queued_expired,
runtime_offline, runtime_recovery, timeout, etc.) which already align
with the canonical set.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 13:52:56 +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
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
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
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
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
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
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
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
960befa56f feat(agent): per-agent toggle to isolate host-machine skills (MUL-2603) (#3200)
* feat(agent): per-agent toggle to isolate host-machine skills (MUL-2603)

Adds an agent-scoped `skills_local` switch ("ignore" default / "merge") so
shared agents stop inheriting the operator's user-global Claude skill
directory. A single broken local skill on one operator's machine was
crashing the Claude CLI before it ever read stdin — the daemon saw a
"broken pipe" with no recoverable signal (GitHub #3052).

- DB: migration 108 adds `agent.skills_local` (NOT NULL DEFAULT 'ignore'),
  with sqlc CreateAgent/UpdateAgent updates and handler validation.
- Claude runtime: when the agent is in "ignore" mode the backend points
  CLAUDE_CONFIG_DIR at an empty per-task scratch dir under the task cwd
  (fallback: OS temp), strips any inherited override, and cleans up after
  the run. Workspace skills under `{cwd}/.claude/skills/` still load.
  "merge" preserves the legacy inherit-from-machine behavior; Codex and
  other isolated backends are no-ops.
- UI: new Skills toggle in the Create Agent dialog and the Agent → Skills
  tab, with EN/zh-Hans copy and SkillsLocalToggle shared between the two.
- Tests: unit coverage for the new env helper, isolation dir lifecycle,
  full Claude execute paths (ignore + merge), and the handler tristate
  contract. Existing skills-tab test updated for the new copy.
- Docs: updated `/skills` docs (EN + ZH) and added a 0.3.7 changelog entry
  in the landing-page i18n.

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

* fix(agent): preserve claude login + validate skills_local input (MUL-2603)

Address Elon's review on PR #3200:

1. Skill isolation no longer drops the operator's Claude login. The
   per-task scratch dir now mirrors every entry under `~/.claude/`
   as symlinks except `skills/`, so `.credentials.json`, settings,
   plugins, etc. reach the CLI exactly as on the host while the
   user-global skills directory stays hidden. Without this, default
   `ignore` would have broken every Claude agent on a non-API-key
   host the moment migration 108 landed.

2. Internal CreateAgent callers (agent_template, onboarding_shim)
   now set `SkillsLocal: "ignore"`. The Go zero value was about to
   trip the migration-108 CHECK constraint and 500 template /
   onboarding agent creation.

3. Create / update handler validation no longer normalizes garbage
   to "ignore". The strict 400 path is now reachable on bad client
   input; the drift-safe `normalizeSkillsLocal` stays on the read
   side only.

UI copy + docs clarified that the toggle is Claude-only; other
runtimes ignore the setting.

Verification:
- `go test ./...` green (full suite locally).
- `pnpm --filter @multica/views exec vitest run agents/components/tabs/skills-tab.test.tsx` green.
- Handler DB-backed tests still skip locally without docker (same
  as Elon's run) — CI will validate the create / update paths
  against migration 108.

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

* fix(agent): mirror effective claude config dir with windows fallback (MUL-2603)

Address Elon's second-round review on PR #3200:

1. The per-task scratch dir now mirrors the *effective* host Claude
   config dir, not unconditionally `~/.claude/`. Precedence: agent
   `custom_env` CLAUDE_CONFIG_DIR > parent process env > `~/.claude/`.
   Without this, an operator who pinned Claude at a managed install
   (custom env CLAUDE_CONFIG_DIR) would get the wrong credentials in
   the scratch dir, because `buildClaudeEnv` strips that env before
   handing it to the child. We resolve the source up front and feed
   it to the mirror, so the override env still points at the right
   bytes.

2. Mirror entries now go through platform-aware linkers. On Windows
   without Developer Mode / admin, `os.Symlink` is denied, which
   previously left the scratch dir empty and broke Claude Code auth
   on default `ignore`. The new helpers try symlink first, then fall
   back to a directory junction (`mklink /J`) for dirs or a hardlink
   (same-volume content share) / copy for files. Mirrors the
   execenv/codex_home_link_windows.go pattern.

3. Tests:
   - `TestResolveHostClaudeConfigDir` locks in the custom_env >
     parent_env > `~/.claude` precedence.
   - `TestNewIsolatedClaudeConfigDirMirrorsCustomHostDir` confirms
     the scratch dir picks up `.credentials.json` from a synthetic
     custom host dir, proving the source resolution actually
     propagates into the mirror.
   - `TestNewIsolatedClaudeConfigDirEmptyHostIsNoop` documents the
     env-var-auth-only case (no host source ⇒ empty scratch dir).
   - `TestMirrorHostClaudeExceptSkillsWith_FallbackWhenSymlinkFails`
     exercises the Windows-no-Developer-Mode path via the new
     `mirrorHostClaudeExceptSkillsWith` seam, asserting credentials
     and sub-dir children still reach the scratch dir after the
     symlink stand-in fails.
   - `TestMirrorHostClaudeExceptSkillsWith_PropagatesFirstLinkError`
     confirms callers see the per-entry error when even fallback
     fails (so the warn-log fires on broken Windows installs).
   - `TestCopyFileRoundTrip` covers the last-resort copy fallback
     and its EXCL no-overwrite contract.
   - `TestClaudeExecuteIsolatesUsesCustomEnvSource` is the
     end-to-end check: an agent with custom_env CLAUDE_CONFIG_DIR
     reads its credentials from the pinned dir, not `~/.claude/`.

4. Docs: `apps/docs/content/docs/skills.{mdx,zh.mdx}` updated to
   describe the effective-source resolution and the Windows
   fallback chain so the docs match the runtime behaviour.

Verification:
- `go test ./...` green (full server suite locally, including
  `pkg/agent` 23 cases covering the new + existing isolation
  paths).
- `GOOS=windows GOARCH=amd64 go vet ./pkg/agent/...` and
  `go test -c -o /dev/null` both compile clean, confirming the
  Windows-tagged linker file builds.

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

* fix(agent): default skills_local to merge to preserve legacy behavior (MUL-2603)

Per Bohan's product decision on PR #3200, the per-agent host-skill toggle
defaults to "merge" — the pre-MUL-2603 inherit-from-machine behavior —
so existing personal workflows that rely on locally installed Claude
Skills keep working unchanged. Agent owners explicitly opt into "ignore"
when they need to harden a shared agent against a broken local skill on
one operator's machine (GitHub #3052).

Also audited all 11 runtimes for user-global skill discovery paths and
documented the scope of the toggle. Only Claude reads a user-global
`~/.claude/skills/`; Codex isolates via `CODEX_HOME`, the ACP backends
(Hermes / Kimi / Kiro) and the JSON-stream backends (Copilot / Cursor /
Gemini / Pi / OpenCode / OpenClaw) anchor discovery to the task workdir
and never read a user-global skill directory. UI copy and docs now say
"for runtimes that support it (currently Claude Code)" everywhere so
the scope is explicit.

Changes:

- Migration 108: column default flipped to 'merge'.
- Handler CreateAgent: missing field → "merge"; explicit "ignore" /
  "merge" still validated, garbage still 400.
- normalizeSkillsLocal: drift-safe coercion now lands on "merge" for
  anything that isn't the exact literal "ignore".
- agent_template.go / onboarding_shim.go: internal CreateAgent callers
  send "merge" instead of "ignore" to match the new default.
- Claude runtime (`claude.go`): isolate-mode gate flipped from
  `SkillsLocal != "merge"` to `SkillsLocal == "ignore"`, so "" (legacy
  daemons / older clients) and "merge" both walk `~/.claude/` directly.
- Create Agent dialog + Skills tab: toggle defaults to on (merge); only
  duplicate of an explicit "ignore" agent carries through. The
  isolation opt-in is now `skills_local: "ignore"` when the user flips
  off; "merge" is omitted from the request body.
- i18n (EN + zh-Hans): copy reframed — "On (default) — merged"; "Off —
  ignored. Recommended for shared agents".
- Docs (`/skills`, `/guides/agents.zh`): describe new default and
  enumerate which runtimes act on the toggle.
- Landing changelog 0.3.7: retitled "Per-Agent Local-Skill Toggle"; note
  the on-by-default behavior + off-to-isolate framing.
- Tests:
  - `TestClaudeExecuteIsolatesHostSkillsWhenIgnoreOptedIn` replaces the
    old by-default isolation case (now requires explicit "ignore").
  - New `TestClaudeExecuteDefaultModeKeepsHostConfigDir` locks in that
    default ExecOptions preserve the host CLAUDE_CONFIG_DIR.
  - `TestClaudeExecuteIsolatesUsesCustomEnvSource` now explicitly opts
    into "ignore" mode.
  - Handler tests: omitted → "merge"; explicit "ignore" round-trips;
    preserve-existing test seeds "ignore" and asserts "merge" flip-back.
  - `TestNormalizeSkillsLocal_DriftStaysSafe`: only literal "ignore"
    maps to ignore; everything else → "merge".
  - `skills-tab.test.tsx`: toggle ON by default; flip OFF when agent
    opted into "ignore". Intro-text matcher anchored to a more specific
    phrase so it no longer collides with the toggle hint copy.

Verification:
- `go test ./...` green (full server suite locally).
- `GOOS=windows GOARCH=amd64 go vet ./pkg/agent/...` and
  `go test -c -o /dev/null` both compile clean (windows-tagged linker
  file still builds).
- `pnpm typecheck` green across all packages and apps.
- `pnpm --filter @multica/views test` 88 files / 771 tests green.
- `pnpm --filter @multica/core test` 43 files / 390 tests green.
- Handler DB-backed tests still skip locally without docker; CI will
  validate the create / update paths against migration 108.

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

* chore(landing): drop 0.3.7 changelog entry from this PR (MUL-2603)

The landing-page release notes belong in a separate release-prep PR, not in the feature PR.

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

* fix(agent): propagate skills_local=ignore to codex user-skill seed (MUL-2603)

Make the per-agent skills_local toggle real for Codex too, not just Claude.
Previously the toggle was only consumed by the Claude backend, while the
daemon's execenv layer always seeded Codex's per-task CODEX_HOME with the
host machine's user-installed skills from ~/.codex/skills/. A shared Codex
agent with skills_local=ignore could still inherit a broken local skill
from one operator's machine.

Now: PrepareParams/ReuseParams carry SkillsLocal; hydrateCodexSkills
skips seedUserCodexSkills when SkillsLocal == "ignore" so the per-task
CODEX_HOME exposes only workspace skills to the codex CLI. Default
("merge", or empty from older servers/clients) preserves existing
inherit-from-machine behavior. UI / docs are updated to reflect the
contract honestly: Claude Code and Codex honor the toggle; other
runtimes (Hermes / Kimi / Kiro / Copilot / Cursor / Gemini / Pi /
OpenCode / OpenClaw) leave $HOME untouched and discover user-level
skills natively, so the toggle is a no-op for them today.

New tests: TestPrepareCodexSkillsLocalIgnoreSkipsUserSeed,
TestPrepareCodexSkillsLocalMergeSeedsUserSkills, and
TestReuseCodexSkillsLocalIgnoreSkipsUserSeed cover Prepare(ignore),
Prepare(merge), and the toggle-flip-on-reuse path.

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

* docs(skills): scope skills_local toggle copy to Claude Code + Codex (MUL-2603)

Off-state hint and Skills tab intro now explicitly call out Claude Code +
Codex as the only runtimes that honor the toggle, with "other runtimes
ignore this setting" wired into both states (en + zh-Hans), so users on
non-Claude/Codex agents don't read "Off" as runtime-wide isolation.

Docs (skills.mdx, skills.zh.mdx, guides/agents.zh.mdx) stop describing
Hermes / Kimi / Gemini / Copilot / Cursor / Pi / OpenCode / OpenClaw / Kiro
as having native user-level skill discovery; the daemon simply does not
manage user-level skill discovery for those runtimes today, and the toggle
is a no-op regardless of where it is set.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 13:26:33 +08:00
Bohan Jiang
13f74e651a feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600) (#3209)
* feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600)

The agent resource shape (list / get / create / update / archive /
restore responses + WebSocket events) no longer carries `custom_env`
values. Reads/writes of env now flow exclusively through a dedicated
`/api/agents/{id}/env` endpoint that is owner/admin-only, rejects
agent-actor sessions, applies a "****" sentinel preserve guard on
PUT, and writes a persistent audit row per reveal/update.

Why
- `multica agent list --output json` historically returned plaintext
  `custom_env` for owner/admin callers (the redaction gate gave only
  members the masked map). Any agent token running on the workspace
  inherits its owner's role and could read every other agent's
  secrets just by listing.
- Patching list/get redaction alone (PR #3175 direction) left
  symmetric leaks via mutation responses, WS events, the "reveal"
  path itself (no actor-aware auth), and a `****` overwrite footgun
  on UpdateAgent.

What changed
- Backend: drop `custom_env` from AgentResponse; add coarse
  `has_custom_env` + `custom_env_key_count`. Strip env handling from
  UpdateAgent (silently ignored if sent). Keep CreateAgent's
  custom_env acceptance.
- Backend: new GET/PUT `/api/agents/{id}/env` handlers in
  `internal/handler/agent_env.go`:
  - resolveActor → 403 for agent actors (closes the lateral-movement
    path).
  - Owner/admin role gate via existing helper.
  - PUT honours value == "****" as "preserve existing value".
  - Both write to `activity_log` with `agent_env_revealed` /
    `agent_env_updated` actions. Audit details record key names only,
    never values.
- Daemon claim path (`ClaimAgentTask`) unchanged — `TaskAgentData`
  still carries plaintext env for runtime injection.
- SQL: new `UpdateAgentCustomEnv` query; sqlc regenerated (v1.31.1).
- CLI: new `multica agent env get|set` subcommands. `--custom-env*`
  flags removed from `multica agent update`; the no-fields error
  now points to the new path.
- Frontend: drop env fields from `Agent` + `UpdateAgentRequest`; add
  `getAgentEnv` / `updateAgentEnv` client methods; rewrite env-tab
  to show "N variables configured" + explicit "Reveal & edit"
  button, fetching values only on intentional reveal.
- Locales: parity-safe additions to en + zh-Hans.
- Docs: agents-create.{mdx,zh.mdx} reflect the new threat model and
  endpoint.
- Mobile: schema drops `custom_env` / `custom_env_redacted`, adds
  metadata fields.

Tests
- Handler tests pinned the new invariants: no env in list/get
  responses, owner reveal happy-path + audit row, agent-actor 403,
  `****` sentinel preserves real values, UpdateAgent silently
  ignores `custom_env`, pure `mergeAgentEnv` cases.
- CLI tests pivot to the new flag surface: `agent update` MUST NOT
  expose the env flags; `agent env set` MUST expose
  --custom-env-stdin/--custom-env-file.
- Frontend test fixtures updated; pnpm typecheck / test / lint
  pass cleanly.

This is a breaking API change. Scripts that read `custom_env` from
`/api/agents` must migrate to `GET /api/agents/{id}/env`.

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

* fix(agents): close actor-spoofing + audit fail-closed in env endpoints (MUL-2600)

Addresses Elon's review of #3209:

* Mint a task-scoped `mat_` token per claim, bound to (agent, task,
  workspace, owner). Daemon injects it into the agent process in place
  of its own credential. Auth middleware authoritatively rebuilds
  X-User-ID / X-Agent-ID / X-Task-ID from the token row and sets
  X-Actor-Source=task_token; that header is server-set only — incoming
  values are stripped before any auth branch runs. resolveActor honors
  the header so an agent that strips X-Agent-ID / X-Task-ID still
  resolves as actor=agent.
* GetAgentEnv / UpdateAgentEnv are now fail-closed on audit-log
  failures: GET refuses to return plaintext, PUT persists inside the
  same tx as the audit row so they commit/roll back together.
* PUT /api/agents/{id} returns 400 when the body carries custom_env
  instead of silently dropping it — directs callers to the audited env
  endpoint.
* Agent actors never see mcp_config, even when the underlying member
  is owner/admin; mutation broadcasts go through a redaction shim so
  WS subscribers don't pick it up either.
* Fix backend test that asserted dense JSON (jsonb::text renders
  whitespace) and frontend test that assumed a unique "Test User"
  match.

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

* fix(agents): close residual MUL-2600 gaps from review (MUL-2600)

Migration 108 FK now correctly references agent_task_queue(id) instead
of the non-existent agent_task table; the previous name blocked CI
backend migrations.

Task-token-authenticated requests can no longer be re-routed at a
different workspace by passing workspace_slug / workspace_id /
?workspace_id / a URL workspace param. ResolveWorkspaceIDFromRequest
and resolveWorkspaceUUID both short-circuit on X-Actor-Source=task_token
and return only the token-bound X-Workspace-ID; buildMiddleware adds a
defence-in-depth 403 if any URL-resolved workspace disagrees with the
token binding.

mcp_config no longer leaks back to agent actors through UpdateAgent /
CreateAgent / ArchiveAgent / RestoreAgent HTTP responses — the same
redactAgentResponseForActor helper that GetAgent/ListAgents use is now
applied to mutation responses too. WS broadcasts were already redacted
via broadcastAgentResponse.

FailTask and every TaskService cancel path (CancelTask /
CancelTasksForIssue / CancelTasksForAgent / CancelTasksByTriggerComment
/ BroadcastCancelledTasks) now eagerly DeleteTaskTokensByTask so the
mat_ token's 24h window doesn't outlive a terminated task. Failure is
non-fatal — the FK cascade and expiry remain durable guards.

Doc-only: clarify that PUT /api/agents/{id} now hard-rejects bodies
that carry custom_env (was previously "silently ignores").

Tests:
- middleware: TestResolveWorkspaceIDFromRequest gains a task_token
  case asserting client-supplied slug/id/query cannot override the
  bound workspace.
- handler: TestUpdateAgent_RedactsMcpConfigForAgentActor and
  TestUpdateAgent_KeepsMcpConfigForMemberActor pin the mutation-
  response redaction contract per actor type.

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

* fix(agents): match redacted mcp_config as JSON null, not Go nil (MUL-2600)

`AgentResponse.McpConfig` is `json.RawMessage` without `omitempty`, so
the redacted response serialises as `"mcp_config": null`. On decode,
`json.RawMessage` keeps the literal bytes `null` rather than collapsing
to Go nil, which made the assertion fire on a non-leak.

The product contract (field always present, distinguished from "no
config" via `mcp_config_redacted`) is intentional, so adjust the test
to check for "no secret-bearing content" instead of weakening the
contract via `omitempty`.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 18:42:48 +08:00
LinYushen
8e9df90d32 feat: include repo description in agent brief (#3203)
Add Description field to RepoData structs so that workspace repo
descriptions (set via the settings UI) are preserved through
normalization and rendered in the agent brief as:
  - <url> — <description>

When no description is set, the existing format is unchanged.

Closes MUL-2610

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 15:16:22 +08:00
Bohan Jiang
a55c03a0b3 fix(agent): inject Workspace Context into agent brief (MUL-2542) (#3078)
* fix(agent): inject Workspace Context into agent brief (MUL-2542)

The per-workspace `workspace.context` field (Settings → General) was
stored in the DB but never reached the agent prompt. Plumb it from the
workspace row through the claim response, the daemon's Task struct and
TaskContextForEnv, and render it as `## Workspace Context` in the meta
brief above `## Available Commands`. Heading is skipped when the field
is empty so workspaces that haven't set a context don't see a bare
header. Applies to every task kind — issue, comment, chat, autopilot,
quick-create — so the shared system prompt is consistent regardless of
trigger source.

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

* chore(server): gofmt files touched by workspace-context injection

Run gofmt on the files that buildWorkspaceContext injection touched.
Cleans up composite-literal alignment in execenv task context and
struct-tag alignment in Task / AgentTaskResponse / RegisterRequest.
No behavior change.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <agent-j@multica.ai>
2026-05-22 17:23:27 +08:00
YYClaw
614dfae884 MUL-2488 feat(timezone): Scheduling / Viewing two-layer timezone architecture (#2968)
* docs(timezone): add scheduling/viewing timezone architecture RFC

* feat(db): replace daily rollups with task_usage_hourly, add user.timezone

Migrations 100-104: add "user".timezone (Viewing tz), build the UTC
hourly task_usage_hourly rollup with its pipeline, drop the legacy
task_usage_daily / task_usage_dashboard_daily pipelines, and drop the
agent_runtime.timezone column. Report queries now slice day boundaries
at read time by the caller-supplied @tz instead of materialising in a
fixed tz. Regenerate sqlc.

* feat(server): add task_usage_hourly backfill command

Replace the two legacy backfill commands (daily / dashboard_daily) with
a single backfill_task_usage_hourly that loads historical task_usage
into the new UTC hourly rollup, sliced per workspace.

* refactor(server): resolve viewing timezone in report handlers

Report handlers resolve the Viewing tz per request (?tz query param,
then user.timezone, then UTC) and pass it to the hourly-rollup queries.
Drop the UseDailyRollup feature flags and the old raw-scan/daily-rollup
dual paths, remove the /api/usage endpoints, and stop the daemon from
reporting and the runtime handler from accepting host timezone.

* refactor(core): switch report queries to viewing timezone

API client and dashboard/runtime queries send ?tz with each report
request, the user schema/types carry the new timezone field, and the
runtime timezone field/mutation is removed.

* feat(views): add viewing timezone preference and UI

Add the useViewingTimezone hook and a Timezone setting in Preferences;
report charts and the dashboard week boundary follow the viewer tz.
Remove the runtime detail timezone editor and its locale strings.

* fix(test): update fixtures and stabilize tests for timezone refactor

The timezone architecture refactor changed several types without
updating dependent test code:

- RuntimeDevice no longer has a timezone field — drop it from the
  create-agent-dialog runtime fixture.
- User now requires a timezone field — add it to the apps/web mockUser
  fixture.
- The PreferencesTab timezone tests asserted on the async save handler
  (PATCH then store update) with a bare expect, racing the mutation's
  settle callback, and timed out querying the Select's ~600-option IANA
  list on a loaded CI runner. Wrap the assertions in waitFor and extend
  the timeout for those three tests.

* docs(timezone): document self-host migration order and trigger invariant

Add a SELF-HOST UPGRADE ORDER runbook to the backfill command's package
comment: applying migrations 100-104 in a single migrate-up drops the
legacy daily rollups before the hourly backfill runs, leaving dashboards
empty until cron catches up.

Add an INVARIANT comment on trg_atq_dirty_hourly noting that agent_id
must be added to the trigger's OF list if it ever becomes mutable,
otherwise dirty buckets for the old agent_id are silently missed.

* style(runtimes): drop trailing blank line in runtime-detail
2026-05-21 15:33:47 +08:00
iYuan
2f1f90c11a fix(agent): retry codex semantic inactivity fresh (#2593) 2026-05-20 20:03:39 +08:00
Bohan Jiang
2bec2221d2 feat(agent): per-agent thinking_level for claude + codex (MUL-2339) (#2865)
* feat(agent): persist thinking_level per agent (MUL-2339)

Adds a nullable `thinking_level` column to the `agent` table so the
backend can route a runtime-native reasoning/effort token (e.g. Claude's
`xhigh`, Codex's `minimal`) through to the agent CLI on every dispatch.

The column is intentionally TEXT rather than an enum — Claude and Codex
publish overlapping but distinct vocabularies and we want the persisted
value to round-trip exactly through whichever CLI receives it. NULL is
the "use runtime default" sentinel that every downstream consumer reads
as "do not inject --effort / reasoning_effort".

This commit is just the storage layer (migration + sqlc); subsequent
commits wire it through the API, daemon, and agent backends.

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

* feat(agent-backend): inject reasoning effort for claude + codex (MUL-2339)

Extends ExecOptions with a runtime-native ThinkingLevel string and wires
it into the Claude and Codex backends. Discovery is driven by the local
CLI so the daemon advertises whatever the host install supports rather
than a hand-maintained list that goes stale.

Per Elon's PR1 review:
- Claude: parses `claude --help` to learn the `--effort` superset and
  projects through a per-model allow-list (xhigh is Opus-only; max is
  session-only on the smaller models). Falls back to a conservative
  static list when the binary is missing or help drift hides the line.
- Codex: drives `codex debug models --output json` so per-model
  reasoning subsets and the documented default come directly from the
  CLI. The older config-error probe trick is gone — the JSON path is
  stable and doesn't pollute stderr with an intentional misconfig.
- Cache key includes (provider, executablePath, cliVersion) so a CLI
  upgrade invalidates entries that referenced the older help / catalog.

Per Trump's PR1 constraint, all three Codex injection points
(thread/start.config, thread/resume.config, turn/start.effort) flow
through one helper (`applyCodexReasoningEffort`) so they cannot drift
independently. The shared `codexReasoningCases` fixture in
`thinking_test.go` asserts the same value→{shape, key} contract at
each site for every level the runtimes know about.

Claude's `--effort` is also added to `claudeBlockedArgs` so a user
custom_args entry can't silently outvote the daemon-injected value.

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

* feat(api): wire thinking_level through API + daemon contract (MUL-2339)

End-to-end plumbing for the per-agent reasoning/effort setting:

- AgentResponse / TaskAgentData now carry `thinking_level`; the daemon's
  claim response includes it and the daemon's executor passes it through
  to agent.ExecOptions, where the Claude and Codex backends already know
  what to do with it.
- ModelEntry on the runtime-models wire format gains a `thinking` block
  carrying `supported_levels` + `default_level` per model so the UI can
  render a runtime-aware picker without the server having to know about
  the local CLI install. `handleModelList` projects the agent-package
  catalog (including the new Thinking field) into the wire shape.
- CreateAgent / UpdateAgent gate the field with a synchronous provider
  enum check (claude / codex only today). UpdateAgent is tri-state:
  field omitted = no change, "" = explicit clear (new
  `ClearAgentThinkingLevel` query, mirrors the existing mcp_config null
  pattern), non-empty = validate then set.

Per Trump's PR1 review, the API NEVER auto-clears on a runtime/model
swap and ALWAYS returns 400 on an unknown literal value — same shape
across CreateAgent, UpdateAgent, and combined patches that move
runtime + level in one request. Per-model combination failures (e.g.
`xhigh` against a model that only supports up to `high`) surface as a
daemon-side task error, not a silent server-side rewrite.

TS types follow the same shape: `Agent.thinking_level`,
`CreateAgentRequest`/`UpdateAgentRequest` add the field, `RuntimeModel`
grows a `thinking` block. Older backends omit the field, which the
front-end treats as "no picker for this model" — installed desktop
builds keep working.

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

* fix(agent): correct codex debug models argv + pin via runner test (MUL-2339)

`codex debug models --output json` is rejected by codex-cli 0.131.0 —
the subcommand emits JSON on stdout by default and has no `--output`
flag. Drop the flag and add `--bundled` to skip the network refresh
discovery doesn't need. Move the argv to a package-level var and add
a test that runs a fake `codex` to assert the binary actually
receives exactly `debug models --bundled`, so the contract can't
silently drift on the next refactor.

Also teach ValidateThinkingLevel to resolve an empty model to the
provider's default model entry. Without this, every default-model
task with a persisted thinking_level would be misjudged "unknown
model" by the daemon guard.

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

* fix(api): reject runtime switch that would leave invalid thinking_level (MUL-2339)

A PATCH that changed `runtime_id` without touching `thinking_level`
used to silently keep the existing value, so a Claude agent storing
`max` could land on a Codex runtime where `max` is not a recognised
token at all, and the daemon would receive a literal-invalid level.

Hold the same "always 400 on literal-invalid, never silent coerce"
rule on this implicit path. When runtime_id changes and the existing
value is not in the new provider's enum, return 400 with the
recovery options (clear via `thinking_level=""` or re-set in the
same PATCH).

Add coverage for both the kept-when-still-valid and the rejected
cases, plus the two recovery paths (clear and replace).

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

* fix(daemon): guard runTask with per-model thinking_level validator (MUL-2339)

ValidateThinkingLevel existed but had no call site — `task.Agent.
ThinkingLevel` flowed straight into ExecOptions, so `xhigh` configured
on a non-Opus Claude model, or API-side stale values that escaped the
provider enum gate, would be injected anyway.

Run the validator before building ExecOptions. Invalid combinations
log a warning and drop the level instead of failing the task: the
agent still runs, just at the runtime's default reasoning effort.
Discovery errors fail open (keep the level, let the CLI surface any
objection) so a transient `claude --help` failure can't strand work.

Empty model is forwarded as-is; the validator resolves it to the
provider's default model internally per the cross-package contract.

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

* chore(agent): drop stale `--output json` comments + unused scanner (MUL-2339)

Codex CLI's `debug models` subcommand emits JSON without an `--output`
flag, and `parseCodexDebugModels` never read from the bufio.Scanner.
Sync the comments with the actual invocation and remove the dead init.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 12:30:10 +08:00
Jiayuan Zhang
2ad1cd8ff8 feat(profile): user profile description injected into agent brief (MUL-2406)
## Summary

Adds per-user `profile_description` so coding agents have cheap, durable context about who is asking. v1 per the brief Xeon locked in on [MUL-2406](mention://issue/63a7247c-4f6a-42cf-90d1-7c746e77158a):

- **DB** — `user.profile_description TEXT NOT NULL DEFAULT ''` (migration 096). 2000-rune cap enforced server-side. No nullable / privacy state to manage.
- **API** — `PATCH /api/me` accepts the field; `UserResponse` always emits it. Client wraps `updateMe` in a lenient `UserSchema` + `EMPTY_USER` fallback per CLAUDE.md API Response Compatibility.
- **UI** — Settings → Account gains an "About you" textarea with live `n/2000` counter, `maxLength` guard, and a localized too-long error (EN + zh-Hans).
- **CLI** — `multica user profile get` / `multica user profile update` with `--description / --description-stdin / --description-file / --clear`, mirroring the existing `issue comment add` input-mode menu.
- **Daemon injection** — claim handler resolves the runtime owner and stamps `requesting_user_name` + `requesting_user_profile_description` on the task. `buildMetaSkillContent` emits `## Requesting User` between `## Agent Identity` and `## Available Commands`, blockquoted and framed as background context. The block is omitted entirely when the description is empty (no token cost when unused).

Brief is written **once per task** via `CLAUDE.md` / `AGENTS.md`, not the per-turn prompt — same path the agent already reads for identity, so no extra per-turn cost.

## Test plan

- [x] `go build ./...`, `go vet ./...`, `go test ./internal/cli/ ./internal/daemon/ ./internal/daemon/execenv/ ./cmd/multica/`
- [x] New brief tests: `TestBuildMetaSkillContentEmitsRequestingUser`, `TestBuildMetaSkillContentOmitsRequestingUserWhenEmpty`
- [x] `pnpm typecheck`, `pnpm lint`, `pnpm test` (74 files, 644 tests pass)
- [ ] Handler DB tests (`TestUpdateMe*`) require a migrated test DB — not runnable in this sandbox
- [ ] Manual: open Settings → Account, set a description, confirm the next daemon-run agent's `CLAUDE.md` shows `## Requesting User`
2026-05-19 19:51:28 +02:00
Jiayuan Zhang
cd37b4e3d6 feat(settings): consolidate GitHub options under a dedicated Settings tab (MUL-2414) 2026-05-19 17:23:30 +02:00
Bohan Jiang
9a577f3e11 fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir (MUL-2416) (#2849)
* fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir

OpenCode resolves its project discovery root from `--dir` and `PWD`
before falling back to `process.cwd()`. The daemon set `cmd.Dir =
workDir` but never overrode the inherited `PWD`, so OpenCode walked
from the daemon's shell directory and silently bypassed the per-task
workdir — agents lost visibility into `.opencode/skills/` and
`AGENTS.md`, falling back to whatever global skills the host had
installed (MUL-2416).

- Pass `opencode run --dir <workDir>` and override `PWD=<workDir>` in
  the child env so AGENTS.md walk-up + `.opencode/skills` project
  config scan both anchor on the task workdir.
- Block `--dir` from custom args so user overrides cannot re-introduce
  the regression.
- Plumb skill `description` from DB through service / daemon /
  execenv. `writeSkillFiles` synthesizes a YAML frontmatter block
  (`name`, optional `description`) when the stored content lacks one,
  since runtimes like OpenCode silently drop SKILL.md files without a
  parseable `name`. Existing frontmatter is preserved unchanged so
  upstream-imported skills (GitHub / ClawHub / Skills.sh) keep their
  hand-shaped metadata.

Tests:
- New fake-CLI test confirms argv carries `--dir <workDir>` and the
  child sees `PWD=<workDir>`.
- New test confirms a user-supplied `--dir` in custom_args is dropped.
- New execenv tests cover synthesized frontmatter and preservation of
  pre-existing frontmatter.

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

* fix(runtimes): inject SKILL.md `name` when upstream frontmatter omits it

Skills imported with frontmatter that sets `description` but leaves `name`
implicit (relying on the directory slug, as common in GitHub/Skills.sh
imports) still hit OpenCode's "no parseable name → drop" path because the
DB Name fallback never made it into the SKILL.md body. ensureSkillFrontmatter
now scans the existing block and, when name is missing or empty, prepends
`name: <slug>` while preserving description, body, and any runtime-specific
keys verbatim.

Also tighten yamlEscapeInline to always double-quote so descriptions that
look like YAML keywords (`null`, `true`, `[foo]`, `{x: y}`, `2024-01-01`)
parse as strings rather than getting reinterpreted and rejected.

Adds regression test for the nameless-frontmatter case and updates the
existing OpenCode skill test for the always-quoted description format.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:21:02 +08:00
Bohan Jiang
fe1ccb19c9 Revert "MUL-2324 conditionally inject non-core rule blocks (#2771)" (#2802)
This reverts commit e8fb0efe3d.
2026-05-18 17:48:44 +08:00
AdamQQQ
fab0671332 feat(skills): support multi-select bulk import in Copy from runtime (#2686)
- Multi-select UI for batch importing skills from a local runtime
- Server batch-dispatches up to 10 import requests per heartbeat cycle
- WS heartbeat now reads supports_batch_import from daemon payload
  instead of hardcoding true, so old daemons correctly fall back to
  one-at-a-time dispatch
- Raised server pending timeout to 3min and client poll timeout to 4min
  to accommodate daemons that pop only one import per 15s heartbeat

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 16:56:27 +08:00
Multica Eve
e8fb0efe3d MUL-2324 conditionally inject non-core rule blocks (#2771)
* feat(runtime): conditionally inject non-core rule blocks

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

* fix(runtime): tighten mention rule triggers

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-18 12:52:54 +08:00
Jiayuan Zhang
431006e7d6 feat(daemon): add debug-level logs at key debug-path nodes (MUL-2304) (#2733)
Local daemon previously logged mostly at Info, leaving startup/exit,
config resolution, registration, heartbeat ticks, agent invocation, and
result classification undiagnosable without code-reading. Add Debug
logs at those checkpoints so LOG_LEVEL=debug (the default) produces
enough detail to follow a run end-to-end without changing normal Info
output.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 18:02:12 +02:00
Bohan Jiang
bfe9bf3eea feat(daemon): force-stop hung agent runs via idle watchdog (MUL-2281) (#2691)
* feat(daemon): force-stop hung agent runs via idle watchdog (MUL-2281)

A backend whose subprocess hangs on a stuck child process (e.g. claude
blocked on `docker ps` against a frozen dockerd) keeps the daemon's run
record at status="running" until the full DefaultAgentTimeout (2 h)
expires, because cmd.Wait() never returns and Session.Result is never
written. MUL-2225 spent 17+ minutes in this state in the wild.

Add a per-task idle watchdog around executeAndDrain:

- Wrap the caller's ctx so a single cancel propagates to the agent
  subprocess (via the ctx passed to backend.Execute) AND the drain loop.
- Stamp lastActivityAt every time the drain loop receives a message.
- Tick at window/2; when idle_for >= window AND session.Messages buffer
  is empty, set a fired flag and call cancel.
- Tag the resulting Result.Status as "idle_watchdog" so runTask routes
  it through a dedicated failure_reason instead of "agent_error".

Default window is 5 min, configurable via MULTICA_AGENT_IDLE_WATCHDOG;
set to 0 to disable. Tests cover the activity-then-silence case, the
zero-message case, the disabled case, and the happy path.

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

* fix(daemon): skip idle watchdog while a tool call is in flight

A legitimate long-running tool call (npm install, docker build, test
suite) can sit silent between tool_use and tool_result for many minutes.
Without this gate, the watchdog would yank the agent mid-build.

Track unmatched tool_use messages in an atomic counter; only let the
watchdog fire when the counter is zero. tool_result clamps non-negative
so a stray result with no matching use can't re-arm the watchdog one
call too early.

Adds two regression tests:
  - DoesNotFireDuringInFlightToolCall: tool_use -> silence past
    window -> tool_result -> completed (must NOT fire)
  - FiresAfterToolResultIfBackendStaysSilent: tool_use -> tool_result
    -> silence past window (MUST fire — backend really is stuck)

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 19:48:39 +08:00
Bohan Jiang
fcd13aece9 feat(daemon): auto-update CLI when idle (MUL-2100) (#2679)
* feat(daemon): auto-update CLI when idle (MUL-2100)

Add a periodic poller that checks GitHub for a newer multica release
every hour and self-updates when the daemon is idle, reusing the same
brew-or-download upgrade path the Runtimes-page "Update" button already
runs.

- Refactor handleUpdate to call a shared runUpdate(target) helper so
  both server-triggered and auto-triggered upgrades go through the same
  brew detection + atomic replace + restart.
- New autoUpdateLoop gates each tick on: opt-out flag, Desktop launch
  source, dev-build version, an in-flight update, and active tasks. The
  idle gate guarantees we never interrupt a running agent — busy ticks
  silently retry at the next interval.
- Config: MULTICA_DAEMON_AUTO_UPDATE=false to disable (also via
  --no-auto-update), MULTICA_DAEMON_AUTO_UPDATE_INTERVAL to retune the
  poll period.
- IsNewerVersion / IsReleaseVersion helpers in the cli package, with
  tests covering patch/minor/major bumps, dev-describe strings, and
  malformed input.
- Daemon-side tests cover every skip path (updating, active tasks,
  fetch failure, no-newer) plus the success path that fires
  triggerRestart while keeping the updating flag held to the end.

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

* fix(daemon): close idle race + verify checksum in auto-update (MUL-2100)

Two issues raised in PR #2679 review:

1. The first idle check in tryAutoUpdate only ran before the release-metadata
   fetch, so a poller that won the claim race during the fetch could end up
   handing handleTask a task that triggerRestart was about to cancel via root-
   ctx cancellation. Add a strict claim barrier: runRuntimePoller now
   tryEnterClaim()s before ClaimTask, and tryAutoUpdate flips pauseClaims
   under claimMu only after observing claimsInFlight + activeTasks == 0.
   Pollers that were already mid-claim hold claimsInFlight > 0, so the barrier
   refuses to engage and the update defers to the next tick.

2. The direct-download path replaced the running binary with whatever bytes
   GitHub returned, without checking checksums.txt. Pull the manifest first,
   buffer the archive, and reject on SHA-256 mismatch before extraction. The
   GoReleaser config already publishes checksums.txt; we just consume it.

Also tighten parseReleaseVersion so it stops accepting dev-describe shapes
like "v0.1.13-5-gabcdef0" through the patch trim, matching its docstring.
The auto-update loop already guards on IsReleaseVersion, but the lenient
parser was a footgun and the existing test name even said "not newer" while
asserting the opposite.

Tests:
- TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier (new race coverage)
- TestTryAutoUpdate_HoldsBarrierAcrossRestart / ReleasesBarrierOnUpgradeFailure
- TestTryEnterClaim_RespectsBarrier
- TestFindChecksumManifestAsset / TestParseChecksumManifest / TestVerifyAssetSHA256
- TestIsNewerVersion: dev-describe cases now expect false (matches docstring)

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

* chore(daemon): default auto-update poll interval to 6h (MUL-2100)

1h was overly chatty for a release that lands at most a few times a week.
Operators who want a different cadence can still set
MULTICA_DAEMON_AUTO_UPDATE_INTERVAL or --auto-update-interval.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 18:10:22 +08:00
LinYushen
b7a58c06ac Revert "feat(task): wire claim lease into TaskService and sweeper (MUL-2246) …" (#2673)
This reverts commit bb32be0e50.
2026-05-15 16:06:58 +08:00
LinYushen
bb32be0e50 feat(task): wire claim lease into TaskService and sweeper (MUL-2246) (#2662)
* feat(task): wire claim lease queries into TaskService and sweeper (MUL-2246)

- ClaimTask now uses ClaimAgentTaskWithLease (generates claim_token + lease)
- StartTask accepts optional claim_token for token-verified start
- AgentTaskResponse includes claim_token for daemon to use
- Daemon client sends claim_token in StartTask body
- Sweeper calls RequeueExpiredClaimLeases each tick
- Legacy daemons without claim_token still work (graceful fallback)

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

* fix(task): address PR #2662 review blockers (MUL-2246)

1. ClaimAgentTaskForRuntime: push runtime_id into atomic SQL WHERE clause
   so runtime A cannot claim tasks queued for runtime B under the same agent.

2. Legacy StartAgentTask: add claim_token IS NULL guard so leased rows
   cannot be started without token verification. Handler rejects malformed
   tokens with 400 instead of silently degrading to legacy path.

3. StartAgentTaskWithClaimToken: validate claim_expires_at >= now(),
   preserve claim_token until terminal state (only clear claim_expires_at),
   use CTE + UNION ALL for idempotent retry when daemon resends after a
   lost StartTask response. Return 409 Conflict on token mismatch/expiry.

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

* fix(daemon): StartTask 409 handling, transport retry, claim_token on FailTask (MUL-2246)

- StartTask 409 (claim superseded): release slot, don't call FailTask
- StartTask transport timeout/5xx: retry once with same token, then
  check task status before failing
- FailTask now sends claim_token; server-side FailAgentTask SQL adds
  AND (claim_token IS NULL OR claim_token = @claim_token) guard so
  stale daemons cannot fail tasks that have been re-claimed

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

* fix(task): close FailTask token bypass and RequeueExpiredClaimLeases liveness gap (MUL-2246)

Blocker 1 - FailTask token validation:
- SQL: change (param IS NULL OR claim_token = param) to
  (param IS NULL AND claim_token IS NULL) OR claim_token = param
  so tokenless requests can only fail legacy (tokenless) rows.
- task.go: malformed claim_token now returns ErrInvalidClaimToken (400)
  instead of being silently dropped to NULL.
- Handler: maps ErrInvalidClaimToken→400, ErrClaimTokenInvalid→409.
- Service: when UPDATE returns no rows but task is still active,
  return ErrClaimTokenInvalid (token mismatch) instead of silent success.

Blocker 2 - RequeueExpiredClaimLeases runtime liveness:
- SQL: JOIN agent_runtime, only requeue tasks where runtime is 'online'.
  Dead/offline runtime tasks stay dispatched for FailTasksForOfflineRuntimes.
- FOR UPDATE → FOR UPDATE OF atq (required with JOIN).

Regression tests:
- task_claim_token_test.go: malformed, tokenless-on-tokened, wrong-token
- requeue_lease_test.go: SQL must JOIN agent_runtime with online filter

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

* fix(task): move expired lease requeue to ClaimTaskForRuntime preflight, add heartbeat freshness backstop (MUL-2246)

- Add RequeueExpiredClaimLeasesForRuntime: per-runtime preflight self-requeue
  in ClaimTaskForRuntime. Runtime proves liveness by actively claiming, so no
  heartbeat check needed.
- Update global RequeueExpiredClaimLeases to require ar.last_seen_at freshness
  (stale_threshold_secs param). Prevents requeuing to a dead runtime in the
  90s gap between lease expiry (60s) and offline detection (150s).
- Add regression tests verifying the heartbeat freshness check and that the
  preflight query does not join agent_runtime.

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

* fix(task): use LivenessStore for global requeue, move preflight before empty-cache (MUL-2246)

Blocker 1: Global RequeueExpiredClaimLeases now uses LivenessStore.IsAliveBatch
to verify runtimes are truly alive before requeuing expired leases. When
LivenessStore is unavailable (no Redis), global requeue is skipped entirely —
the preflight self-requeue in ClaimTaskForRuntime handles live runtimes. This
closes the 60-150s gap where a dead runtime still appears online in DB.

Blocker 2: Moved RequeueExpiredClaimLeasesForRuntime BEFORE EmptyClaim.IsEmpty
fast-path in ClaimTaskForRuntime. Expired leases are now requeued (which bumps
the empty cache via notifyTaskAvailable) before the empty check can
short-circuit the claim path.

Also adds ListRuntimesWithExpiredClaimLeases SQL query and LivenessChecker
interface on TaskService.

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

* fix(task): wire EmptyClaimCache into backend taskSvc for backstop requeue (MUL-2246)

The backend taskSvc used by the sweeper only had Liveness wired but not
EmptyClaim. When global backstop requeue called notifyTaskAvailable,
s.EmptyClaim.Bump() was a nil no-op — the handler's empty-cache was never
invalidated, so the daemon's next claim hit a stale empty verdict.

Fix: wire the same Redis-backed EmptyClaimCache into the backend taskSvc
in main.go (same Redis keys as router.go:139 handler instance).

Add regression test verifying backstop requeue invalidates the handler's
empty-cache.

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

* fix(task): global backstop must not requeue — alive runtimes use preflight, dead stay dispatched (MUL-2246)

- RequeueExpiredClaimLeases is now a no-op (returns 0 always)
- Alive runtimes self-requeue via ClaimTaskForRuntime preflight
- Dead runtimes stay dispatched for FailTasksForOfflineRuntimes
- Rewriting to queued on dead runtime creates 2h blackhole (offline
  sweeper only handles dispatched/running)
- Test actually calls RequeueExpiredClaimLeases and asserts 0 in all cases

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

* fix(daemon): remove duplicate usage reporting block after merge conflict (MUL-2246)

The merge resolution introduced a second ReportTaskUsage call after the
status check, duplicating the usage-before-early-return block that already
runs right after runner.run. Remove the duplicate and add a regression test
asserting /usage is called exactly once on the normal completion path.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 15:15:31 +08:00
joyanup
4c1fd60215 fix(daemon): report task usage before cancel check (#1180)
handleTask had two early-return paths that ran before ReportTaskUsage:
the cancelledByPoll select and the post-run GetTaskStatus check. Both
silently discarded any usage accumulated by the agent — and both
claude.go and codex.go populate Result.Usage even when runCtx is
cancelled mid-run, so cancelled tasks consistently under-reported tokens.

Hoist ReportTaskUsage to run immediately after the runner returns,
before any early-return path. Add a taskRunner interface seam and a
cancelPollInterval field so tests can inject a fake runner and trigger
the poll-cancellation path on a 10ms ticker without spawning real agents.

Two regression tests cover both leak windows:
- TestHandleTask_ReportsUsageBeforeCancel: post-run /status returns
  "cancelled"; usage must be reported before the status check.
- TestHandleTask_ReportsUsageWhenCancelledByPoll: poll goroutine fires
  first and cancels runCtx; runner returns usage on Done; assert
  poll-status precedes usage (proving the cancelledByPoll branch was
  the one exercised, not the post-run path).

Sanity-checked: reverting only the ReportTaskUsage hoist fails both
tests with the original "tokens lost" message.

MUL-2258

Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 13:33:17 +08:00
Bohan Jiang
464201ba0d feat(execenv): native OpenClaw skill discovery via per-task config (MUL-2219) (#2628)
* feat(execenv): native OpenClaw skill discovery via per-task config

MUL-2213 stopped lying about native discovery and routed openclaw skills
to .agent_context/skills/ — a path openclaw's scanner never reads.
Multica skills attached to openclaw-backed agents were still invisible to
the runtime; the AGENTS.md fallback was only a documentation patch.

OpenClaw's skill scanner walks <workspaceDir>/skills/ (plus a few other
roots), and workspaceDir is resolved from the openclaw config file —
specifically agents.list[id].workspace → agents.defaults.workspace →
~/.openclaw/workspace. There is no CLI flag or env var override on the
agent runtime; the only knob is the config file.

This change wires a per-task synthesized config:

  1. execenv.prepareOpenclawConfig deep-copies the user's existing
     openclaw.json (priority: $OPENCLAW_CONFIG_PATH, else
     ~/.openclaw/openclaw.json), rewrites agents.defaults.workspace AND
     every agents.list[].workspace to the task workdir, and writes the
     result to {envRoot}/openclaw-config.json. Provider sections,
     registered agents, model providers, gateway settings — everything
     openclaw needs to actually start — are preserved as-is.
  2. resolveSkillsDir for "openclaw" now points at {workDir}/skills/,
     which is the first path openclaw scans under workspaceDir. Skills
     written here are picked up natively.
  3. daemon.go exports OPENCLAW_CONFIG_PATH={env.OpenclawConfigPath} on
     the openclaw subprocess and adds OPENCLAW_CONFIG_PATH to the
     custom_env blocklist so users cannot accidentally override it.
  4. buildMetaSkillContent now lists openclaw alongside the
     "discovered automatically" providers; the .agent_context/skills/
     fallback line stays for gemini/hermes.

The new regression test TestPrepareOpenclawSkillWriteMatchesScanPath is
the one MUL-2219's DoD calls out: it resolves the workspaceDir the way
openclaw does (reading agents.defaults.workspace out of the synthesized
config) and proves {workspaceDir}/skills/<name>/SKILL.md is what Multica
actually wrote. The pre-MUL-2219 fix asserted "we wrote a file" without
checking the scanner would ever see it — which is how the dead drop into
.openclaw/skills/ landed in #2621's first commit.

Verified locally: minimum-viable synthesized config validates via
`openclaw config validate`, and `OPENCLAW_CONFIG_PATH=<path> openclaw
config get agents.defaults.workspace` returns the task workdir as
expected. MUL-2219

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

* fix(execenv): delegate openclaw config parsing to CLI and fail closed

Address Elon's must-fix on PR #2628: the previous implementation parsed
~/.openclaw/openclaw.json with encoding/json, which cannot read JSON5
or follow $include — the OpenClaw spec's actual format. When parsing
failed, prepareOpenclawConfig silently emitted a minimal config, which
could boot OpenClaw without the user's registered agents, model
providers, or API keys.

Two changes:

1. Delegate active-config-path resolution and config reading to the
   openclaw CLI itself. `openclaw config file` locates the active
   config (covering OPENCLAW_CONFIG_PATH / OPENCLAW_STATE_DIR /
   OPENCLAW_HOME / default and the legacy chain), and the wrapper we
   write uses $include to point at it so OpenClaw's own loader handles
   JSON5, $include nesting, env-substitution, and secret refs. We read
   only agents.list via `openclaw config get --json` to rewrite each
   entry's workspace — secrets, comments, and includes in the user
   config are never touched.

2. Remove the silent minimal-config fallback. Any CLI failure,
   malformed output, or write error now surfaces as a hard error from
   Prepare / Reuse. The only "synthesize minimal" path left is a fresh
   install (CLI reports a path but the file doesn't exist), where
   there is no user data to lose.

The per-task override still rewrites every agents.list[].workspace,
not just agents.defaults.workspace — this is intentional task
isolation, documented in prepareOpenclawConfig and the PR body. A
host-scope per-agent workspace would otherwise silently route the
scanner back to the user's shared workspace.

Cleanups Elon flagged in the same review:
- daemon.go inline-system-prompt comment no longer claims openclaw
  ignores the task workdir; it does load it now, and the inline brief
  is a belt-and-suspenders carryover for older releases.
- execenv.go openclaw block no longer references "skill file paths in
  the inline brief" — the brief uses "discovered automatically".

Reuse() switches to a ReuseParams struct so the openclaw binary path
threads through alongside CodexVersion without a 6th positional arg.

MUL-2219

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

* fix(execenv): grant OpenClaw $include cross-dir confinement for per-task wrapper

The per-task wrapper at envRoot/openclaw-config.json $includes the user's
active config (typically ~/.openclaw/openclaw.json), but OpenClaw confines
$include resolution to the wrapper file's directory unless the target's
parent is granted via OPENCLAW_INCLUDE_ROOTS. Without this, OpenClaw refuses
to follow the link at runtime and the wrapper boots with no user-registered
agents.

prepareOpenclawConfig now returns dirname(activePath) as IncludeRoot, and
the daemon prepends it to whatever the user already has in
OPENCLAW_INCLUDE_ROOTS via the new composeOpenclawIncludeRoots helper
(dedupes, drops empty segments, preserves user-configured roots). Fresh
install emits no $include and leaves the env var untouched.

Adds OPENCLAW_INCLUDE_ROOTS to the custom_env blocklist so a per-agent
override cannot strip the granted root.

Regression tests:
- TestPrepareOpenclawConfigWrapperLoadableUnderIncludeConfinement asserts
  every $include target's dirname is covered by the IncludeRoot we surface.
- TestPrepareEnvironmentOpenclawWiresIncludeRoot covers the non-fresh-install
  Environment wiring.
- TestComposeOpenclawIncludeRoots covers the daemon-side env composition
  (preserve, dedupe, drop empties).

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 22:35:31 +08:00
Bohan Jiang
8572a79950 MUL-2215: fix(daemon): close handleRuntimeGone success/straggler race (#2623)
* MUL-2215: fix(daemon): close handleRuntimeGone success/straggler race

handleRuntimeGone coalesced concurrent recoveries with a per-workspace
`reregisterNextAttempt` slot that was deleted immediately on success. A
late-arriving goroutine whose `removeStaleRuntime` was delayed by mutex
contention could reach the coalesce gate after the winner cleared the
slot, observe no slot, re-claim, and double-register — the source of the
intermittent `register endpoint called 2 times under stampede, want 1`
failure on PR #2348.

The slot delete on success is intentional (a genuinely later distinct
deletion in the same workspace must register again, validated by
TestHandleRuntimeGone_DistinctDeletionsWithinCoalesceWindowBothRecover),
so we can't just extend the slot's lifetime.

Add a second per-workspace gate: `reregisterLastCompletedAt`. Every call
captures `entryAt` at the top of handleRuntimeGone; at the coalesce gate
a caller bails if `lastCompletedAt >= entryAt`, i.e. a peer's register
completed AFTER we entered the function. Same-wave stragglers bail
deterministically; distinct later events have `entryAt > lastCompletedAt`
and proceed.

Extracted the gate into `tryClaimRegisterSlot` / `recordRegisterCompletion`
so the race can be exercised deterministically with synthetic timestamps
instead of relying on `-count=N` to win the scheduling lottery.

- TestHandleRuntimeGone_CoalescesConcurrentCallers: -count=500 -race
  clean (previously intermittent).
- New unit tests cover the straggler bail, the distinct-later-event
  claim, failure backoff suppression, and peer-holds-slot coalescing.

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

* MUL-2215: narrow completion stamp to success path

Second review caught that recordRegisterCompletion stamped
lastCompletedAt on both success and failure. A failed register has not
covered any workspace state, so a same-wave straggler whose entryAt
predates the failure must be allowed to retry once the failure backoff
expires — the previous behavior would let the failure-time stamp also
hide that straggler. workspaceSyncLoop only retries when a workspace's
runtimeIDs fully drain, so partial-deletion recovery has to come from
the straggler path.

Failure path now only updates reregisterNextAttempt; success path keeps
its existing stamp + slot clear. Add a regression test covering the
entryAt-before-failed-completion / arrival-past-backoff edge.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 21:01:55 +08:00
fr00st
cc9fbd3db0 Fix stale Done replies on comment follow-ups (#2495)
* fix: avoid stale done replies on comment follow-ups

* fix: avoid inlining runtime brief for Hermes ACP

* fix: address comment follow-up review feedback
2026-05-14 12:00:04 +08:00
LinYushen
7a1284128d fix: allow squad leader to exit silently on no_action without posting a comment (#2564)
The runtime prompt's Output section unconditionally required all tasks to
post a comment via 'multica issue comment add', which conflicted with the
squad leader protocol that says to 'exit silently' on no_action.

Changes:
- Add IsSquadLeader bool to TaskContextForEnv (detected via Squad Operating
  Protocol marker in agent instructions)
- Relax the Output section and assignment-triggered workflow step 5 to
  allow squad leaders to exit with only a 'multica squad activity' call
  when the outcome is no_action

Fixes MUL-2168

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