159 Commits

Author SHA1 Message Date
LinYushen
c366cf2ba1 feat(agent): add Kiro CLI ACP runtime (#1780)
* feat(agent): add kiro cli acp runtime

* fix(agent): align kiro acp prompt and notifications

* chore(agent): clarify kiro acp args compatibility
2026-04-28 17:03:46 +08:00
devv-eve
9db91e89f5 feat: add daemon websocket task wakeups (#1772)
* feat: add daemon websocket task wakeups

* feat: fan out daemon wakeups across nodes

* fix: dedupe daemon wakeup loopback events

* fix: lengthen daemon polling fallback interval

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 16:07:24 +08:00
Bohan Jiang
541aaa974d fix(server): clarify silent-exit prompt and pin handoff contract (#1775)
Follow-ups to #1765 review nits:

- Tighten the per-turn prompt and AGENTS.md workflow instructions so
  that "exit with no output" only applies when the trigger is from
  another agent AND no actual work was produced this turn. If the
  agent did real work, the standard "post results as a comment" rule
  still applies — a result reply is not a noise comment.

- Add TestAgentExplicitMentionStillTriggers as a positive control
  documenting the boundary the structural fix preserves: suppressing
  implicit parent-mention inheritance for agent authors does NOT
  block deliberate handoffs. An agent that explicitly @mentions
  another agent in its own content still enqueues a task for the
  mentioned agent and does not self-trigger.
2026-04-28 15:21:39 +08:00
Bright Zheng
81231e06f8 fix(server): prevent agent-to-agent mention inheritance loops (BRI-34) (#1765)
When an agent replied in a thread whose root mentioned another agent,
the reply inherited the parent mention and re-triggered the other agent.
This caused 'No reply needed' ping-pong loops between co-assigned agents.

Structural fix:
- In enqueueMentionedAgentTasks, suppress parent-mention inheritance
  when authorType == 'agent'. Explicit @mentions in the agent's own
  comment still work for deliberate handoffs.

Defense-in-depth (prompt):
- Strengthen per-turn prompt and AGENTS.md workflow instructions to
  explicitly forbid posting 'No reply needed' noise comments.

Regression test:
- TestAgentReplyDoesNotInheritParentMentions covers both the fix
  (agent reply does not re-trigger) and the positive control
  (member reply still inherits mentions).

Also updates TestBuildPromptCommentTriggeredByAgent to match the
new prompt wording.
2026-04-28 15:14:14 +08:00
dyjxg4xygary
6bd5bbad9c fix: timeout stalled Codex turns (#1730)
* fix: timeout stalled codex turns

* fix: count codex progress events as activity
2026-04-27 18:23:31 +08:00
Bohan Jiang
d14265de2a fix(comments): preserve newlines from agent CLI writes (#1744)
* fix(comments): preserve newlines from agent CLI writes

Agents (e.g. Codex) routinely emit `multica issue comment add --content
"para1\n\npara2"` because Python/JSON-style string literals are their
default. Bash does not expand `\n` inside double quotes, so the literal
4-char sequence flowed through the CLI into the database and rendered
as text in the issue panel — comments came out as one wall of prose.

Three coordinated fixes so the platform behavior no longer depends on
whether a given model has strong bash-quoting intuition:

- CLI: decode `\n / \r / \t / \\` in `--content` and `--description` for
  `issue create / update / comment add` (callers needing a literal
  backslash still have `--content-stdin`).
- Agent prompt: rewrite the comment-add example in the injected runtime
  config to require `--content-stdin` + HEREDOC for any multi-line body,
  and call out the same rule for `--description`. The previous wording
  flagged stdin only for "backticks, quotes", which models read as
  irrelevant to plain paragraphs.
- Renderer: add `remark-breaks` to the shared Markdown plugin chain so a
  bare `\n` becomes a visible line break instead of a CommonMark soft
  break — protects against models that emit single newlines for
  formatting.

Tests: pin the new CLI helper, and pin the runtime-config guidance so
the multi-line wording cannot decay back into a footnote.

* fix(comments): address review feedback on newline-rendering PR

- Cover the issue panel: ReadonlyContent (used by every comment card and
  the issue description) has its own react-markdown wiring; add
  remark-breaks there too so the renderer fix actually applies to the
  surface the bug was reported on, not just the chat panel. Pinned by
  ReadonlyContent line-break tests.
- Make the prompt's `--description` guidance executable: add
  `--description-stdin` to `issue create` / `issue update`, refactor
  comment-add to share a single `resolveTextFlag` helper, and have the
  injected runtime config name the real flag instead of an imaginary
  "stdin / a tempfile" path. Pinned by the runtime-config guidance test.
- Document the unescape contract on each affected flag's help text and
  pin the precise boundary in tests: `\n / \r / \t / \\` are decoded;
  `\d / \w / \s / \u / \0` and other unrecognised escapes pass through
  verbatim, so regex literals and Windows paths survive intact unless
  they embed a literal `\n` / `\r` / `\t`. Callers that need the literal
  sequence have `--content-stdin` / `--description-stdin` as the escape
  hatch.
2026-04-27 17:17:34 +08:00
Bohan Jiang
12e6ca9906 refactor(execenv): collapse codex plugin cache stale-link branches (#1697)
Merge the two symlink removal branches in exposeSharedCodexPluginCache —
they shared the same os.Remove + recreate path with only the error label
differing. The branch is now keyed off Lstat's ModeSymlink bit, with
Readlink reused only to fast-path an already-correct link. Behaviour is
unchanged; just less duplicated code.
2026-04-26 11:05:08 +08:00
Y. L.
25b393df17 fix(execenv): hydrate Codex skill sources (#1668)
Expose the shared Codex plugin cache inside each per-task CODEX_HOME before launch so plugin-provided skills are available on the first session.

Refresh agent-assigned workspace skills for both newly prepared and reused Codex environments, and cover plugin cache plus reuse behavior with focused execenv tests.
2026-04-26 10:57:51 +08:00
Bohan Jiang
95912243bb test(daemon): cover cancelled classification in executeAndDrain (#1692)
Follow-up to #1686. Locks in two nits flagged during review:

1. agent.Result.Status doc comment now lists "cancelled" alongside the
   existing values, so the enum surface matches actual usage.
2. New TestExecuteAndDrain_ContextCancelled_ReportsCancelled exercises
   the path added in #1686: when the parent context is cancelled before
   the backend produces a Result, executeAndDrain must return
   Status="cancelled" (not "timeout"). A regression here would silently
   restore the misleading log line we just fixed.
2026-04-26 09:27:13 +08:00
Alex Fishlock
2df969cffc fix(daemon): report cancelled tasks as "cancelled", not "timeout" (#1686)
When the server cancels a task (e.g. assignee changes during execution,
explicit user cancel, or workspace_isolation check fail), the daemon's
cancellation poll fires runCancel() on the run context. The drainCtx
derived from runCtx then signals Done(), but executeAndDrain() was
returning Status: "timeout" regardless of *why* the context ended.

The "agent finished status=timeout" log line is then misleading — it
suggests an actual deadline timeout when really the task was cancelled
by upstream. We spent hours misdiagnosing a healthy handoff as a
broken timeout because of this.

Distinguish context.Canceled from context.DeadlineExceeded in
executeAndDrain, and add a "cancelled" case to runTask so the status
propagates through the existing log path.

No behaviour change for genuine timeouts; no behaviour change for
the cancelled-by-poll discard path in handleTask. Only the daemon
log line and TaskResult.Status get the more accurate label.
2026-04-26 09:23:32 +08:00
Bohan Jiang
a89064d693 docs: clean up leftover .pi/agent/skills references (#1645)
PR #1632 updated the Pi project-level skill dir from
.pi/agent/skills/ to .pi/skills/, but missed two references:

- server/internal/daemon/execenv/runtime_config.go:20 — the comment
  block here lists project-level paths for every other provider, so
  using Pi's global path was inconsistent and misleading.
- docs/docs-rewrite-plan.md:88 — planning doc still listed the old
  path in the Skills row.

Follow-up to #1632.
2026-04-25 02:08:33 +08:00
etern
68a312c297 fix(runtimes): fix pi skills dir to: .pi/skills (#1632)
change .pi/agent/skills to .pi/skills

Pi loads skills from:

Global:
  ~/.pi/agent/skills/
  ~/.agents/skills/
Project:
  .pi/skills/
  .agents/skills/

- ref: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md#locations
2026-04-25 02:06:25 +08:00
devv-eve
13d9d7df1b fix: pass autopilot run-only context to agents
Fix run-only autopilot tasks so agents receive autopilot context instead of empty issue instructions. Add regression coverage for run-only terminal event sync.
2026-04-24 16:36:04 +08:00
L.Amar
9c177562e2 fix(daemon/repocache): make bare repo cache keys collision-resistant 2026-04-24 13:04:08 +08:00
Bohan Jiang
e0e91fc792 feat(daemon): harden agent mention-loop instructions (#1581)
* feat(daemon): harden agent mention-loop instructions

Two agents that mention each other via `mention://agent/<id>` can fall into
an infinite reply loop — each says "I'm done" in prose but keeps
`@mentioning` the other, which re-enqueues their run. Adding hard caps on
agent-to-agent turns conflicts with Multica's design principle of giving
agents the same authorship freedom as humans, so this change hardens the
instructions that the harness injects instead.

- Replace the terse "mentions are actions" blurb with a full Mentions
  protocol: `side-effecting` warning, explicit "when NOT to mention"
  (replying to another agent, sign-offs, thanks) and "when a mention IS
  appropriate" (human escalation, first-time delegation, user asked).
- Add a pre-workflow decision step for comment-triggered runs: decide
  whether a reply is warranted at all, decide whether to include any
  `@mention`, and clarify that the post-a-comment rule is mandatory *if*
  you reply — silence is a valid exit for agent-to-agent threads.
- Thread the triggering comment's author kind + display name
  (`TriggerAuthorType` / `TriggerAuthorName`) from the claim endpoint
  through the daemon task type, per-turn prompt, and CLAUDE.md workflow.
  When the author is another agent, both surfaces now name that agent
  and warn against sign-off mentions.
- Soften the old closing line that told agents to `always` use the
  mention format — the word generalized to member/agent mentions and
  encouraged the very behavior that causes loops.

Refs GH#1576, MUL-1323.

* fix(daemon): remove MUST-respond conflict and sanitize trigger author name

Addresses two blocking points on PR #1581:

1. buildCommentPrompt told the agent "You MUST respond to THIS comment"
   and unconditionally appended the reply command — directly conflicting
   with the new agent-to-agent silence-as-valid-exit workflow. Models
   were likely to keep following the older must-reply rule and fall back
   into the loop this PR is trying to close.

   Rewrite the header as "Focus on THIS comment — do not confuse it
   with previous ones" (keeps the anti-stale-comment signal) and change
   BuildCommentReplyInstructions to open with "If you decide to reply,
   post it by running exactly this command" so the reply command is
   available but conditional across both prompt surfaces.

2. Raw agent/user display names were being embedded directly into the
   high-priority prompt and CLAUDE.md via TriggerAuthorName. Agent and
   member names are only validated as non-empty at write time, so a
   name containing newlines, backticks, or fake mention markup would
   turn the field into a cross-agent prompt-injection surface.

   Add execenv.SanitizePromptField — strip control runes, collapse
   whitespace, drop markdown structural characters (backtick, asterisk,
   brackets, pipe, angle brackets, hash, backslash), truncate to 64
   runes — and apply it at both embed sites (per-turn prompt and
   CLAUDE.md). Defense-in-depth at the consumption layer so this works
   for already-stored names without a migration.

Tests: TestSanitizePromptField covers the policy; TestBuildPromptSanitizesAgentName
plants an attack payload in TriggerAuthorName and checks the rendered prompt
does not leak the newline-anchored injection or the fake mention markup.
TestBuildPromptCommentTriggered*{,ByMember} updated to lock in the
conditional reply-command framing.

* refactor(daemon): trim redundant CLAUDE.md preamble and drop name sanitizer

Per PR #1581 feedback:

1. Remove the `if ctx.TriggerAuthorType == "agent"` preamble block in
   runtime_config.go. It duplicated what workflow steps 4 and 5 already
   say ("Decide whether a reply is warranted", "Never @mention the
   agent you are replying to as a thank-you or sign-off"), so the
   signal lands the same without the extra ~7 lines of CLAUDE.md. The
   per-turn prompt preamble in prompt.go stays — that surface has no
   numbered workflow below it and would otherwise lose the
   silence-as-exit signal.

2. Delete execenv.SanitizePromptField + its test. Workspace agents are
   created by trusted team members, so the cross-agent name-injection
   surface it defended isn't realistic in the current trust model.

3. Drop TriggerAuthorType/Name from execenv.TaskContextForEnv and stop
   populating them in daemon.go — they're no longer read by the
   execenv package. The same fields on daemon.Task stay because
   prompt.go still needs them to label the triggering author in the
   per-turn prompt.

Tests simplified to match the leaner shape: CLAUDE.md regression
guards now assert that the anti-loop phrases live in the numbered
workflow, and the sanitizer-specific tests are removed.
2026-04-24 01:39:12 +08:00
Bohan Jiang
8f10741a4d feat(daemon/gc): tighten GC defaults + flex duration suffix (#1559)
* feat(daemon/gc): tighten GC defaults + flex duration suffix

Driven by user feedback in #1539 (40 GB VPS filling within 24h of heavy
AI-coding usage): the existing TTLs were sized for desktop/laptop
deployments and are too lenient for small-disk, long-running daemons.

- GCTTL: 5d → 24h. Done/canceled issues almost never need a multi-day
  grace period in AI-coding workflows.
- GCOrphanTTL: 30d → 72h. Covers crash-leftover and pre-GC directories
  without a month-long wait.
- Issue-deleted orphans (API returns 404) are now cleaned on the next GC
  cycle regardless of mtime. The issue row is gone; there is nothing
  left to protect.
- parseFlexDuration: accept a `d` (day) suffix in addition to the stdlib
  time.ParseDuration syntax. MULTICA_GC_TTL=5d now works; previously only
  120h was accepted.

* fix(daemon/gc): address review — 404 safety + decimal/overflow in duration parser

Two issues flagged in PR review:

1. 404-immediate-clean is unsafe. The /gc-check endpoint returns 404 for
   both "issue deleted" AND "daemon token has no access to the workspace"
   (anti-enumeration, see requireDaemonWorkspaceAccess). Clean-on-404
   would let a scoped-down daemon token wipe taskDirs whose issues are
   still live. Restore the mtime gate against GCOrphanTTL. With the new
   72h default we still shrink the original 30d window dramatically
   without the cross-workspace hazard. Lock the behavior in with a new
   test that asserts a recent 404 is skipped.

2. parseFlexDuration mishandled decimals and swallowed Atoi errors:
   "0.5d" → 7m12s (regex matched only the "5d"), "1.5d" → 1h7m12s,
   and 20+ digit day values Atoi-errored silently to 0. Match the full
   decimal number with `\d*\.\d+|\d+` and parse with ParseFloat so
   fractional days and oversized inputs both go through
   time.ParseDuration correctly — fractions as sub-hour durations,
   overflow as a returned error.
2026-04-23 17:40:09 +08:00
Bohan Jiang
cbe0cbef56 fix(daemon): retry local-skill reports on transient server errors (#1561)
Review follow-up on PR #1557: the server-side change started returning
500 when the store write failed, but the daemon's handleLocalSkillList /
handleLocalSkillImport were discarding the ReportLocalSkill*Result error
return. Net effect was a silent drop — the daemon moved on, the request
stayed in "running" on the server, and the user saw the same "daemon did
not respond within 30 seconds" timeout the store refactor was supposed
to kill.

Fix: route both report calls through reportLocalSkillResultWithRetry,
which retries on 5xx + network errors with 0 / 0.5s / 2s / 4s backoff
(total ~6.5s, well inside the 60s server-side running timeout), stops
on 4xx (request expired / cross-workspace rejection — retry won't help),
bails on context cancel, and logs Error on exhaustion so ops has a
footprint to grep for.

Tests (server/internal/daemon/local_skill_report_test.go, 6 new cases):
- 500 twice then success -> 3 attempts, second retry lands
- 404 -> exactly 1 attempt (permanent, no retry)
- import 502 then success -> 2 attempts
- All-500 -> burns through all backoff slots then gives up with ERROR log
- Context cancel mid-backoff -> exactly 1 attempt, cancellation logged
- Smoke: report paths hit /api/daemon/runtimes/<rt>/local-skills{,import}/<req>/result

localSkillReportBackoffs is var-assignable so tests can swap in zero-delay
schedules without paying real sleep latency.
2026-04-23 17:39:20 +08:00
Bohan Jiang
6fd1255873 feat(runtimes): remove Test Connection / runtime ping feature (#1554)
* feat(runtimes): remove Test Connection / runtime ping feature

The Test Connection action invoked a real single-turn agent run to verify
runtime connectivity. In practice it was expensive (reuses none of the
normal task exec env, so it also gave misleading results) and low value —
daemon heartbeat + Online status already covers the "is the runtime
alive" question. Dropping the whole end-to-end probe path:

- deletes server handler and in-memory PingStore
- drops pending_ping from the heartbeat response and daemon poll loop
- removes daemon.handlePing, PendingPing, ReportPingResult
- removes the CLI `multica runtime ping` command
- removes the PingSection UI block and RuntimePing types / api methods

* docs: fix runtime CLI subcommand list in product-overview
2026-04-23 16:18:21 +08:00
LinYushen
d97aec83d7 fix: pass model to Hermes ACP and add hermes to InjectRuntimeConfig (#1203)
* fix: pass model to Hermes ACP session/new and add hermes to InjectRuntimeConfig

- hermes.go: include opts.Model in session/new params so Hermes uses
  the configured model instead of its default (fixes local LLM failures)
- runtime_config.go: add "hermes" to the AGENTS.md provider list so
  Hermes receives the Multica runtime instructions and skill discovery

Fixes: https://github.com/multica-ai/multica/issues/1195

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hermes): drop false native-skill claim and add regression tests

The previous change added 'hermes' to the 'skills discovered automatically'
branch of buildMetaSkillContent, but resolveSkillsDir has no Hermes case so
skills still land in the .agent_context/skills/ fallback. AGENTS.md ended up
claiming native discovery while the files were somewhere else, which would
mislead Hermes (and future debuggers).

- Move 'hermes' to the fallback branch alongside 'gemini' so AGENTS.md points
  Hermes at .agent_context/skills/ — matching where writeContextFiles actually
  writes them.
- Extract buildHermesSessionParams so the session/new payload is unit-testable.
- Add regression tests covering:
  * buildHermesSessionParams includes/omits 'model' correctly
  * InjectRuntimeConfig('hermes') writes AGENTS.md with the fallback hint
  * writeContextFiles('hermes') writes skills to .agent_context/skills/

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
2026-04-23 12:43:30 +08:00
Bohan Jiang
aa9932e4e1 fix(skills): unify Add Skill UX + surface every local skill with real file count (#1480)
* fix(skills): unify Add Skill UX + surface every local skill with real file count

Iterating on the local-skill import flow that just landed. Three fixes
shipped together because they all surfaced while testing the same code
path on the Skills page.

UX — fold runtime import into the existing "+ Add Skill" dialog
- Drop the standalone HardDrive icon button + the empty-state
  "Import From Runtime" buttons. Adding a skill is now a single entry
  point: the "+" header button (or empty-state button) opens one dialog
  with three tabs: Create / Import URL / From Runtime.
- Extract the runtime-import body into RuntimeLocalSkillImportPanel so
  it can mount inline as a tab. The standalone Dialog wrapper stays for
  the per-runtime "Import this skill" flow on the agent skills tab,
  which preselects runtime + skill and benefits from its own modal.
- Cap the dialog at max-h-[85vh] with a scrollable tabs body so the
  From-Runtime tab (runtime selector + skill list + name/description
  form) no longer overflows the screen on shorter displays.
- Filter the runtime selector to runtimes the caller owns. Other users'
  runtimes were listed but the import endpoint rejects them anyway,
  matching the Runtimes page's "Mine" default.
- The selected-runtime label in the trigger now shows the runtime name
  (`Claude (MacBook-Air.local) (claude)`) instead of the raw UUID — the
  shadcn SelectValue needs explicit children when items don't render
  the bare value as their label.
- Drop the placeholder Sparkles icon to the left of the skill name /
  description inputs in the detail header — it was decorative noise.

Daemon — surface every installed local skill and report the right count
- listRuntimeLocalSkills used filepath.WalkDir, which silently dropped
  every symlinked skill via the os.ModeSymlink early return. Skill
  installers like lark-cli ship every skill at ~/.agents/skills/<name>
  and symlink each one into ~/.claude/skills/, so users with dozens of
  skills only saw the few they had cloned in place. Switch to ReadDir
  + os.Stat (which follows symlinks) on the runtime root.
- collectLocalSkillFiles also failed for symlinked skill dirs because
  filepath.WalkDir does not descend into a symlinked root, so every
  such skill reported 0 files. Resolve the skill dir via EvalSymlinks
  before walking.
- Bundle file count purposely excludes SKILL.md (it travels in the
  bundle's `Content` field to avoid duplication on import). The summary
  now adds 1 back so the user-facing count matches the real file total
  — every skill has SKILL.md, we just required it to be parseable.

Tests
- New TestListRuntimeLocalSkills_FollowsSymlinkedSkillDirs seeds a
  shared installer dir, symlinks one skill into the runtime root, and
  asserts both regular and symlinked skills come back with the right
  source path (~/.claude/...) and metadata.
- TestListRuntimeLocalSkills_Claude updated to expect file_count = 2
  (one supporting file + SKILL.md) and a comment explains the +1 split.

* test(skills): drive new Add Skill dialog flow in skills-page test

Old test asserted the standalone "Import From Runtime" button. The PR
folded that into the unified "+ Add skill" dialog as the third tab, so
the test now opens the dialog, switches to the "From Runtime" tab, and
asserts the same end state.

Also stub useAuthStore so the runtime panel's "Mine"-only filter sees
the seeded runtime owner (user-1).

* fix(daemon): list nested skills, not just depth-1 entries

Per #1480 review (MUL-1246): switching listRuntimeLocalSkills from
filepath.WalkDir to flat ReadDir lost coverage for nested skill
layouts. opencode stores skills as e.g. `release/reporter/SKILL.md`,
and loadRuntimeLocalSkillBundle accepts that slash-delimited key, so
the import dialog could no longer surface skills the load endpoint
was perfectly happy to fetch.

Replace the flat ReadDir with a recursive enumerator that:

- Follows symlinks at every level (so installer-style symlinked skill
  trees still work — that was the original reason for moving off
  WalkDir).
- Short-circuits at every SKILL.md: a directory that qualifies as a
  skill is registered, and its children are NOT scanned for further
  skills. Stale nested SKILL.md files inside a parent skill's bundle
  stay part of that bundle.
- Caps recursion at maxLocalSkillDirDepth=4 (covers opencode's depth=2
  with headroom) and tracks visited resolved paths so a cyclic symlink
  can't loop forever.

New regression test seeds both a top-level skill (with a decoy
SKILL.md inside its templates dir) and a depth-2 nested skill, and
asserts the walker registers exactly two keys — "top" and
"release/reporter" — with the inner templates SKILL.md correctly
ignored.
2026-04-22 14:38:27 +08:00
LinYushen
b624cd98ad feat: identify clients via X-Client-Platform/Version/OS (#1477)
* feat: identify clients via X-Client-Platform/Version/OS

Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.

- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}

Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).

Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.

CORS allowlist extended for the new headers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: address client-identity PR nits

- Memoize the CoreProvider identity object on Web and Desktop, and key
  WSProvider's effect on identity primitives instead of the object
  reference, so unrelated parent re-renders no longer tear down and
  reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
  clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
  on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
  client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
  and asserts the "websocket connected" log entry surfaces them as
  structured attributes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 13:36:13 +08:00
gezilinll
f247a4f544 feat(skills): import runtime local skills into workspace (#1431)
* feat(skills): import runtime local skills into workspace

* fix(skills): address runtime local skill review feedback

* docs(skills): annotate local provider skill paths

---------

Co-authored-by: zhangliang <zhangliang@gaoding.com>
2026-04-22 13:16:51 +08:00
LinYushen
0b1333fb00 feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128) (#1476)
* feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128)

When the daemon process crashed mid-task the issue was stuck at
in_progress for up to 2.5h: the in-flight task timeout was the only
mechanism that ever moved the row, and the runtime heartbeat sweeper
only fires after the runtime stays offline for 45s — a quick restart
beats both windows.

This change implements the A+B plan from the issue thread:

A. lifecycle hygiene
- migration 055 adds attempt / max_attempts / parent_task_id /
  failure_reason / last_heartbeat_at to agent_task_queue
- new daemon-auth endpoint POST /runtimes/{id}/recover-orphans:
  daemon calls it on every register so the server fails any
  dispatched/running tasks the previous process left behind
- new daemon-auth endpoint POST /tasks/{id}/session: persists the
  agent's session_id + work_dir mid-flight so a crash doesn't
  lose the resume pointer (claude+codex emit MessageStatus with
  SessionID; daemon forwards on the first one it sees)
- FailAgentTask / FailStaleTasks / FailTasksForOfflineRuntimes
  now set failure_reason ('agent_error' / 'timeout' /
  'runtime_offline')

B. auto-retry with resume context
- TaskService.MaybeRetryFailedTask spawns a fresh queued attempt
  carrying parent's session_id/work_dir when the failure reason
  is infrastructure-shaped (timeout, runtime_offline,
  runtime_recovery) and attempt < max_attempts; skips autopilot
- wired into the runtime sweeper paths and TaskService.FailTask
  so the user transparently sees a new in_progress run instead of
  a stuck row
- new user-auth POST /api/issues/{id}/rerun + multica issue rerun
  CLI for the manual escape hatch

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(server): address PR review for orphan-task recovery (MUL-1128)

Three review-must-fix items on top of the A+B implementation:

1. recover-orphans now funnels through TaskService.HandleFailedTasks,
   the same shared post-failure pipeline used by the runtime sweeper.
   This guarantees task:failed events are emitted, agent status is
   reconciled, and issues stuck in_progress with no remaining active
   task are reset to todo even when no auto-retry is created
   (max_attempts exhausted, autopilot, non-retryable reason).

2. RerunIssue now uses CancelAgentTasksByIssueAndAgent, scoped to the
   issue's current assignee. The previous implementation called
   CancelAgentTasksByIssue, which would collateral-cancel parallel
   @-mention agents on the same issue.

3. GetLastTaskSession now considers both completed and failed tasks
   (mirroring GetLastChatTaskSession), ordering by the most recent
   timestamp. With UpdateAgentTaskSession pinning session_id/work_dir
   mid-flight, an auto-retry or manual rerun of a daemon-crash failure
   now actually resumes the prior conversation context instead of
   starting fresh — matching the stated B-branch behaviour.

go build / go vet pass; the existing service and agent test suites pass.
runtime_sweeper / handler integration tests require a local DB with the
055 migration (and the pre-existing 050 first_executed_at column).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 13:08:37 +08:00
Bohan Jiang
632fdde700 fix(cli): keep Windows daemon alive after terminal closes + unblock multica update (#1420)
* fix(cli): detach daemon from parent console on Windows

CREATE_NEW_PROCESS_GROUP alone leaves the daemon attached to the
parent console, so closing the launching cmd/PowerShell window fires
CTRL_CLOSE_EVENT down the inherited console and takes the daemon
with it. Add DETACHED_PROCESS so the child has no console at all;
stdout/stderr are already redirected to the log file before spawn.

* fix(cli): make `multica update` work while the binary is running on Windows

On Windows, a running .exe is opened without FILE_SHARE_WRITE, so the
previous os.Rename(tmp, exe) always failed with "Access is denied" —
every `multica update` on Windows hit this, because the CLI is
updating its own running binary.

Windows does allow renaming the running .exe (just not overwriting
it), so the new Windows-only replaceBinary moves the running binary
to `.old` first, installs the new one, and restores the original if
installation fails. A best-effort CleanupStaleUpdateArtifacts runs
at CLI/daemon startup to reclaim the leftover `.old` file once the
old process has exited.

Unix keeps the plain rename-over semantics (the old inode stays valid
for the running process).

* fix(cli): stop daemon via HTTP /shutdown instead of console ctrl events

With DETACHED_PROCESS the Windows daemon shares no console with the
stop caller, so `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid)`
silently never reaches it — the old code would report "stop sent"
while the daemon kept running. Replace the platform-specific
stopDaemonProcess with a cross-platform POST to the daemon's HTTP
/shutdown endpoint, which cancels the same top-level context the
self-restart path already uses. Fall back to `process.Kill()` if
the HTTP call fails.

Also drops the now-unused stopDaemonProcess / CTRL_BREAK_EVENT
wiring, adds handler tests, and updates the DETACHED_PROCESS comment.
2026-04-21 13:03:48 +08:00
Bohan Jiang
8eb81aa396 fix(daemon): enforce workspace isolation for agent execution (#1235) (#1260)
Phase 0 hotfix for the cross-workspace contamination reported in MUL-1027
/ #1235: an agent running for workspace A ended up commenting on (and
renaming) a two-day-old issue in workspace B.

#1249/#1259 fixed resolution for autopilot tasks and consolidated the
task-workspace resolver, and #1294 populated workspace_id in the claim
response for run_only autopilot tasks. Those closed the known fallthroughs
but the failure mode is still broader: whenever the daemon or server fails
to supply a workspace, the CLI silently falls back to
`~/.multica/config.json`, which is user-global, not workspace-scoped. On a
host running daemons for multiple workspaces, a single gap in workspace
propagation is enough to leak writes across workspaces.

This PR adds three coordinated guards so no single layer's bug can cause a
cross-workspace write:

1. `server/cmd/multica/cmd_agent.go` — `resolveWorkspaceID` detects the
   agent execution context (`MULTICA_AGENT_ID` / `MULTICA_TASK_ID` env,
   both daemon-only markers) and in that context refuses to fall back to
   the user-global CLI config. Human / script usage (no agent env) is
   unchanged: flag → env → config fallback chain still applies.

2. `server/internal/handler/daemon.go` — `ClaimTaskByRuntime` now
   captures the runtime's workspace from `requireDaemonRuntimeAccess` and
   enforces `resolved_task_workspace == runtime_workspace` after the
   existing issue/chat/autopilot branches. On mismatch or empty, the
   handler explicitly cancels the just-dispatched task (via
   `TaskService.CancelTask`, which also reconciles agent status) and
   returns 500. Without the explicit cancel, `ClaimTaskForRuntime` had
   already transitioned the task to 'dispatched' and the agent status to
   'working', so a plain 500 would leave both stuck for the ~5 min
   stale-task sweep window.

3. `server/internal/daemon/daemon.go` — `runTask` refuses to spawn the
   agent when `task.WorkspaceID` is empty (defense-in-depth against
   server bugs and reused workdirs).

Tests:
- `cmd/multica/cmd_agent_test.go`:
  `TestResolveWorkspaceID_AgentContextSkipsConfig` — five subtests
  covering the full fallback matrix (outside agent context still reads
  config; agent context uses env; agent context with empty env returns
  empty; task-id-only marker also counts; requireWorkspaceID surfaces the
  agent-context error message).
- `internal/handler/daemon_test.go`:
  `TestClaimTaskByRuntime_TaskWorkspaceMismatch_CancelsAndRejects` —
  constructs a data-inconsistent task (runtime_id in workspace A,
  issue_id in workspace B) and asserts the handler returns 500 AND
  leaves the task in 'cancelled' state (not 'dispatched').

Phase 1/2 follow-ups (prompt injection of workspace slug, session lookup
workspace filter, cross-workspace audit of agent-facing endpoints,
observability) are out of scope for this PR and tracked separately.
2026-04-21 12:55:12 +08:00
devv-eve
9e47b83f02 feat(agent): add Kimi CLI as agent runtime (#1400)
* feat(agent): add Kimi CLI as agent runtime

Adds support for Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/kimi-cli)
as a new agent runtime, alongside Claude, Codex, OpenCode, OpenClaw, Hermes,
Gemini, Pi, Cursor and Copilot.

Kimi Code CLI implements the standard Agent Client Protocol (ACP) via the
`kimi acp` subcommand, so the new `kimiBackend` reuses the existing
hermesClient JSON-RPC transport in the agent package — only the binary,
client identity, log prefix, and tool-name extraction differ.

Wiring:
- server/pkg/agent: new kimiBackend + kimi_test.go; registered in New(),
  LaunchHeader map, and the supported-types coverage test.
- server/internal/daemon/config.go: probes `kimi` (overridable via
  MULTICA_KIMI_PATH / MULTICA_KIMI_MODEL).
- server/internal/daemon/execenv: writes AGENTS.md as the runtime context
  file (Kimi reads AGENTS.md natively via /init), and writes skills under
  `.kimi/skills/` so they are auto-discovered by the project-level skill
  loader.
- packages/views/runtimes: ProviderLogo gains a Kimi mark.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(agent/kimi): support per-agent model selection via ACP set_model

Wire Kimi into the model dropdown introduced in #1399:

- ListModels gets a 'kimi' case that drives the same ACP
  initialize + session/new handshake as Hermes; both share a new
  discoverACPModels helper and parseACPSessionNewModels parser
  so future ACP backends only need a small provider entry.
- kimiBackend now issues session/set_model after session/new when
  opts.Model is non-empty, mirroring the Hermes flow. Failures
  fail the task instead of silently falling back to Kimi's
  default model — silent fallback would hide that the dropdown
  pick wasn't honoured.

Verified: go build ./..., go test ./pkg/agent/... ./internal/daemon/... ./internal/handler/..., pnpm typecheck and pnpm test (138 passed).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(agent): address code review feedback on Kimi runtime

- Share ACP provider-error sniffer between hermes and kimi. Previously
  only hermes promoted stderr-observed 4xx/5xx into a failed task;
  kimi would report "completed + empty output" when the Moonshot
  upstream rejected a request (expired token, rate limit, …). Rename
  hermesProviderErrorSniffer → acpProviderErrorSniffer and parameterise
  the provider name; wire it into kimiBackend.Execute the same way.
- Rename extractHermesSessionID → extractACPSessionID (shared by all
  ACP backends) so the name matches parseACPSessionNewModels.
- Drop the redundant second argument to kimiToolNameFromTitle; the
  Message struct has only one relevant field (Tool), so passing it
  twice was a dead fallback. Document that the function normalises
  residual capitalised kimi titles not caught by hermesToolNameFromTitle.
- Remove kimi-only cmd.WaitDelay override; the hermes baseline is
  fine for both and divergence adds noise.
- Add TestKimiBackendSetModelFailureFailsTask: fake `kimi acp` binary
  that returns a JSON-RPC error for session/set_model, asserts that
  the task result surfaces status=failed with the model name + upstream
  message and preserves the session id.
- Fix stale agent listings in agent.go / daemon/config.go doc comments
  (missing cursor, gemini, copilot).

All: `go build ./...`, `go vet ./...`, `go test ./pkg/agent/...
./internal/daemon/... ./internal/handler/...` green.

* fix(agent/kimi): pass --yolo so Shell tools don't hang on approval

Kimi's default config has `default_yolo = false`. Every Shell/file-mutating
tool call causes kimi acp to send a `session/request_permission` request
and block (up to 300s) waiting for a response. The daemon's hermesClient
only handles `session/update` notifications — permission requests go
unanswered, the tool call times out, and the UI loop eventually dies
("UI loop timed out"). Observed with the first real kimi task: agent sat
as Live for ~7 minutes before the daemon killed it.

The fix mirrors hermes' HERMES_YOLO_MODE=1 override: pass `--yolo` to
`kimi` so it auto-approves everything. `--yolo` is a top-level flag on
the `kimi` CLI (not a flag on `kimi acp`), so it must come before the
`acp` subcommand in argv. Added to kimiBlockedArgs so user custom_args
can't strip it.

While here, fix a related bug that made kimi tool names show up empty
in the daemon log ("tool #1: "): hermesToolNameFromTitle's fallback
returned `kind` when neither title-with-colon nor kind matched a known
tool. Kimi's ACP `tool_call` emits bare titles like "Shell" or "Read
file" with no `kind` at all, so we'd drop the title on the floor before
kimiToolNameFromTitle ever got a chance to map it. Now: preserve the
title when kind is unclassified; hermes titles always carry a colon so
this branch never fires for hermes.

Tests:
- TestKimiBackendPassesYoloFlag — fake binary that records its argv,
  asserts --yolo comes before acp.
- TestHermesToolNameFromTitle rows for bare kimi-style titles.
- Existing suite green: go build, go vet, full pkg/agent + daemon +
  handler test packages.

* fix(agent/acp): auto-approve session/request_permission from agent

The previous attempt (`kimi --yolo acp`) was a no-op. Inspected the
kimi-cli source: the `acp` Typer subcommand takes no parameters, so
flags on the root `kimi` command are dropped before `acp_main()` runs
— it's impossible to opt into YOLO mode through CLI flags for ACP.

The real fix is on our side: respond to session/request_permission.

ACP is bidirectional. When kimi runs a Shell or file-write tool, it
sends `session/request_permission` (agent → client, JSON-RPC request
with id + method) and waits up to 300s for a response. Our existing
hermesClient.handleLine only dispatched: (id + result/error) →
handleResponse, and (no id + method) → handleNotification. A request
with BOTH id and method fell through and got silently dropped — kimi
timed out, UI loop died, task sat stuck for 7 minutes.

Add handleAgentRequest: for session/request_permission, echo the id
and respond with outcome=selected, optionId=approve_for_session. The
daemon is headless; there's no user to prompt. `approve_for_session`
lets the agent remember the action so subsequent identical calls
(every Shell, every file write) skip the round-trip entirely. For any
other agent → client method, reply with standard -32601 method-not-
found so the agent doesn't block.

Also:
- Add writeMu so request() (main goroutine) and handleAgentRequest
  (reader goroutine) don't interleave JSON frames on stdin.
- Revert the `--yolo acp` flag — it's a no-op, and carrying it in
  kimiBlockedArgs gives the wrong impression that it does something.
  Comment in kimi.go now points at handleAgentRequest as the real fix.

Tests:
- TestHermesClientAutoApprovesPermissionRequest: inject a
  session/request_permission, assert the reply echoes the id and
  carries {outcome: selected, optionId: approve_for_session}.
- TestHermesClientReplesMethodNotFoundForUnknownAgentRequest: confirm
  unknown agent → client methods get JSON-RPC -32601 instead of silence.
- TestKimiBackendInvokesACPSubcommand replaces the yolo-flag assertion
  with a negative assertion: no dead --yolo / --auto-approve / -y on
  argv, since they'd pretend to do something they can't.

All: go build ./..., go vet ./..., go test ./pkg/agent/... green.

* fix(agent/acp): surface kimi tool input/output via content blocks

Kimi-cli emits tool_call and tool_call_update ACP frames with the
input/output inside a `content` array of ContentToolCallContent
blocks (shape: {type:"content", content:{type:"text", text:"..."}}),
not in the hermes-style `rawInput` map / `rawOutput` string. Our
parser only looked at rawInput/rawOutput, so the daemon recorded
empty Input and Output for every kimi tool — the execution-history
UI showed blank terminal panels even for commands that ran fine.

Add extractACPToolCallText() and a fallback in handleToolCallStart /
handleToolCallUpdate: when rawInput is nil / rawOutput is empty, pull
the text out of the content blocks. rawInput / rawOutput still take
precedence so hermes' behaviour is untouched. Terminal /
FileEditToolCallContent blocks are skipped (we have nothing to render
them as — kimi only emits TerminalToolCallContent when the client
advertises terminal capability, which we don't).

Tests:
- TestHermesClientHandleToolCallStartKimiContent — content array →
  Input.text populated.
- TestHermesClientHandleToolCallCompleteKimiContent — multi-block
  content → Output concatenated with newline separator.
- TestHermesClientHandleToolCallRawOutputTakesPrecedence — hermes
  rawOutput still wins when both are present.
- TestExtractACPToolCallText — unit coverage for the helper
  (single/multiple text blocks, terminal-block skip, empty input).

* fix(agent/acp): buffer streaming tool args so Input isn't empty in UI

kimi-cli streams tool args token-by-token via tool_call_update frames
— the initial tool_call carries an empty content block and each
subsequent in_progress update carries the cumulative JSON so far
(`{`, `{"comma`, `{"command": "echo`, …). The final completed update
then carries the tool's stdout, not the args. Observed per kimi-cli
acp/session.py::_send_tool_call{,_part,_result} and confirmed by
driving a real Shell call end-to-end: 10 in_progress frames, last
with `{"command": "echo hello world"}`, then completed with `hello
world\n`.

Our previous handleToolCallStart emitted MessageToolUse on the first
tool_call frame, capturing the empty content — so every kimi tool
appeared in the execution-history UI with a blank input. Output was
correct (fix 4335c198) but command was missing.

Changes:
- hermesClient now tracks pending tool calls per toolCallId. Hermes
  path is unchanged — rawInput is present at tool_call time, so
  emit-immediately-then-flag-emitted still fires on the initial frame.
- kimi path defers MessageToolUse until status=completed / failed.
  tool_call_update in_progress frames update the buffered argsText
  (cumulative, so overwrite); on completion we parse the accumulated
  JSON into Message.Input. Malformed JSON falls back to `{"text": …}`
  so non-JSON tool args still render.
- Orphan completion frames (no matching tool_call seen — e.g. daemon
  restarted mid-task) synthesise ToolUse from the update's own
  title/kind/rawInput so the UI still gets a header.
- extractACPToolCallText now also renders FileEditToolCallContent
  blocks as a compact header ("--- path / +++ path / (edited: N → M
  bytes)"). kimi emits these for Write / StrReplaceFile / Patch when
  the tool's display block is a DiffDisplayBlock.

Tests:
- TestHermesClientKimiStreamingToolCall: empty tool_call + 5 streaming
  in_progress + completed. Asserts no emission until complete, then
  [ToolUse(Input.command="echo hi"), ToolResult(Output="hi\n")].
- TestHermesClientKimiMalformedArgsFallback: non-JSON argsText → falls
  back to Input.text.
- TestHermesClientHandleToolCallCompleteOrphan: completed frame
  without a start → ToolUse synthesised from update's rawInput.
- TestExtractACPToolCallText: diff + new-file-diff cases.

All agent / daemon / handler test packages green.

---------

Co-authored-by: Eve <8b0578a3-cf72-4394-9e38-b328eca92463@users.noreply.multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-21 02:18:30 +08:00
Jiayuan Zhang
b291db11c2 feat(agents): add per-agent model field with provider-aware dropdown (#1399)
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.

Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.

Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.

Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
2026-04-21 00:06:34 +08:00
Bohan Jiang
c76c790b32 fix(daemon/execenv): make posting result comment an explicit workflow step (#1372)
Agents were silently finishing tasks without ever posting results to the
issue — their final reply stayed in terminal/log output only. See MUL-1124.

Root cause: the injected CLAUDE.md / AGENTS.md put "post a comment with
results" inside the body of step 4 (a nested clause in the default workflow
description), so skill-driven flows jumped straight from "do the work" to
`status in_review`.

- Hoist posting the result comment into its own explicit, numbered step in
  both assignment-triggered and comment-triggered workflows, with the exact
  `multica issue comment add` invocation inlined.
- Add a hard warning at the top of the Output section that terminal / chat
  text is never delivered to the user.
- Add regression test covering both workflow branches.
2026-04-20 17:48:06 +08:00
Bohan Jiang
951f51408a fix(agent/comments): prevent resumed sessions from reusing stale --parent UUID (#1374)
* fix(agent/comments): re-emit trigger comment id every turn + server-side parent_id guard

Resumed Claude sessions keep prior turns' tool calls in context, so a
comment-triggered task could reuse the PREVIOUS turn's --parent UUID
instead of the current trigger's. The reply landed in the wrong thread
(MUL-1125): backend stored exactly what the agent sent, but the agent
pulled a stale UUID from its own conversation memory.

Two layers of defense:

1. Extract BuildCommentReplyInstructions so daemon.buildCommentPrompt
   and execenv.InjectRuntimeConfig emit the same "use this exact
   --parent, do not reuse values from previous turns" block. The
   per-turn prompt now carries the current TriggerCommentID, which it
   previously relied on CLAUDE.md for (and CLAUDE.md isn't re-read
   mid-session).

2. Handler-side guard in CreateComment: when an agent posts from inside
   a comment-triggered task (X-Agent-ID + X-Task-ID, task has
   TriggerCommentID), require parent_id == task.TriggerCommentID or
   return 409. Assignment-triggered tasks are untouched.

* fix(agent/comments): scope parent_id guard to the task's own issue

Two issues from CI + GPT-Boy's review:

1. Guard was too broad: the CLI stamps X-Task-ID on every request, so an
   agent legitimately commenting on a different issue while its current
   task was comment-triggered would get 409'd with the wrong issue's
   trigger comment id. Narrow the guard to fire only when the request's
   issue matches the task's own issue — cross-issue agent activity
   stays unblocked.

2. The integration test tried to insert a second queued task for the
   same (agent, issue), which hits the idx_one_pending_task_per_issue_agent
   unique index. Replace the assignment-triggered-task sub-case with a
   cross-issue regression test (the scenario we now need to cover anyway):
   post on issue B while X-Task-ID points at a comment-triggered task on
   issue A, expect 201.
2026-04-20 15:56:16 +08:00
Bohan Jiang
bd445782d5 fix(openclaw): stop passing unsupported flags and actually deliver AgentInstructions (#1362)
Fixes #1332.

Two regressions introduced in #910 (2026-04-14, "OpenClaw backend P0+P1
improvements") that together block all openclaw users:

1. `openclaw agent` does not accept `--model` or `--system-prompt`, so
   any agent configured with a Model field crashed in ~700ms with
   `exit status 1`. Remove both forwards, and add them to
   openclawBlockedArgs so custom_args can't reintroduce the crash.
   Model is bound at registration time via `openclaw agents
   add/update --model`.

2. AgentInstructions were written to `{workDir}/AGENTS.md` by
   execenv.InjectRuntimeConfig, but openclaw loads bootstrap files
   from its own workspace dir — the file was never read, so every
   agent's Instructions field was silently discarded. Populate
   opts.SystemPrompt for the openclaw provider in runTask and
   prepend it to the `--message` payload in the backend so the
   model actually receives the instructions.

Other providers surface instructions through their native runtime
config file (CLAUDE.md / AGENTS.md / GEMINI.md) and are intentionally
left unchanged to avoid double injection.

Extract buildOpenclawArgs so arg construction is directly testable;
add unit tests covering the removed flags, the SystemPrompt prepend,
and custom_args filtering.
2026-04-20 14:01:41 +08:00
devv-eve
5fa1da448f fix(chat): preserve chat session resume pointer across failures (#1360)
* fix(chat): preserve chat session resume pointer across failures

The chat 'forgets earlier messages' bug came from PriorSessionID being
silently lost in several edge cases:

- UpdateChatSessionSession unconditionally overwrote chat_session.session_id,
  so any task that completed without a session_id (early agent crash,
  missing result) wiped the resume pointer to NULL.
- CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A
  follow-up chat message claimed in between resumed against a stale (or
  NULL) session and started over.
- FailAgentTask never wrote session_id back, so a task that established
  a real session before failing lost its resume pointer.
- ClaimTaskByRuntime only trusted chat_session.session_id and never
  fell back to the existing GetLastChatTaskSession query, so a single
  bad turn could permanently drop the conversation memory.

This change:

- Use COALESCE in UpdateChatSessionSession so empty inputs preserve the
  existing pointer; surface DB errors instead of swallowing them.
- Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside
  the same transaction (TaskService now takes a TxStarter).
- Extend FailAgentTask + the daemon FailTask path (client, handler,
  service) to forward session_id/work_dir, so failed/blocked tasks that
  built a real session still record it.
- Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the
  chat_session pointer is missing, and include failed tasks in that
  lookup so a single failure can't lose the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): forward session_id/work_dir on blocked + timeout paths

runTask previously dropped result.SessionID and env.WorkDir on the
non-completed return paths:

- timeout returned a naked error, so handleTask called FailTask with
  empty session info and the chat resume pointer was either left stale
  or eventually overwritten with NULL.
- blocked / failed (default branch) returned a TaskResult without
  SessionID / WorkDir, so even though FailTask now COALESCEs into
  chat_session, there was no value to write through.
- the empty-output completion path was the same: it raised an error
  even when a real session_id had been built.

All three paths now return a TaskResult that carries the SessionID /
WorkDir the backend produced. Combined with the COALESCE-based update
in UpdateChatSessionSession and the FailTask plumbing introduced in
PR #1360, the next chat turn can always resume from the latest agent
session — even when the previous turn timed out, was rate-limited, or
returned an empty completion — instead of starting over with no memory
of the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): capture session id from session.start as fallback

The Copilot backend only read sessionId from the synthetic 'result'
event, ignoring the one already present on session.start. When the CLI
was killed before result arrived (timeout, cancel, crash, or a
session.error mid-turn), the daemon reported SessionID="" and the
chat-session resume pointer could not advance — causing the chat to
silently drop conversation memory on the next turn.

Capture session.start.sessionId into state up front, and only let
'result' overwrite it when it actually carries one. result still wins
when present (it is the authoritative end-of-turn record).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): parse premiumRequests as float to preserve session id

Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5),
not an integer. Our copilotResultUsage struct typed it as int, which
made the entire 'result' line fail json.Unmarshal — silently dropping
sessionId on every turn.

This was the real cause of chat memory loss: the daemon reported
SessionID="" to the server, chat_session.session_id stayed NULL, and
the next chat turn never received --resume <id>, so each turn started
a fresh Copilot session with no prior context.

Add a regression test using the real JSON line from CLI v1.0.32 that
asserts sessionId is preserved when premiumRequests is fractional.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: yushen <ldnvnbl@gmail.com>
2026-04-19 22:50:33 -07:00
Bohan Jiang
e198a67f8f docs(prompt): warn agents that mention syntax is an action, not a text reference (#1306)
Agent mentions enqueue a new task; member mentions send a notification.
Without this warning, agents have used `[@Name](mention://agent/<id>)` in
prose (e.g. "GPT-Boy is correct") and accidentally re-triggered the agent.

Adds a caveat under `## Mentions` in the prompt injected into agent
runtimes, plus tightens the Agent bullet to make the side-effect explicit.
2026-04-18 13:09:07 +08:00
Korkyzer
63800f05ff fix(agent): add per-agent mcp_config field to restore MCP access (#1168)
* fix(agent): add per-agent mcp_config field to restore MCP access

Closes #1111

The --strict-mcp-config flag was added defensively in #592 to prevent
Claude agents from inheriting MCP state from the outer Claude Code session.
It was meant to be paired with --mcp-config <path> to inject a controlled
set of MCPs, but that path was never implemented, which silently stripped
all user-scope MCPs from spawned agents.

This PR completes the original design by:

- Adding a nullable mcp_config jsonb column to the agents table
- Wiring mcp_config through AgentResponse, Create/Update requests
- Piping it into ExecOptions.McpConfig in the daemon
- Serializing to a temp file and passing --mcp-config <path> in buildClaudeArgs
- Blocklisting --mcp-config in claudeBlockedArgs to prevent override
  via custom_args

Does not touch Codex provider (tracked separately in #674).
Does not implement Multica MCP auto-injection (out of scope).

* fix: disambiguate JSON null vs absent for mcp_config
2026-04-18 01:35:22 +08:00
devv-eve
b2307a5ee9 fix(execenv): write Copilot skills to .github/skills/ for native discovery (#1270)
GitHub Copilot CLI scans project-level skills from .github/skills/<name>/SKILL.md
(per the official cli-config-dir-reference docs), not from .agent_context/skills/.
Previously, skills injected for the copilot provider were placed under
.agent_context/skills/ and only referenced by name in AGENTS.md, meaning
Copilot would not actually pick them up.

- resolveSkillsDir: add a dedicated copilot case writing to .github/skills/
- Update doc comments in context.go and runtime_config.go
- Add TestWriteContextFilesCopilotNativeSkills covering the new path and
  ensuring .agent_context/skills/ is not created for copilot

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 03:07:32 -07:00
Bohan Jiang
4bd8533269 fix(daemon): machine-scoped daemon.id so CLI + desktop share one identity (#1263)
Before this PR, `EnsureDaemonID(profile)` wrote to ~/.multica/profiles/
<profile>/daemon.id — meaning the same physical machine minted a different
UUID per profile. On any host running both the CLI-spawned daemon (default
profile) and the desktop-spawned daemon (profile derived from API host),
that produced two runtime rows per provider per workspace. The server-side
`legacy_daemon_ids` merge only covers hostname variants, not UUIDs, so the
rows just piled up.

Profile boundaries are about which backend/account the daemon is talking
to, not about the physical machine. Identity should be per-machine, token
should be per-profile.

Changes:

- `EnsureDaemonID` now always reads/writes ~/.multica/daemon.id regardless
  of the `profile` argument. The argument is retained for migration-only
  use (see promotion below).
- Migration path: when the canonical file is missing and the requested
  profile has a pre-change per-profile daemon.id, promote that UUID in
  place so a user who only ever ran under a named profile keeps the same
  identity instead of minting a fresh UUID and round-tripping a merge.
- New `LegacyDaemonUUIDs()` scans ~/.multica/profiles/*/daemon.id and
  returns every UUID that survives parsing. `config.go` now appends those
  to the daemon's `legacy_daemon_ids` payload, so any runtime rows
  previously registered under a per-profile UUID (on any backend) get
  merged into the canonical machine UUID at register time.

Tests replace the `ProfileIsolated` assertion with `SharedAcrossProfiles`
and add coverage for promotion, UUID scanning (including skipping corrupt
files), and the empty-profiles-dir fast path.
2026-04-17 15:29:30 +08:00
Bohan Jiang
a73336dcf8 feat(daemon): persistent UUID identity + legacy-id merge at register-time (#1220)
* feat(daemon): persistent UUID identity + legacy-id merge at register-time

daemon_id is now a stable UUID persisted to `<profile-dir>/daemon.id` on
first start, replacing the hostname-derived id that drifted whenever
`.local` appeared/disappeared, a system was renamed, or a profile
switched — each of which used to mint a fresh `agent_runtime` row and
strand agents on the old one.

To migrate existing installs without operator intervention, the daemon
reports every legacy id it may have registered under previously
(`host`, `host` with `.local` stripped, and `host[-profile]` variants
for both). At register-time the server looks up each candidate row
scoped to (workspace, provider), re-points its agents and tasks onto
the new UUID-keyed row, records which legacy id was subsumed in the
new `legacy_daemon_id` column for audit, and deletes the stale row.
Result: users running `xxx.local`-keyed runtimes today transparently
land on the new UUID row on next daemon restart.

The hostname-prefix `MigrateAgentsToRuntime` / `daemon_id LIKE '...-%'`
compatibility shim is no longer needed and has been removed along with
the handler call that invoked it.

* fix(daemon): handle bidirectional .local drift and case drift in legacy merge

Review on #1220 flagged two gaps in the legacy-id migration candidate set:

1. Reverse .local: LegacyDaemonIDs only added the stripped variant when the
   current hostname ended in `.local`. The opposite direction — DB has
   `foo.local`, current host is `foo` — was missed, so runtimes registered
   under the `.local` variant stayed orphaned after upgrade. Now both
   variants (`foo` and `foo.local`) are always emitted, regardless of what
   `os.Hostname()` currently returns, plus their `-<profile>` suffix forms.

2. Case drift: os.Hostname() has been observed returning different casings
   on the same machine across mDNS/reboot state. A case-sensitive `=`
   comparison stranded rows like `Jiayuans-MacBook-Pro.local` when the
   daemon later reported `jiayuans-macbook-pro.local`. FindLegacyRuntimeByDaemonID
   now uses `LOWER(daemon_id) = LOWER(@daemon_id)` on both sides, so casing
   differences merge rather than orphan. The (workspace_id, provider) prefix
   still bounds the scan to a tiny set of rows so the non-indexed LOWER()
   comparison has negligible cost.

Tests: TestLegacyDaemonIDs gets the mixed-case + reverse-direction cases;
daemon_test.go adds TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal
and TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift.

* fix(daemon): consolidate every case-duplicate legacy runtime, not just the first

Follow-up review on #1220: after switching to `LOWER(daemon_id) =
LOWER(@daemon_id)`, the single-row lookup still only merged one legacy
row per candidate. If a machine already had two rows in the DB that
differed only in casing (e.g. `Jiayuans-MacBook-Pro.local` AND
`jiayuans-macbook-pro.local` coexisting because earlier hostname drift
already minted a duplicate), only one of them got consolidated and the
other stayed orphaned — violating the "no duplicate runtime per machine
after backfill" acceptance.

- FindLegacyRuntimeByDaemonID → FindLegacyRuntimesByDaemonID (:many)
- mergeLegacyRuntimes iterates every returned row and dedupes across
  overlapping legacy candidates so `foo` and `foo.local` both resolving
  to the same stored row don't double-process

Test: TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes seeds two
case-duplicate rows with one agent each and confirms both rows are
deleted and both agents end up on the new UUID-keyed row.
2026-04-17 15:10:38 +08:00
Jiayuan Zhang
9e15b17c92 feat(cli): add autopilot commands (#1234)
* feat(cli): add autopilot commands

Expose the existing autopilot REST API through the multica CLI so
users and agents can list, get, create, update, delete, trigger, and
inspect autopilots, plus manage their triggers (schedule/webhook/api).

Also surface the read + core write commands in the agent meta skill
prompt so agents discover them without needing --help.

- new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints
- add APIClient.PatchJSON (autopilot update uses PATCH)
- expose autopilot in CORE COMMANDS group
- extend runtime_config.go meta skill with autopilot entries
- document autopilot command group in CLI_AND_DAEMON.md

* fix(autopilot): address code review — restrict run_only, validate workspace on update

Code review caught two issues with the initial CLI PR:

1. run_only mode is broken end-to-end. The daemon-side
   resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves
   workspace from issue/chat, so run_only tasks (which have neither)
   return 404 from /start. BuildPrompt() would also emit an empty issue
   ID. The service-level resolver in internal/service/task.go already
   handles AutopilotRunID, but the daemon endpoint uses the handler
   copy. Fixing that path is out of scope for the CLI PR; drop
   run_only from the CLI and docs so we don't recommend a mode that
   cannot complete. Server continues to accept it for the existing UI.

2. UpdateAutopilot did not verify that a new assignee_id belongs to
   the workspace, unlike CreateAutopilot. This let a PATCH swap in an
   agent from a different workspace. Mirror the same
   GetAgentInWorkspace check.
2026-04-17 14:46:34 +08:00
LinYushen
b5de04da59 fix(daemon): platform-aware Codex sandbox config to unbreak macOS network (MUL-963) (#1246)
* fix(daemon): platform-aware Codex sandbox config to unbreak macOS network

On macOS, Codex's Seatbelt sandbox in workspace-write mode silently
ignores '[sandbox_workspace_write] network_access = true' (see
openai/codex#10390). That blocks DNS inside the sandbox, so 'multica
issue get' and other CLI calls fail with 'dial tcp: lookup ...: no such
host' — this is what caused MUL-963.

Changes:

- New server/internal/daemon/execenv/codex_sandbox.go: picks a sandbox
  policy based on runtime.GOOS and the detected Codex CLI version.
  Non-darwin or darwin with a known-fixed version keeps workspace-write
  + network_access=true; older darwin falls back to danger-full-access
  and logs a warn with upgrade hint. The fix-version threshold is a
  single constant (CodexDarwinNetworkAccessFixedVersion) so it's easy
  to bump once upstream ships.
- Per-task config.toml now gets a 'multica-managed' marker block
  (BEGIN/END comments) rewritten idempotently; user-owned keys outside
  the markers are preserved. Legacy inline sandbox directives from
  earlier daemon versions are stripped on migration.
- execenv.PrepareParams gains CodexVersion; execenv.Reuse takes a
  codexVersion arg; daemon.go caches detected versions at registration
  and threads them through to Prepare/Reuse.
- Replaces the old ensureCodexNetworkAccess tests with
  platform-parameterised coverage (linux vs darwin, idempotency,
  legacy-migration, policy matrix).
- docs/codex-sandbox-troubleshooting.md: symptom fingerprint table,
  decision matrix, self-check commands, trade-offs.

Refs: MUL-963

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): hoist managed sandbox block above user tables (MUL-963)

Review on #1246 flagged that upsertMulticaManagedBlock appended the
managed block to EOF. If the user's config.toml ends inside a TOML table
(e.g. [permissions.multica] or [profiles.foo]), a trailing bare
sandbox_mode = "..." is parsed as a key of that preceding table, so
Codex silently ignores the policy the daemon meant to apply.

Two changes make the block position-independent:

- renderMulticaManagedBlock now emits only top-level key=value lines and
  uses TOML dotted-key form (sandbox_workspace_write.network_access =
  true) instead of opening a [sandbox_workspace_write] header. The block
  therefore neither inherits from nor leaks into any surrounding table.
- upsertMulticaManagedBlock always hoists the block to the top of the
  file (stripping any previously written managed block first), so the
  sandbox_mode line is always at the TOML root regardless of what the
  user put below it. This also migrates configs written by the original
  PR #1246 logic where the block was trapped behind a user table.

Added tests for the regression scenario (pre-existing [permissions.*]
table) and the legacy-trailing-block migration; updated the existing
Linux default test and the troubleshooting runbook to reflect the
dotted-key form.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 14:03:13 +08:00
Bohan Jiang
3d98f64ea1 Revert "fix(daemon): normalize hostname by stripping .local mDNS suffix (#1070)" (#1207)
This reverts commit 6428a10046.
2026-04-17 00:35:06 +08:00
pradeep7127
6428a10046 fix(daemon): normalize hostname by stripping .local mDNS suffix (#1070)
* fix(daemon): normalize hostname by stripping .local mDNS suffix

Daemons started via different methods (standalone CLI vs desktop app
bundled binary) resolve the hostname differently on macOS — one gets
'computer' and the other 'computer.local'. This caused duplicate runtime
registrations for the same machine.

Stripping the .local suffix at the point of hostname resolution ensures
both always register under the same identifier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(daemon): move empty-host fallback to after .local trim; fix Makefile @ prefix

- Reorder: TrimSuffix runs first, then empty-check, so a hostname of
  just ".local" doesn't propagate as an empty daemon_id/device_name
- Add missing @ prefix on migrate command in Makefile so it isn't
  echoed twice at startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 23:42:12 +08:00
Naiyuan Qing
6a2432b16b refactor: remove onboarding flow, fix daemon zero-workspace bootstrap (#1175)
* fix(daemon): allow startup with zero workspaces

The daemon used to fail fast with "no runtimes registered" when the
initial workspace sync returned zero workspaces. This masked a latent
bug: a newly-signed-up user has no workspaces yet, so the daemon would
crash immediately after login instead of waiting for the first
workspace to be created.

workspaceSyncLoop already polls every 30s (daemon.go:107, 365) to
discover new workspaces — the fail-fast check at startup was bypassing
this dynamic discovery. Remove the check so the daemon stays resident
and picks up the first workspace whenever it appears.

PR #1001 partially addressed this for the "server has workspaces but
local CLI config is empty" case. This finishes the job for the true
zero-workspace state, which until now was masked by the onboarding
wizard always creating a workspace before the daemon started.

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

* refactor(views): extract CreateWorkspaceForm for reuse

Modal and the upcoming /new-workspace page share the same form +
mutation + slug validation. Extract to a shared component so they
can't drift.

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

* feat(views): add NoAccessPage for unknown or inaccessible workspace slugs

Rendered when the URL slug doesn't resolve to a workspace the user has
access to. Deliberately doesn't distinguish 404 vs 403 to avoid letting
attackers enumerate workspace slugs.

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

* feat(paths): add /new-workspace route and reserve slug on both sides

Adds paths.newWorkspace() builder, registers /new-workspace as a global
(pre-workspace) prefix, and reserves the "new-workspace" slug on both
frontend and backend (kept in sync per convention). Existing
"onboarding" reservation retained — removing it would desync FE/BE
and leaves no future fallback if an onboarding route is revived.

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

* chore(migrations): audit no existing workspace uses 'new-workspace' slug

Migration 046 blocks deploy if any workspace in the DB has slug =
'new-workspace', which would shadow the new global workspace creation
route at /new-workspace.

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

* feat: add /new-workspace route on web and desktop

Renders the CreateWorkspaceForm as a full-page workspace creation flow,
used as the destination for first-time users with zero workspaces.
Replaces the 4-step onboarding wizard with a single form.

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

* feat: show NoAccessPage on unknown workspace slug, hold null during active removal

Layouts render NoAccessPage when the URL slug doesn't resolve to an
accessible workspace — except when the slug previously resolved during
this layout instance's lifetime.

URL and cache are two asynchronous signals: there will always be a
short window where the URL still points at the old workspace but the
cache has already been invalidated (e.g. just after a delete/leave
mutation, or a realtime workspace:deleted event). Rendering
NoAccessPage during that window would flash "Workspace not available"
with recovery buttons in front of a user who just deleted the
workspace themselves — jarring and wrong.

useWorkspaceSeen classifies the two cases:
 - slug was seen before, now gone → user's intent is changing (caller
   is navigating away); render null, no flash
 - slug never seen → user is genuinely looking at an inaccessible
   workspace (stale bookmark, revoked access, link from a former
   teammate); render NoAccessPage with recovery options

NoAccessPage deliberately does not distinguish 404 vs 403 to avoid
letting attackers enumerate workspace slugs.

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

* refactor: redirect zero-workspace users to /new-workspace instead of /onboarding

Switches 8 call sites and the CLI:
- Web: login, auth callback, landing redirect-if-authenticated
- Desktop: routes.tsx IndexRedirect
- Shared: dashboard guard, invite page fallback, workspace-tab on delete,
  realtime sync on workspace loss
- CLI: cmd_login.go waitForOnboarding now opens /new-workspace

Also adds /new-workspace to navigation store's lastPath exclusion list
so it doesn't get persisted as a 'last visited' page.

Adds a desktop App.tsx effect that restarts the daemon when workspace
count transitions 0 → ≥1, so first-workspace creation triggers
immediate daemon pickup rather than waiting up to 30s for the daemon's
workspaceSyncLoop.

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

* refactor: remove onboarding flow

The 4-step onboarding wizard (workspace → runtime → agent → demo issues)
is replaced by:
- /new-workspace: a single-page workspace creation form (Phase 3)
- NoAccessPage: explicit feedback when a slug doesn't resolve (Phase 4)
- daemon zero-workspace bootstrap (Phase 1) so the daemon doesn't
  crash before the user creates their first workspace
- desktop daemon restart on first workspace creation (Phase 5) for
  instant pickup instead of the 30s workspaceSyncLoop tick

Deletions:
- packages/views/onboarding/ (OnboardingWizard + 4 step components + tests)
- apps/web/app/(auth)/onboarding/page.tsx
- apps/desktop/src/renderer/src/components/onboarding-gate.tsx (+test)
- OnboardingGate wrapper in desktop-layout.tsx
- OnboardingRoute + /onboarding route in desktop routes.tsx
- paths.onboarding() builder + /onboarding from GLOBAL_PREFIXES
- packages/views/package.json onboarding export
- /onboarding from navigation store's EXCLUDED_PREFIXES

Retained (intentional):
- 'onboarding' in RESERVED_SLUGS (both FE + BE) — kept for FE/BE sync
  and future-proofing if /onboarding is ever revived

Also drops 4 demo issues that onboarding used to create on the new
workspace ('Say hello', 'Set up repo', etc.). New workspaces are now
fully empty; all list views already render empty-state UI correctly.

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

* chore: clean stale 'onboarding' references in comments and CLI helpers

Batch cleanup of references to the removed onboarding flow:
- 13 comment sites mentioning 'onboarding' updated to reflect the
  new /new-workspace flow or removed where no longer accurate
- CLI waitForOnboarding renamed to waitForWorkspaceCreation (function
  name + docstring); behavior unchanged

The 'onboarding' reserved slug entries (frontend + backend) are
intentionally retained — see prior commit rationale.

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

* refactor(views): extract shared NewWorkspacePage shell

The web (/new-workspace) and desktop (NewWorkspaceRoute) pages had
identical outer layout — same container, heading, and copy — with only
the onSuccess navigation primitive differing. That's exactly the
No-Duplication Rule pattern: extract the shared UI, inject the
platform-specific behavior.

The apps now only own the thin auth guard (web needs it, desktop
routes below WorkspaceRouteLayout already handle it) and the
onSuccess → navigate call.

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

* refactor: remove rollback compat layer and tighten daemon restart trigger

Two cleanup items:

1. Drop localStorage['multica_workspace_id'] double-write in both
   workspace layouts. That write was added as a rollback safety net
   for the workspace-slug URL refactor (PR #1138) — the refactor has
   since landed and stabilized, so the compat shim is no longer
   needed. Per CLAUDE.md: don't keep compat layers beyond their
   purpose.

2. Tighten the desktop daemon-restart trigger. The previous ref-based
   logic fired a restart on any 0→1 workspace-count transition,
   including account switches (user A logout → user B login). Scope
   it precisely to 'this session started with zero workspaces and
   just gained one' using a three-state ref (null=undecided,
   true=empty-start, false=already-restarted-or-started-nonempty).
   Account switches are already handled by daemon-manager.ts on
   token change, so this avoids a redundant restart there.

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

* fix(auth): redirect to /login on logout and unauthenticated workspace visits

Two gaps previously left users stuck on blank workspace pages:

1. app-sidebar logout() cleared all state but never moved the URL. The
   current path is /{workspaceSlug}/... which has no meaning without
   auth; the workspace layout would then see user=null, render null
   (via the hasBeenSeen short-circuit), and the user saw a blank page
   thinking logout didn't work.

2. The workspace layouts (web + desktop) had no !user handling at all.
   Any path that leaves user=null — token expiration, cross-tab logout,
   or fresh visit to a workspace URL without a session — resulted in
   the same blank screen.

Fix:
- app-sidebar.logout() explicitly push(paths.login()) after authLogout()
  to cover the primary (user-initiated) logout path.
- Both workspace layouts get a defensive useEffect that redirects to
  /login whenever auth has settled and user is null. Covers token
  expiration, realtime logout, and any other silent session loss.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:18:43 +08:00
Bohan Jiang
a36252ca99 refactor(runtime): derive runtime usage from task_usage only (#1167)
* refactor(runtime): derive runtime usage from task_usage only

The daemon used to scan each runtime's local CLI log directory every 5
minutes (Claude Code, Codex, OpenCode, OpenClaw, Hermes) and post daily
aggregates to /api/daemon/runtimes/{id}/usage. Those directories are
shared with the user's own local CLI sessions, so the user's personal
usage was being counted as Daemon-executed usage. Cursor and Gemini had
no scanner at all, so their runtime-level aggregates were always zero.

Switch GetRuntimeUsage to aggregate task_usage (already scoped to
Daemon-executed tasks) via agent_task_queue.runtime_id. Single source of
truth; Cursor/Gemini/Copilot get runtime usage for free; no reliance on
external CLI log formats.

Removes:
- server/internal/daemon/usage/ (all scanners)
- Daemon.usageScanLoop + providerToRuntimeMap
- Client.ReportUsage
- ReportRuntimeUsage handler + POST /api/daemon/runtimes/{id}/usage
- UpsertRuntimeUsage / GetRuntimeUsageSummary queries
- runtime_usage table (migration 046)

Refs: MUL-786

* fix(runtime): bucket daily usage by task_usage.created_at, not enqueue time

ListRuntimeUsage was aggregating by DATE(atq.created_at) and filtering
on atq.created_at. agent_task_queue.created_at is the enqueue timestamp,
which drifts from actual token-production time: a task queued at 23:58
and executed at 00:05 was attributed to yesterday; a task sitting in
the queue overnight was counted on the queue day.

The ?days=N cutoff also became a rolling window (now() - N) instead of
a calendar-day boundary, silently clipping the morning of the earliest
day returned.

Switch bucket + filter to task_usage.created_at (~= task completion /
usage-report time) and snap the since cutoff to start-of-day via
DATE_TRUNC.

Add a regression test covering both scenarios: cross-midnight task
attributes to the day tokens were reported, and the earliest day's
pre-cutoff rows are still included.
2026-04-16 18:54:12 +08:00
LinYushen
cd50c31201 feat(agent): add GitHub Copilot CLI backend (#1157)
* feat(agent): add GitHub Copilot CLI backend

Integrate Copilot CLI as a new agent backend using the stable
`-p` JSONL mode (`--output-format json`), following the same
spawn-CLI-scan-JSONL pattern established by claude.go.

Backend (server/pkg/agent/copilot.go):
- Spawn `copilot -p <prompt> --output-format json --allow-all-tools --no-ask-user`
- Parse streaming JSONL events (system/assistant/user/result/log)
- Extract session ID for resume support (`--resume <id>`)
- Accumulate per-model token usage for billing
- Filter blocked args to prevent protocol-critical flag overrides

Daemon config:
- Probe MULTICA_COPILOT_PATH / MULTICA_COPILOT_MODEL env vars
- Copilot uses AGENTS.md (native discovery) and default skills path

Frontend:
- Add Copilot logo SVG and provider switch case

Tests: 14 unit tests covering arg building, event parsing, usage
accumulation, and edge cases. All Go + TS checks pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): add restart subcommand, make daemon uses it

- `daemon start` keeps original behavior: errors if already running
- `daemon restart` stops existing daemon then starts fresh
- `make daemon` now runs `daemon restart --profile local`

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): address review nits 1-5

- Nit 1: Add MinVersions["copilot"] = "1.0.0"
- Nit 2: Seed activeModel from session.start.data.selectedModel (falls
  back to opts.Model, then "copilot"). First-turn tokens now get correct
  model attribution.
- Nit 3: Handle assistant.reasoning/reasoning_delta → MessageThinking,
  reasoningText in assistant.message → MessageThinking,
  session.warning → MessageLog{warn}
- Nit 4: Extract handleCopilotEvent() method shared by production and
  tests — no more duplicated switch body that can drift
- Nit 5: Deltas write to output buffer as defense-in-depth; if process
  dies before assistant.message, output is non-empty

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 17:14:56 +08:00
devv-eve
c0b4e7e8b8 feat(agent): add Cursor Agent CLI runtime support (#1057)
* feat(agent): add Cursor Agent CLI runtime support

Add cursor-agent as a new agent backend, following the same pattern as
existing providers. The implementation spawns cursor-agent CLI with
stream-json output, parses JSONL events into the unified Message type,
and supports session resume, usage tracking, and auto-approval (--yolo).

Changes:
- server/pkg/agent/cursor.go: cursorBackend implementation
- server/pkg/agent/cursor_test.go: unit tests for args, parsing, errors
- server/pkg/agent/agent.go: register "cursor" in New() factory
- server/internal/daemon/config.go: probe cursor-agent in PATH
- server/internal/daemon/execenv/context.go: cursor skill discovery path
- server/internal/daemon/execenv/runtime_config.go: AGENTS.md injection
- packages/views/.../provider-logo.tsx: cursor logo in UI

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

* fix(agent): address PR review for cursor backend

1. Fix token usage double-counting: usage is now taken exclusively from
   "result" events (session totals). Per-message usage in "assistant"
   events is intentionally ignored. "step_finish" usage is only used as
   fallback when no "result" usage is available.

2. Remove dead code: isCursorUnknownSessionError() and its regex were
   defined but never called. Removed along with corresponding test.

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

* fix(agent): add missing CustomArgs, SystemPrompt, MaxTurns, and debug logging to cursor backend

- Add cursorBlockedArgs and filterCustomArgs support for safe custom arg passthrough
- Add --system-prompt and --max-turns flag support to buildCursorArgs
- Add debug logging of command args before execution (consistent with all other backends)
- Move stdout-close goroutine inside main goroutine (consistent with claude.go pattern)
- Add tests for SystemPrompt/MaxTurns and CustomArgs filtering

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: make daemon uses local profile & update Cursor logo to official brand

- Makefile: make daemon now runs 'daemon start --profile local' for local dev
- Replace Cursor runtime logo with official brand SVG (removed background rect)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(agent): remove unsupported --system-prompt and --max-turns from cursor-agent

cursor-agent CLI does not support these flags. Instructions are already
injected via AGENTS.md and .cursor/skills/ files.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(agent): prevent step_finish + result usage double-counting in cursor

Split usage accumulation into separate stepUsage and resultUsage maps.
After stream ends, use resultUsage if available (session totals from
result event), otherwise fall back to stepUsage (sum of step_finish).
This prevents 2x counting when result.usage already includes totals.

Added table-driven test covering: result-only, step_finish-only,
step_finish+result (no double count), and multi-model scenarios.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(agent): fix misleading comment on cursor -p flag

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 15:54:21 +08:00
Bohan Jiang
8c518c350a feat(agent): add Pi agent runtime support (#1064)
* feat(agent): add Pi agent runtime support

Add Pi as a new agent runtime provider, following the established adapter
pattern. Pi CLI outputs JSONL events which are parsed for messages, tool
calls, and usage tracking.

Backend:
- New piBackend implementing the Backend interface (pi.go)
- Pi CLI discovery via MULTICA_PI_PATH env var or PATH lookup
- JSONL event stream parsing (agent_start, message_update, thinking_update,
  tool_execution_start/end, agent_end)
- Usage scanner for ~/.pi/sessions/*.jsonl files
- Runtime config injection via AGENTS.md
- Skill injection to .pi/agent/skills/

Frontend:
- Pi provider logo (teal π icon)
- Pi label in transcript dialog

Docs:
- Updated all provider lists in README, CLI_INSTALL, and docs

* fix(agent): filter Pi usage scanner to agent_end events only

Address review feedback: restrict usage parsing to agent_end events
which contain cumulative totals, preventing potential inaccuracy if
Pi adds usage fields to other event types in the future.

* fix(agent): align Pi runtime with real CLI flags, event schema, and custom_args

- Flags: Pi's CLI uses `--mode json` (not `--output-format jsonl`), has no
  `--yolo` (explicit `--tools` allowlist instead), takes the prompt as a
  positional argument (not `-p <prompt>`), splits model as
  `--provider <name> --model <id>`, and treats `--session` as a file path
  that must exist before spawn.
- Event parsing: rewrite the stream event struct to match Pi's actual
  JSON event schema (`message_update.assistantMessageEvent.delta`,
  `turn_end.message.usage.{input,output,cacheRead,cacheWrite}`, etc.).
- Sessions: generate/persist session files under ~/.multica/pi-sessions/
  and use the file path as the opaque SessionID returned to the daemon.
- Usage scanner: read assistant `message` events from the same session
  files (Pi's session-file schema, distinct from the stdout stream).
- Custom args: consume `ExecOptions.CustomArgs` via `filterCustomArgs`
  with a Pi-specific blocked set (`-p`, `--print`, `--mode`, `--session`)
  so Pi matches the pattern shared by every other agent backend.
2026-04-16 15:42:40 +08:00
Bohan Jiang
df920e8641 fix(daemon): normalize repo URL and clarify reposVersion intent (#1090)
- TrimSpace incoming repoURL in ensureRepoReady to prevent unnecessary
  server refreshes when CLI passes URLs with whitespace
- Add comment on reposVersion field clarifying it is stored for future
  version-based skip optimization
- Add concurrency safety comment on syncWorkspacesFromAPI skip logic
- Add test for URL trimming fast-path behavior
2026-04-15 19:14:26 +08:00
Black
0427fd8cc7 fix(daemon): refresh workspace repos on checkout miss (#1085)
Co-authored-by: black-fe <black-fe@gate.me>
2026-04-15 19:10:54 +08:00
Bohan Jiang
ce447c7f06 feat(agent): add custom CLI arguments support (#986)
* feat(agent): add custom CLI arguments support

Allow users to configure custom CLI arguments per agent that get
appended to the agent subprocess command at launch time. This enables
use cases like specifying different models (--model o3), max turns,
or other provider-specific flags without needing separate runtimes.

Changes:
- Add custom_args JSONB column to agent table (migration 041)
- Update API handler to accept/return custom_args in create/update
- Pass custom_args through claim endpoint to daemon
- Append custom_args to CLI commands for all agent backends
- Add ExecOptions.CustomArgs field in agent package
- Add Custom Args tab in agent detail UI
- Add --custom-args flag to CLI agent create/update commands

Closes MUL-802

* fix(agent): filter protocol-critical flags from custom_args

Add per-backend filtering of custom_args to prevent users from
accidentally overriding flags that the daemon hardcodes for its
communication protocol (e.g. --output-format, --input-format,
--permission-mode for Claude).

This follows the same pattern as custom_env's isBlockedEnvKey: we
only block the small, stable set of flags that would break the
daemon↔agent protocol — not every possible dangerous flag. Workspace
members are trusted for everything else.

Each backend defines its own blocked set:
- Claude: -p, --output-format, --input-format, --permission-mode
- Gemini: -p, --yolo, -o
- Codex: --listen
- OpenCode: --format
- OpenClaw: --local, --json, --session-id, --message
- Hermes: none (ACP is positional)

Includes unit tests for the filtering logic.

* fix(agent): address code review nits for custom_args

- Replace module-level `nextArgId` counter with `crypto.randomUUID()`
  in custom-args-tab.tsx to avoid SSR ID conflicts
- Add unit tests for custom args passthrough and blocked-arg filtering
  in both Claude and Gemini arg builders
2026-04-15 14:58:53 +08:00
Naiyuan Qing
08c3513eef fix(cli): add pagination metadata to issue list JSON output and update agent prompt
Issue list JSON now includes total, limit, offset, has_more fields so agents
can detect truncated results and paginate. Also documents --limit/--offset in
the agent prompt and emphasizes mention format in Output section.

Closes MUL-837

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:51:08 +08:00
marcel
287a9eb546 fix(repocache): pass explicit env to remote-facing git subprocesses (#1029)
fix(repocache): pass explicit env to remote-facing git subprocesses
2026-04-15 13:15:36 +08:00