1533 Commits

Author SHA1 Message Date
liuguiyuan3
6890812ae4 MUL-5657 fix(agent): add missing streamingCurrentTurn gate to kimi ACP backend (#6308)
kimi.go was the only ACP backend missing the streamingCurrentTurn gate.
Without it, history replay emitted by the Kimi CLI during session/resume
contaminates Result.Output and the message stream with previous-turn
content — the user sees the old answer duplicated alongside the new one.

The root cause is chronological: the gate was introduced for Hermes in
PR #2024 (2026-05-03) but kimi already existed at that point and was not
updated. Later backends (grok, traecli) were written after the fix and
included the gate from day one.

Add the same atomic.Bool gate + acceptNotification callback pattern used
by hermes, grok, traecli, kiro, and qoder. Pin with
TestKimiBackendDropsHistoryReplayOnResume.
2026-08-03 17:59:16 +08:00
Multica Eve
4fe94a6d40 revert: "MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)" (#6314)
This reverts commit aa349fed02.

Preflight flagged the server/daemon mixed-version gate as a release blocker for
today's v0.4.17 window (MUL-5655): a managed, non-inheriting mcp_config claimed
by a daemon that does not advertise authoritative-mcp-v1 fails the task with
mcp_config_daemon_outdated, and that failure is not auto-retryable. Reverting to
unblock the release; the fix should return once daemon capability coverage in
production is confirmed.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 17:43:03 +08:00
Multica Eve
a54662f30e fix(daemon): provision config-referenced Codex instruction files into task home (#6291)
* fix(daemon): provision config-referenced Codex instruction files into task home

A per-task CODEX_HOME copies the user's config.toml verbatim, so a
model_instructions_file reference survived the move while the file it
names did not. Codex resolves the relative value against CODEX_HOME —
now the task home — and failed loading its configuration before the task
prompt was ever delivered (#6271).

Generalize the existing model_catalog_json provisioning into a keyed
table of path-valued config keys and add model_instructions_file plus its
deprecated experimental_instructions_file alias. Semantics are unchanged
per key: absolute/~ values are left for Codex to read directly, relative
values must stay inside the task home, the copy refreshes on reuse, and a
missing source fails during environment preparation with a diagnostic
naming the key instead of an opaque os error 2 at thread/start.

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

* fix(daemon): root-scope task-home writes for config-referenced Codex files

Review must-fix: filepath.IsLocal only proves the config string has no
lexical '..'. A task home is reused, and the task that ran in it can
replace an intermediate directory of the copy destination with a symlink,
which made the daemon's next MkdirAll/Remove/copy follow that link and
delete or overwrite a file outside the task home.

Do the mkdir, stale-copy removal, and write through os.OpenRoot(codexHome)
so links leaving the task home are rejected (links staying inside it are
harmless), and refuse outright when codexHome itself is a symlink, since
OpenRoot would resolve it before confining anything below. The pre-existing
model_catalog_json path is covered by the same helper.

Also stop reporting every source stat failure as a missing file — a
permission or IO error now says so — and document the source-side symlink
policy plus the stale-copy contract.

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

* test(daemon): pin stale-copy contract when a referenced Codex file is repointed

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

* fix(daemon): bind codex-home root check to the opened handle

Review must-fix: checking the path with os.Lstat and then opening it with
os.OpenRoot leaves a window where the directory is swapped for a link to
somewhere else. OpenRoot resolves the path it is given, so the resulting
root is confined to the wrong tree and every root-scoped write lands
outside the task home without ever escaping its root. Task homes are
reused and Windows cannot confirm descendant cleanup, so a leftover
process that knows its old CODEX_HOME can create that window.

Open first, then prove identity against the handle: compare root.Stat(".")
with a no-follow os.Lstat of codexHome via os.SameFile, and reject a
symlink outright. A swap before the open now fails the check; a swap after
it cannot matter because all writes go through the verified handle.

verifyCodexHomeRoot is split out so the swap is tested deterministically
instead of raced, plus an end-to-end test for a task home that is already
a link to an outside directory.

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

* docs(daemon): scope the codex-home symlink test name and root-handle claim

Review nit: the end-to-end test asserted only that the referenced-file copy
refuses a symlinked task home, but its name read as though the whole
prepare were safe. Rename it accordingly and state in both the test and
openVerifiedCodexHomeRoot that the earlier path-addressed steps of
prepareCodexHomeWithOpts are out of scope, tracked in MUL-5647.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 16:23:43 +08:00
Multica Eve
aa349fed02 MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)
* fix(mcp): treat agent mcp_config as an authoritative allowlist

An agent's saved mcp_config was silently widened with the runtime host's
own user-level MCP servers, so an explicitly empty `{"mcpServers":{}}`
resolved to the COMPLETE host set instead of no servers at all — the
opposite of what the operator configured (GitHub #6283).

`--strict-mcp-config` was being passed correctly; the merge happened
before it, in the daemon, so strict mode constrained an already-widened
set. Introduced by #5277 and present in v0.4.16 through main.

Restore the three-state contract in resolveEffectiveMcpConfig:

  null / unset        -> inherit the provider's native MCP configuration
  {"mcpServers":{}}   -> strict empty, no host servers
  non-empty object    -> strict allowlist, exactly those servers

Two explicit inherit paths keep the additive behaviour reachable without
weakening the default:

- runtime_config.mcp.inherit_runtime = true opts an agent back in.
- The claim response now carries mcp_config_overlay_only so the daemon
  can tell an agent-authored config from a per-task Composio overlay.
  Without it, enabling an integration on an agent that never configured
  MCP would have stripped the host servers it was already inheriting.

Both decode paths fail closed: malformed runtime_config never enables
inheritance, and a failed runtime merge falls back to the agent's own
config.

The web MCP tab and the `agent create/update --mcp-config` help text
described the old additive behaviour, which is how a tightened config
could look correct while exposing every host server; both now state
which mode is in effect.

Note for rollout: the fix lives in the daemon, so self-hosted users must
upgrade the daemon — a server/UI upgrade alone does not apply it.

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

* fix(mcp): close review gaps in the authoritative mcp_config change

Addresses the four must-fix findings from review of #6292.

1. Deleting the last managed server no longer widens access.
   removeManagedMcpServer cleared the config to null, which now means
   "inherit the host's MCP servers" — so a delete took the agent from one
   allowed server to every server on the host. It now leaves an explicit
   `{"mcpServers":{}}`. Restoring inheritance moved to a separate
   clearManagedMcpConfig action behind its own confirmation that states the
   widening. The delete dialog no longer claims "Runtime servers are not
   affected", which was the opposite of the truth.

2. The UI no longer promises a boundary an old daemon does not enforce.
   The strict semantics live in the daemon, so a config saved against an
   older daemon is not yet in effect. Adds the authoritative-mcp-v1 daemon
   capability:

   - The daemon advertises it and reports authoritative_mcp on the
     runtime-capabilities response.
   - The claim path fails closed: a managed, non-inheriting mcp_config
     claimed by a daemon without the capability cancels the task and
     returns 412 with an actionable message, instead of letting that daemon
     merge the host's servers in. runtime_config.mcp.inherit_runtime is the
     documented escape hatch, and it is honest — it declares that the
     operator accepts the host's servers.
   - The MCP tab shows "needs upgrade" rather than "Not exposed" while the
     bound runtime lacks the capability.

3. Saving OpenClaw settings no longer drops the inherit opt-in.
   parseOpenclawRuntimeConfig discarded unknown keys and the tab persisted
   the result as the whole runtime_config, so one unrelated routing save
   silently deleted mcp.inherit_runtime. Unknown keys now round-trip
   through OpenclawRuntimeConfig.passthrough, excluded from the dirty check
   so they cannot make the form look edited.

4. Documents the new semantics in the built-in creating-agents skill and
   its source map: the three states, the persisted
   runtime_config.mcp.inherit_runtime field, and the claim-time capability
   gate.

Also corrects the PR's rollout claim: there is no database migration, but
this does add a persisted JSON field and change the meaning of an existing
one.

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

* fix(mcp): stop the authoritative-daemon gate from blocking valid claims

CI's backend job failed three handler claim tests with the new 412. Two
distinct problems, both real:

1. The gate fired for a non-object mcp_config. 66 handler fixtures seed
   `[]`, which is not a valid MCP config and cannot carry `mcpServers`, so
   it expresses no boundary to protect. An old daemon does not widen it
   either: mergeRuntimeAndAgentMcpConfig fails to unmarshal a non-object
   and falls back to the agent config alone (verified directly). Gating
   these blocked tasks with no security benefit, so the gate now requires a
   JSON object.

2. The shared daemon test-request helper advertised no capabilities, so
   every claim test was accidentally simulating a pre-#6283 daemon. It now
   defaults authoritative-mcp-v1 on, matching what every current daemon
   sends. Only that capability — skill-bundles / coalesced-comments / rpc
   are feature negotiations whose absence tests real legacy behaviour, so
   they stay opt-in per test.

Adds claim-level coverage for the gate itself, which is what the unit tests
alone could not catch: an outdated daemon gets 412 with an actionable
message and the task is cancelled; a capability-advertising daemon gets
200; the inherit_runtime opt-in lets an outdated daemon through; and an
unmanaged or non-object config is never gated.

Verified against a real migrated schema this time (throwaway Postgres),
which is how the three failures were reproduced locally and confirmed
fixed: `go test ./internal/handler ./internal/daemon` both ok.

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

* fix(mcp): surface the daemon-upgrade refusal and stop gating safe providers

Addresses the second review round on #6292.

1. The refusal is now visible wherever the operator looks. The default
   claim path is the machine-level BATCH endpoint, which skips build
   failures and still answers 200 {"tasks":[]}, so the previous bare
   CancelTask showed a task that vanished with no stated reason — turning an
   explicit upgrade requirement into an unexplained failure. The claim path
   now fails the task with a new classified reason,
   mcp_config_daemon_outdated, plus the actionable message. That reaches the
   user on all three claim paths and on any daemon version, which a new
   response field could not: the audience is by definition a daemon too old
   to read one. The per-runtime path keeps its 412.

   The reason is deliberately not auto-retryable — the same outdated daemon
   would claim the retry and fail it again.

2. The gate no longer cancels safe tasks. It applied to every provider, but
   only claude / codebuddy / codex / cursor / opencode / openclaw were ever
   merged with host MCP by an old daemon (loadRuntimeMcpServerConfigs).
   Qwen was never merged and already had strict semantics, so its tasks were
   being failed for a risk that does not exist. Scoped via
   providersOldDaemonsMergedRuntimeMcp; an unknown provider does not gate,
   because the gate should only fire where the old behaviour is concrete.

3. The new authoritative_mcp flag now goes through the API schema layer.
   Both local-skills responses were returning raw network JSON, so the flag
   that decides whether the UI may assert an MCP boundary rested on an
   unchecked type assertion. Adds RuntimeLocalSkillListRequestSchema with
   authoritative_mcp and mcp_supported defaulting to FALSE — the fail-closed
   direction — and a MALFORMED_ fallback that cannot express a guarantee.

Claim-level tests now cover all three paths, which is what the previous
helper-only tests missed: per-runtime 412, batch recording the refusal on
the task while still delivering the healthy tasks in the same batch, WS RPC
refusing and accepting, the qwen negative case, the inherit_runtime escape
hatch, and unmanaged / non-object configs.

Verified against a real migrated schema (throwaway Postgres):
go test ./internal/handler ./internal/daemon ./pkg/agent ./pkg/taskfailure
./internal/service all ok.

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

* fix(mcp): register the new failure reason and wire its copy into the UI

Addresses the third review round on #6292.

1. mcp_config_daemon_outdated was declared but never registered in
   taskfailure.allReasons, so metrics.NormalizeFailureReason missed the
   known-value map and fell through to free-text Classify() — relabelling a
   platform-side refusal as `agent_error.unknown` (verified directly) and
   leaving the Prometheus series un-pre-warmed. Registered it, canonical
   count 22 → 23 (platform 8 → 9), with the wire value and IsAgentError split
   pinned. New test pins the WHOLE canonical set through
   NormalizeFailureReason so forgetting the next reason fails a test instead
   of quietly mislabelling a metric; NormalizeFailureReason had no coverage
   at all before.

2. The upgrade copy was dead. The locale strings landed last round but
   neither consumer mapped the reason: chatFailureCopy fell back to generic
   failure text with the actionable detail buried in the collapsed raw
   error, and task-failure.ts rendered the bare wire value
   `mcp_config_daemon_outdated` in the agent activity list and issue
   execution log. Both are mapped now, with regression tests, plus the
   runtime class pinned in failure-class.test.ts. This directly contradicted
   the claim in the claim-path comment that every path reaches the user, so
   that is now actually true.

3. providersOldDaemonsMergedRuntimeMcp is documented as what it is: a FROZEN
   record of what pre-capability daemons merged, not a mirror of the daemon's
   current provider switch. The old "keep the two lists in lockstep" note was
   actively harmful advice — runtime MCP discovery for a new provider can only
   ship in a daemon that already advertises the capability (never gated), so
   adding it here would fail tasks on old daemons that never merged for it,
   re-creating the qwen false-positive. Pinned with a test.

Also corrects a stale count in task-failure.ts (7 → 9 platform reasons).

Verified against a real migrated schema (throwaway Postgres): full backend
suite green apart from the pre-existing environmental cmd/multica guard; all
9 TestMcpGate_* integration tests pass.

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

* docs(taskfailure): correct taxonomy counts and finish the reason registration

Non-blocking nits from the fourth review round on #6292.

- Taxonomy counts now say 23 reasons / 9 platform-side. Registering
  mcp_config_daemon_outdated last round updated the assertions but not the
  prose. Swept the whole repo rather than only the flagged lines, which
  turned up four more that were already stale at 21 and drifted further:
  handler/dashboard.go, daemon/poisoned.go, core/types/agent.ts, and the
  db/queries/task_usage.sql comment sqlc copies into the generated file.

  The generated file's comment was updated by hand to match its source.
  Running `sqlc generate` churned 58 lines across 47 unrelated files — the
  local sqlc version differs from the one that produced the checked-in
  output — so that churn was reverted and only the one intended line kept.

- failure_test.go's `required` list now includes
  ReasonMcpConfigDaemonOutdated. Length and label assertions already covered
  the reason, but the list is documented as the complete canonical set, so
  the omission contradicted its own comment.

- Restored the line break in chat-message-list.test.tsx that a previous edit
  of mine collapsed.

Comment, test-fixture and formatting only; no behaviour change.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 16:06:07 +08:00
Naiyuan Qing
76fbd48849 fix(skills): measure draft dirtiness against a seeded baseline (MUL-5645) (#6294)
* fix(skills): measure draft dirtiness against a seeded baseline (MUL-5645)

The detail page inferred "the user edited something" from "the draft differs
from the latest server skill". That difference has two independent causes —
the user typed, or the server moved — and the page could not tell them apart,
so it read both as a local edit. Two user-visible failures came out of it:

- A description ending in whitespace (what `description: |` frontmatter
  yields, so every imported skill) compared unequal to itself, because the
  dirty check trimmed the draft side and not the server side. The page opened
  permanently dirty and Discard reseeded the same value, so the save bar could
  not be dismissed at all — only Save cleared it, by rewriting the stored
  description.
- Any remote update to an open skill was taken for a local edit, so the page
  raised the conflict banner and refused to reseed. The editor stayed frozen
  on pre-update text with no way to see what had changed, and saving from
  there pushed the stale draft back over the newer version.

Record the seeded snapshot in `baselineRef` and compare against that instead.
With a baseline the two causes separate: `draft !== baseline` is a local edit,
and a new `updated_at` with no local edits is just a remote update, which now
reseeds silently. The conflict banner is left for the case it was written for
— a remote update landing on real unsaved work.

`toDraft` also trims name and description at the single seam where server data
becomes a draft, matching what Save persists, so later comparisons are plain
equality rather than a trim both sides have to remember. Content and file
bodies are not normalized: whitespace in a SKILL.md body is content. File sets
are compared through a path-sorted signature, since GET sorts files by path
while PUT echoes request order and that difference is not a content change.

Four of the five regression tests fail against the previous implementation;
the fifth covers the true-conflict path, which was already correct and must
stay that way.

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

* fix(skills): trim frontmatter name and description at the parse seam (MUL-5645)

Both fields are single-line labels everywhere they are consumed, but YAML clip
chomping gives `description: |` and `description: >` a trailing newline, so
imports stored a description that differed from its own trimmed form. The
detail page no longer depends on this — it normalizes when it seeds a draft —
but leaving it means every new import keeps writing the padded value, and any
future consumer that compares a stored description to a trimmed one inherits
the same trap.

Trimming here covers all four import paths (GitHub, skills.sh, archive,
runtime-local) in one place rather than asking each to remember. Callers that
need the raw SKILL.md still have it: `content` is stored untouched.

Defensive only. Reverting this commit alone does not reintroduce the bug.

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

* fix(skills): release the conflict when the user reverts their own edits (MUL-5645)

Review catch on the previous commit. Once a conflict was raised, the seed
effect only reconsidered when a new server version arrived, so a user who
resolved the conflict by hand — retyping the field back to what it was — got
stuck: the draft was clean again, and because the save bar renders only while
dirty, its Discard button unmounted. That left the banner sitting above stale
text with no control left to dismiss it, and no way forward short of a reload.

This state is a regression from measuring dirtiness locally. Previously the
draft was compared against the moved server value, so reverting still counted
as dirty and Discard stayed on screen.

Re-run the decision on draft changes too. When the local edits go away there
is nothing left to protect, so the page adopts the server version and clears
the banner — the same outcome as the never-edited case, reached a moment later.
The true-conflict path is unchanged, and its regression test still passes,
which is what keeps this from over-correcting into "always release".

Reseeding also grew a third caller, so the four pieces that have to move
together — draft, baseline, seeded key, conflict flag — are now assigned in
exactly one place, `adoptServerVersion`, used by first load, silent refresh,
Save and Discard alike.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 15:49:52 +08:00
Wangjue Yao
69d3208586 fix(channels): handle /new in shared router (#6251)
* fix(channels): handle /new in shared router

* test(channels): cover /new provider reset end to end

* fix(channels): preserve command source across rewrites

---------

Co-authored-by: 无瑕 <yaowangjue.ywj@antgroup.com>
2026-08-03 15:40:33 +08:00
Bohan Jiang
0af0c6e27e docs(cli): tighten the issue search help text (#6300)
The help text added in #6293 is correct but runs 19 lines of prose, the
longest Long in cmd_issue.go. Its job is only to correct two wrong
expectations — that comments are not searched, and that an external
identifier implies a cross-tracker link — and each paragraph carried a
sentence that did not serve that job:

- the first paragraph stated the comment-body scope twice;
- the second explained, via LIKE-pattern set theory, that "412" also
  matches "1412" while "AGE-412" does not. That is reviewer-grade
  precision, not user-grade; simply not claiming the two forms are
  equivalent conveys it;
- the third explained the number-only fallback before saying the part
  that is actionable.

Down to 11 lines with no fact dropped: the fallback caveat is kept in
short form, since without it "strongest field that matched" reads as
wrong for a number-only hit.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 15:34:51 +08:00
Bohan Jiang
20d2dd8cef MUL-5620: fix(daemon): close the repo-eviction liveness window and stop GC walking internal caches (#6299)
* fix(daemon): close the repo-eviction liveness window and stop GC walking internal caches

Two review follow-ups from #6297.

1. The live-repo set was snapshotted once at the start of the .repos walk,
   then consulted per repo after running git and filesystem work on each
   one in turn. A workspace re-attaching a repo inside that window could
   see the cache evicted anyway. Replace the snapshot with a per-path
   query and ask twice: once as a cheap early-out so an attached repo
   never pays for the git work, and again immediately before RemoveAll.
   Re-reading in-memory state costs one mutex and no network, and shrinks
   the window from the whole walk to a few adjacent statements.

   This narrows the race rather than eliminating it — attachment updates
   workspaceState without taking the repo lock, so a sufficiently unlucky
   interleaving is still possible. It stays benign: a freshly attached
   repo has no last-used stamp and takes the backfill-and-skip path, and
   a wrong eviction costs one re-clone via ensureRepoReady.

2. runGC skipped only .repos, so it walked .skill-cache as if it were a
   workspace. Its "v1" directory then looked like a task dir with no
   .gc_meta.json and the orphan path deleted the entire bundle cache once
   its mtime went GCOrphanTTL without a new bundle — a few hundred KB
   reclaimed in exchange for a full re-download. Skip every dot-directory,
   matching what ScanDiskUsage already does; workspace directories are
   always UUIDs, so a dot-prefixed entry is one of our own caches.

   The skill cache has no lifecycle of its own, but it is measured in
   hundreds of KB, so leaving it unmanaged is clearly better than deleting
   it wholesale on an unrelated TTL. Giving it a real lifecycle is
   separate work if it ever grows.

Steve's third observation — repoCacheSize measuring any second-level
directory while eviction only handles isBareRepo ones — is left as is on
purpose: measuring more widely than we delete is the right asymmetry for
a visibility feature, so a corrupted leftover stays visible instead of
silently uncounted.

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

* fix(daemon): measure repo size before the final liveness check, not after

Review follow-up: the second liveness check was not actually adjacent to
the delete. dirSize walks every file in the bare repo and ran between the
check and RemoveAll, so on a multi-GiB cache — the size that motivated
this work in the first place — a workspace re-attaching during that walk
would still lose its cache.

Measure first, then check, then delete. That leaves only the window
between two adjacent statements, which is what the comment claimed all
along, and it keeps the slow filesystem walk outside the section the
check is meant to protect.

No behaviour change beyond the ordering: bytes_reclaimed still reports
the size measured just before deletion, which
TestEvictRepoCache_RemovesIdleDetachedRepo already asserts is non-zero.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 15:34:33 +08:00
LiaoyuanNing@TTC
091d6e7c23 MUL-5648: docs(cli): issue search searches comments too, and its number match ignores the prefix (#6293)
* docs(cli): say that `issue search` searches comments too

`multica issue search --help` claimed "Search issues by title or
description", but the server has always matched comment bodies as well
(`server/internal/handler/issue.go` WHERE clause + rank tiers 7/8) and
returns `match_source: "comment"` for those hits.

On a real workspace that is not an edge case: over a 20-query sample,
52% of hits came from comments vs 35% description and 13% title. Readers
who trusted the help text concluded that a decision recorded in a comment
thread was unfindable via search, and fell back to reading whole comment
histories instead.

Fix the one-liner and add a Long that also documents the `match_source`
value set (`title` / `description` / `comment`), including that `comment`
doubles as the fallback for a number-only match — so it reads as a
display hint rather than a filter.

Docs only; no behavior change.

* docs(cli): spell out that identifier-shaped queries ignore the prefix

`parseQueryNumber` accepts a bare number OR anything matching
`(?i)^[a-z]+-(\d+)$`, and never checks the prefix against this
workspace. So "MUL-412" and "ZZZ-412" both match local issue 412, and
because a number match is rank tier 0 it lands at the top of the results
labelled `comment` with an empty snippet.

That matters in practice: external tracker ids appear in our own docs
and in code comments, so pasting one into search returns a confident,
wrong top hit. The previous wording ("a numeric query also matches an
issue by its number") read as bare-digits-only and hid it.

Documents the existing behavior; the loose prefix match is plausibly
deliberate (paste an id from anywhere and still find something) and
tightening it is a product decision, not a bug fix. Still docs only.

* docs(cli): don't claim "AGE-412" and "412" are equivalent

Only the number match is the same for both forms. The text search still
uses the query as written, so a bare number matches a strictly wider set
of text than the identifier form ("412" hits "1412", "AGE-412" does not).

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

---------

Co-authored-by: Liaoyuan Ning <truetalents.lynn@gmail.com>
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 15:25:00 +08:00
Bohan Jiang
a735e4808d feat(daemon): account for and evict the bare repo cache (#6297)
The .repos bare-clone cache was excluded from disk-usage and never
reclaimed, so it grew monotonically and was invisible while doing it. On
the machine in #6265 it was 3.78 GB — 29% of the workspaces root, and
exactly the difference between what disk-usage reported (9.1 GiB) and
what the user's file manager showed (12.88 GiB).

Accounting: .repos is now measured and reported on its own line rather
than skipped. It stays out of the task totals on purpose — every task in
a workspace checks out from this one shared cache, so folding it into
per-task numbers would attribute it to directories that do not contain
it. Other daemon-internal dot-directories (.skill-cache) are no longer
counted as workspaces, which is what produced bogus rows like '.skillca'.

Eviction: a bare repo is removed only when all four hold — GCRepoTTL > 0,
no watched workspace still claims it, no worktrees remain, and no task
has created a worktree from it within the TTL (default 30d).

Two decisions worth calling out:

- The workspace check is a RETAIN predicate, not a delete predicate.
  Sync re-clones every listed repo that is missing whenever a workspace
  registers, which happens on every daemon start, so evicting a repo the
  workspace still claims just buys a full re-clone on the next restart —
  that moves disk cost, it does not reclaim it. Because the set only
  prevents deletion, a stale or empty one cannot widen what we delete.

- Idleness is an explicit stamp written by CreateWorktree, not directory
  mtime. Restarts re-fetch every cached repo, refreshing the mtime of
  repos no task has checked out in months; atime is unavailable in
  practice (noatime on Linux, off by default on Windows). A cache with no
  stamp reports unknown and gets its clock started, never treated as
  ancient — otherwise the first cycle after an upgrade would wipe every
  cache on the machine.

Evicting wrongly costs a re-clone, not a failure: the next task that
needs the repo takes the cache-miss path in ensureRepoReady.

Verified on a live runtime: both table views now show the .repos line
(242.8 MiB across 3 repos) and no longer list .skill-cache as a
workspace.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 15:16:16 +08:00
it235
eb14e73a20 MUL-5617: fix(storage): disable request checksum in buffered S3 Upload (#6261)
* fix(storage): disable request checksum in buffered S3 Upload

* test(storage): make the checksum-trailer regression test actually fail without the fix

The test built its client with s3.New(s3.Options{...}), which leaves
RequestChecksumCalculation unset. An unset value emits no checksum at all,
so the assertions passed with or without the fix in Upload(). Production
builds the client via config.LoadDefaultConfig, which resolves the field to
WhenSupported.

Pin that production default in the test client and serve over TLS, since the
SDK only switches to a trailing checksum when the request is HTTPS. Removing
the option from Upload() now fails buffered_upload on the exact
STREAMING-UNSIGNED-PAYLOAD-TRAILER value Aliyun OSS rejects, while
streaming_upload still passes.

Also gofmt s3.go: the added option callback used spaces instead of tabs.

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

* fix(storage): scope the checksum workaround to non-AWS endpoints

Downgrading RequestChecksumCalculation to WhenRequired is what makes Aliyun
OSS and Tencent COS accept the buffered upload, but it drops the client-side
checksum entirely rather than moving it out of the trailer: on TLS the request
goes out as UNSIGNED-PAYLOAD with no x-amz-checksum-* at all. Applying that to
every backend would change requests that work today, and AWS buckets with a
default Object Lock retention require a checksum to be present.

Gate the option on the endpoint instead. Real AWS S3 — no AWS_ENDPOINT_URL, or
an explicit amazonaws.com host — keeps whatever the SDK resolved, including any
operator-set AWS_REQUEST_CHECKSUM_CALCULATION. Only S3-compatible endpoints get
WhenRequired.

Adds TestS3StorageAWSUploadKeepsChecksumTrailer so the AWS path is pinned from
the other side: making the workaround unconditional now fails that test, while
removing it entirely still fails buffered_upload.

Also drops the incorrect claim that the seekable body stays covered by a real
SigV4 payload hash.

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

* fix(storage): match the AWS endpoint on the host label boundary

usesAWSEndpoint substring-matched "amazonaws.com" anywhere in the configured
endpoint, which got the decision wrong in both directions: an uppercase
"https://S3.US-EAST-1.AMAZONAWS.COM" was treated as third-party and lost its
checksum, while "https://notamazonaws.com", "https://s3.amazonaws.com.evil.net"
and any URL merely carrying the string in its path or query were treated as AWS
and never got the fix they needed.

Parse the endpoint and compare the hostname on the label boundary instead,
covering the China partition. Scheme-less values are retried as https, since
url.Parse otherwise reads the whole value as a path.

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

---------

Co-authored-by: renjianjun <renjianjun@angelalign.com>
Co-authored-by: Bohan-J <bohan.optimism@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 14:46:29 +08:00
Xichang(Seacen) Zhao
cb04226fd4 MUL-5632: fix(engine): broadcast the full issue payload for chat-created issues (#6278)
* fix(engine): broadcast the full issue payload for chat-created issues

An issue opened with /issue from a chat channel broadcast
{"issue_id": <uuid>} — the minimal fallback IssueService.Create emits
whenever a caller leaves IssueCreateOpts.BroadcastPayload nil, which the
engine did. Every issue:created consumer reads payload["issue"], so the
missing key meant extractIssueFields (cmd/server/subscriber_listeners.go)
failed its map assertion and returned false before it ever reached the
id / creator_id check. The listener returned early and no auto-subscribe
rule ran, so the person who typed /issue was never subscribed to the
issue they had just filed and got no notifications for it. Both channels
that register with the engine — Feishu/Lark and Slack — were affected.

The HTTP handler already supplies a BroadcastPayload and autopilot
publishes its own event through issueToMap; only the engine path was
short. Export issueToMap as IssueToMap and have the engine use it, so a
single builder is the one source of truth for that shape instead of
three descriptions drifting apart. The workspace issue prefix comes from
the GetWorkspace call the /issue path already made for the chat reply's
identifier, hoisted so it is read once and used for both.

No regression — the payload only gains keys, and consumers read named
fields. The activity and notification listeners type-assert
payload["issue"] to handler.IssueResponse and still skip a map, exactly
as they already do for autopilot-created issues. The subscriber listener
accepts either shape. The workspace WS fanout marshals the payload
as-is, so open clients now render a chat-created issue live instead of
waiting for a refetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(service): complete the issue:created payload contract

IssueToMap omitted project_id, stage, metadata and properties, all of
which handler.IssueResponse — the other rendering of the same event —
always emits. Clients type both as a complete Issue and insert the
object straight into the list cache without runtime validation, so an
issue created by autopilot, quick-create or the chat /issue command
appeared in open clients missing its project and custom properties
until the next refetch. Broadcasting the full issue from the chat path
would otherwise have spread that existing defect to a third entry
point.

Fill in the missing keys and pin the contract with a test that fails if
the two renderings ever drift apart again, so adding a field to
IssueResponse without adding it here is caught in CI rather than in the
UI. metadata and properties render as {} when unset, matching the
"always present" part of the contract.

Also route both identifier renderings through service.IssueIdentifier,
so a workspace lookup that degrades the prefix cannot show "#42" in the
chat reply while the realtime list shows "-42".

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs(service): name the actual IssueToMap call sites

The doc comment and the shape test listed quick-create among the
non-HTTP publishers of the issue payload. It is not one: the three call
sites are autopilot and the channel engine on issue:created, and the
background stuck-issue status reset on issue:updated.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 14:29:47 +08:00
Bohan Jiang
c037c134ad MUL-5620: fix(daemon): make disk-usage report what is actually on disk (#6270)
* fix(daemon): make disk-usage report what is actually on disk

Two defects made `multica daemon disk-usage` misreport, both of which
matter when a user is trying to work out why their workspaces root is
large (#6265).

1. The STATUS column was dead code. TaskDiskUsage.ParentStatus was
   declared and rendered but never assigned anywhere, so the column
   always printed "-" and parent_status was always "" in JSON. The GC
   is unaffected — it resolves status independently via
   gcDecisionIssueResult — but the one column that answers "which of
   these issues are still open?" was blank.

   ScanDiskUsage stays network-free; ResolveParentStatuses is an opt-in
   second pass wired into the CLI, backed by the same batch gc-check
   endpoint the GC loop uses, so the column reports exactly the status
   the GC would act on. The daemon already authenticates with the
   profile's CLI token, so this needs no new API surface. Best-effort:
   offline or logged out, the column stays blank and the command still
   works.

   Only issue-kind dirs are resolved — they dominate, and they are the
   only kind with a batch endpoint. Chat / autopilot-run / quick-create
   keep an empty status rather than costing one request each.

2. taskSize skipped .git entirely, so size_bytes under-reported every
   task dir holding a real git checkout. That disagreed with both the
   user's file manager and the GC itself: a full gcActionClean removes
   .git with the rest of the dir, and dirSize (which reports
   bytes_reclaimed there) counts it. Now counted wholesale into
   totalBytes, still never descended into, so artifact accounting stays
   aligned with cleanTaskArtifacts.

Verified against a live runtime: STATUS resolves (in_review on the
largest dirs, i.e. not done — which is exactly the question that
prompted this), and top dirs went from 104.9 MiB to 132.6 MiB reported,
closing most of the gap against du.

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

* fix(daemon): skip disk-usage status resolution when nothing renders it

Review follow-up on #6270.

The --by-workspace table has no STATUS column, but the CLI resolved
statuses unconditionally. With credentials on disk and an unreachable
server that cost a full timeout before printing a purely local report —
and --all-profiles paid it once per root, serially — then warned about a
column the output does not contain. Gate resolution on whether task rows
are actually rendered; JSON keeps resolving because it always carries the
task array.

Also correct two contract statements that the .git change had made
false: the command's Long help still claimed 'The walk skips .git', and
ScanDiskUsage's doc comment still claimed it never enters .git. Both now
state the real rule (counted toward the total, never toward the artifact
subset, symlinks still never followed), and the help now says STATUS
needs the network and is left blank when it cannot be reached.

Adds the CLI-level regression tests the wiring was missing: the
by-workspace table makes no request, the per-task table resolves and
renders the status, a failing server still exits 0 with valid JSON on
stdout, and --all-profiles resolves each root with its own profile's
token. Verified the no-request test fails when the gate is removed.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 13:35:53 +08:00
Bohan Jiang
d38da27ed4 MUL-5619: fix(cli): surface the server's 409 message instead of the generic conflict template (#6267)
* fix(cli): show the server's conflict message instead of the generic 409 template

Every 409 this API returns is a deterministic refusal that names its own fix
("a skill with this name already exists", "set parent_id (--parent) to <id>").
The CLI replaced all of them with a template that says the opposite — that the
state changed underneath you and you should re-fetch and retry. Agents took the
retry hint literally: GH #6264 reports 15+ identical retries over 10 minutes
followed by hours spent chasing an optimistic-concurrency theory that never
existed, and GH #5948 is a second user misdiagnosing the same way. MUL-4417 had
already written the useful message server-side; it just never reached anyone.

Route 409 through the same server-message extraction 400/422 already uses, so
roughly forty hand-written conflict messages across skills, agents, runtimes,
labels, projects and comments become visible by default. A body we cannot
recognize still falls back to the template, so this never dumps a raw response.

extractServerMessage now prefers prose over a bare identifier, because a few
endpoints put a stable code in "error" and the sentence in "message".

MUL-5619

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

* fix(comments): stop telling a wrong --parent that it posted a top-level comment

The reply guard returns one message for two different mistakes. A resumed
session that carries a previous turn's --parent forward (GH #6264) did not ask
for a top-level comment, but is told it did — which sends it looking for a
new-thread opt-in (GH #5383) instead of correcting the parent it already passed.

Split the copy: name the rejected parent when one was supplied, and keep the
existing top-level wording for the parentless case. Both still point at the
trigger comment to use.

MUL-5619

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

* fix(runtime): return 500, not a 409 echo, when the update store fails

InitiateUpdate answered every UpdateStore.Create failure with a 409 carrying
err.Error(). The in-memory store only ever returns errUpdateInProgress, so this
looked safe — but the Redis store also wraps infrastructure failures as
"reserve active update: <dial error>" and "persist update request: <error>".

Surfacing 409 bodies in the CLI turns that into a user-visible leak of internal
addresses, and labels an outage as a conflict the caller could fix by retrying.
Classify instead: errUpdateInProgress keeps its 409 and its actionable message,
everything else is logged and answered with a 500 and fixed copy.

Also pins the prose-over-machine-code preference for validation bodies, which
the shared extractor applies to 400/422 as well as 409. Only the issue-table
endpoints are shaped that way and none is reachable from the CLI today, but the
change is intentional and should fail loudly if reverted.

MUL-5619

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 13:35:04 +08:00
Multica Eve
b06af2ae17 feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them

Deleting a runtime archived its agents and then hard-deleted the rows, so the
agents and every conversation with them disappeared — while the confirmation
dialog said "archive", which a user reasonably reads as recoverable. Retiring a
laptop is an ordinary action; losing the agents configured on it is not an
ordinary consequence.

An agent is now a persistent business object and a runtime is replaceable
execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL`
means unbound — orthogonal to archived — and the agent keeps its instructions,
skills, chats, labels, channel installations, autopilots and task history.
service.AgentReadiness already refused an agent with no runtime, so the
scheduling safety gate needed no change.

Two columns become nullable, not one. Without `agent_task_queue.runtime_id`,
deleting the runtime still cascades the task history away (and task_message /
task_usage / task_token with it), so the agents would survive with no record of
anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined
to history: an active task must always have a runtime, so claim / dispatch /
delivery-CAS paths can never observe one without. It is written against
completed_at rather than a status list so a future non-terminal status fails
closed instead of slipping through.

Two prerequisites this depends on:

- 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent.
  It went unnoticed because the delete used to cascade those rows away; with the
  new CHECK it would abort the delete and make the runtime undeletable.
- The channel-installation / label / chat-pin / invocation-target / draft-restore
  cleanups were scoped to "archived agents on this runtime". Archived user agents
  now survive, so that scope is narrowed to kind='system' — otherwise the fix
  would produce a subtler loss: agent alive, configuration wiped.

Also removes the squad guard that refused (409) when an active squad's leader was
an archived agent on the runtime, plus the archived-squad delete that existed
only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted,
so nothing needs to be given up to retire a machine. Autopilots are no longer
paused either: their assignee survives, and a rebind restores them without the
owner having to remember to re-enable.

Reason codes: an unbound agent reports agent_runtime_required, not
runtime_offline. The copy for runtime_offline tells users to reconnect a machine;
an unbound agent has no machine to reconnect, and the fix is to bind a runtime.
Chat's bare 409 string gains the same code so the composer can offer that action.

API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so
installed clients keep parsing and no gated two-release rollout is needed. The
confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete
still routes to it, and the compared expected_active_agent_ids set is unchanged —
widening it would 409 every older client forever.

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

* fix: make runtime unbinding recoverable

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

* fix: address runtime unbind review nits

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

* fix: resolve runtime unbind review blockers

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

* fix(migrations): renumber runtime unbind after main merge

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

* test(daemon): avoid late-request lease flake

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

* test(autopilots): bind validation fixture runtime

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 12:39:27 +08:00
Xichang(Seacen) Zhao
829a4e5af9 fix(daemon): honour HTTP(S)_PROXY when dialing the wakeup WebSocket (#6279)
runTaskWakeupConnection built its dialer by hand:

    dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}

A zero-value Proxy field means "dial direct and ignore the
environment". websocket.DefaultDialer sets Proxy to
http.ProxyFromEnvironment; a dialer built this way gets nothing, and
gorilla skips the CONNECT wrapper entirely.

This is the daemon's control connection to the Multica server, so in
the SaaS deployment it dials out to the public internet. On a machine
whose only egress is a corporate proxy the handshake can never succeed.
Nothing points at the cause either: the loop logs "task wakeup
websocket unavailable; polling fallback remains active" at debug level
and never mentions a proxy. The daemon then runs permanently degraded —
task pickup waits for the HTTP poll (PollInterval, 30s by default)
instead of a server push, heartbeats stay on HTTP, and the WS-first
batch claim path (MUL-4257) falls back to HTTP on every task.

Set Proxy: http.ProxyFromEnvironment. gorilla rewrites wss:// to
https:// on the parsed URL before it calls Proxy, so HTTPS_PROXY — and
NO_PROXY — apply to this dial the same way they apply to every other
HTTPS client in the process. The lark connector fixed the same defect
the same way in #4165; this was the last bare dialer left in non-test
code.

No regression where no proxy is configured: ProxyFromEnvironment
returns a nil URL, gorilla leaves netDial untouched, and the dial is
byte-for-byte the direct dial it was before. Everything downstream of
the handshake — headers, heartbeat writer, RPC attach, teardown — is
unchanged.

The regression test drives the dial from a child process. net/http
resolves the proxy environment once per process and caches the result
(envProxyOnce), so by the time a test in this package runs, an earlier
test has already primed that cache with "no proxy" and t.Setenv can no
longer reach it. The child starts with a clean environment pointing at
a stub CONNECT proxy, and the parent asserts the CONNECT for the wss
target arrived. Without the fix the stub proxy sees nothing.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:00:28 +08:00
Multica Eve
33a2743f93 fix(llm): make quick actions GPT-5.6 compatible (MUL-5573) (#6243)
* fix(llm): use max completion token limits

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

* fix(llm): harden GPT-5.6 JSON generation

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 17:44:37 +08:00
Jiayuan Zhang
28b6105edc fix(subscribers): notify the human an agent files sub-issues for (MUL-5483) (#6209)
When an agent created a sub-issue while working on a human's behalf, that human
received no notifications for it at all. issue_subscriber modelled ACTOR
identity, so an agent-created, agent-assigned issue had a full subscriber list
and zero members to deliver to. The platform already knew who the work was for
(agent_task_queue.originator_user_id, MUL-4302); notification never asked.

- attribution.DelegatedSubscriber: one shared rule over the same origin
  waterfall ClassifyDirect uses. agent_create subscribes the originator as
  'delegated'; quick_create keeps the direct 'creator' tier; autopilot and
  degraded attribution subscribe nobody.
- Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus
  failures and mentions. Routine churn is suppressed, and the parent bubble
  cannot re-deliver what the tier dropped.
- Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later
  rule passes, and opt_out_scope distinguishes "this issue" from "this subtree"
  so a narrow opt-out no longer silently suppresses future children.
- Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly
  against an older backend (Go drops unknown fields); an unknown route 404s,
  which the UI now surfaces with a distinct message.
- Eligibility and the write share one statement under a (workspace, user)
  advisory lock that subtree unsubscribe and member revoke also take, closing
  the check-then-insert races. Revoke additionally clears the departing
  member's subscriptions in the same tx.
- UI explains a delegated subscription and offers both unsubscribe scopes.

Migrations 249/250 add the delegated reason, the opt-out tombstone, and the
opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does
not scan issue_subscriber under an exclusive lock.

Reviewed across eight rounds; an earlier write-time subtree roll-up was built
and then removed in full once it proved unfixable without serializing every
topology mutation. The parent's own status transition already carries that
signal.

Closes MUL-5483.
2026-07-31 16:52:17 +08:00
Bohan Jiang
9daa291d00 fix(migrations): resolve duplicate migration number (#6239)
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 16:23:24 +08:00
Jiayuan Zhang
d4ae220cc1 feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) (#6141)
* feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499)

A project has no `MUL-123`-style identifier — only a UUID and a free-text
title — so there is nothing for the bare-identifier autolink preprocessor to
detect, and the link copied out of the app is how people actually reference
one. It rendered as a raw URL.

RichLink now unfurls a bare in-app entity URL into the same chip the
`mention://project/<uuid>` form already produces (issue URLs go through the
same path for symmetry). Render-only: stored markdown is untouched, and the
editable Tiptap path is deliberately unaffected.

Three guards, each load-bearing: the link must be bare (an authored label is
never discarded), same-workspace (a chip resolves its title in the current
workspace only), and address exactly one entity page by UUID with no query or
fragment.

Also:
- mobile: tapping a `mention://project/` link navigated nowhere despite the
  `project/[id]` route existing — it now pushes the project detail.
- agents had no documented way to emit a clickable project reference: add the
  link form to the runtime brief's Mentions section and to the projects skill,
  and record in the mentioning skill why `project` sits outside `MentionRe`
  (render-only, enqueues nothing).

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

* fix(rich-content): unfurl issue URLs in identifier form

The unfurl required a UUID id, on the stated grounds that "every link the
app itself produces carries a UUID". That holds for a project but not for
an issue: `copyLink` and `openInNewTab` both build
`paths.issueDetail(issueIdentifier || issueId)`, and the issue route
rewrites a UUID URL back to the identifier — so `MUL-123` is the shape a
user actually copies, out of the app or out of the address bar. The issue
half of the feature could not fire on the links people paste, while bare
`MUL-123` prose did become a chip: the fuller reference lost to the
shorter one.

`parseWorkspaceEntityLink` now accepts an issue identifier as well as a
UUID. A project still requires a UUID — it has no shorthand, so an
identifier-shaped id under /projects/ addresses nothing.

An identifier needs a lookup, which means it can miss, and the miss has to
differ by entry point. `AutolinkedIssueMentionLink` degraded to plain text,
which is right for autolinked prose and wrong for a URL: the author wrote a
link, and an issue this workspace cannot see must not cost them the only
pointer to it. The fallback is now a prop — plain text for the autolink
path, the original anchor for a URL.

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

* fix(editor): stop drawing link chrome over mention chips

A mention chip already carries its own affordance — border, icon, hover
background — so the generic `.rich-text-editor a` color and underline draw a
second, competing one straight through the card. `.issue-mention` reset it;
`.project-mention` never did, so project chips shipped with a brand-coloured
underline through them. The rule belongs to the chip shape rather than to one
entity, so both selectors now share it and a future chip is one line.

The hover card had the same gap: it skipped `.issue-mention` only, so hovering
a project chip opened a URL card offering to copy `/{slug}/projects/{uuid}` —
an in-app path, not the shareable link that wording implies.

Both are pre-existing, but a bare project URL now renders as a chip, so what
used to surface on hand-written mentions alone shows up on ordinary pasted
links.

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

* fix(rich-content): decide in-app by resolving the URL, not by its prefix

`href.startsWith("/")` was standing in for "this deployment". It is not: a
browser reads `//other.example/x` and `/\other.example/x` as another host and
goes there, and both start with a slash. The parser skipped the origin check
for exactly the hrefs that most needed it.

Nothing shipped from this: an unfurled chip links to
`paths.projectDetail(uuid)`, so the href it was parsed from is discarded and a
misparse could not send anyone anywhere. The prefix test was still the wrong
instrument. Adding `&& !startsWith("//")` would have looked like a fix while
leaving the backslash spelling through — the gap is the technique, not the
case, so this resolves the href against the app origin with `URL` and compares
`origin`, which is one comparison for every spelling and for the schemes
(`javascript:`, `data:`) whose opaque origin can never match.

Relative and absolute now take the same path, so the slugless legacy form
parses identically whether or not it carries the origin — previously the
absolute spelling was rejected by a reserved-slug test meant for workspace
slugs, and the two disagreed.

`openLink` still tests the prefix, and its result IS navigated. That is a live
issue, older than this feature and wider than it; it needs its own change
rather than a quiet ride here.

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

* docs(skills): state what mobile actually does with a project mention

The projects skill told agents a `mention://project/<uuid>` link "renders as a
navigable project chip on web, desktop, and mobile", and that a pasted project
URL is unfurled into that same chip by "the reader's client". Neither holds on
mobile: `apps/mobile/lib/markdown/markdown.tsx` renders the default enriched
link and only routes the tap, and a bare URL still goes to `Linking.openURL`,
which leaves the app.

These files enter agent context and read as product contract, so an agent
choosing between a mention link and a pasted URL was choosing on false
information — and the URL is the option that strands a mobile reader in a
browser. Both skills and both source maps now say chip on web/desktop, ordinary
link that opens the project on tap on mobile, and unfurling as web/desktop only.

The projects skill also now states the preference outright rather than
presenting the two forms as equivalent.

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

* refactor(views): give a project mention the component an issue mention has

`IssueMentionCard` owns "chip inside a link" for issues; the project equivalent
lived inline in the readonly renderer, so nothing named the pairing and nothing
held the rules that come with being a link.

That cost was not hypothetical. Both gaps fixed a commit ago landed on project
mentions alone: `.project-mention` never got the CSS rule cancelling generic
link chrome, and the hover card never learned to skip it. Each was written for
`.issue-mention` at the component that owns it, and project had no such place
for the second half to be written. `ProjectMentionCard` is that place.

No behaviour change: same anchor, same href, same hover affordance, same
accessibility contract that project-mention-a11y.test.tsx pins. The "open in
new tab" preference stays out — it is scoped to issue links, and inheriting it
by symmetry would be inventing product.

Also drops `not-prose` from both cards. It has no definition anywhere in the
repo — Tailwind's typography plugin is not installed, and the class does not
appear in built CSS — so it read as protection that was not there.

The editor's `MentionView` keeps its hand-rolled anchors: it needs a
modifier-click intent hook `AppLink` does not expose, and it does the same for
issues, so the two stay symmetric there too.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:18:13 +08:00
Bohan Jiang
cd9b956269 fix(agent): spawn Copilot's native binary on Windows so the prompt survives (MUL-5586) (#6236)
On Windows the daemon passes the full multi-line prompt as `-p <prompt>` but
spawns npm's `copilot.cmd`, which we already rewrite to
`powershell -File copilot.ps1`. Neither launcher can carry that argument:

- `copilot.cmd` forwards with `%*`, which cmd.exe expands by re-tokenising
  the raw command line.
- `copilot.ps1` ends in `& node.exe npm-loader.js $args`, and PowerShell
  re-serialises `$args` onto node's command line. Under Windows PowerShell
  5.1 (and pwsh <= 7.2, which default to Legacy native argument passing)
  embedded double quotes are not re-escaped, so the prompt is re-tokenised.

Copilot then sees several argv tokens where one was intended and refuses the
run with "It looks like your prompt was not quoted, so the extra words were
treated as separate arguments" — the same defect class already fixed for
cursor-agent in #5649, except Copilot has no stdin prompt channel to escape
through, so the prompt must stay on the command line and the launchers have
to go.

Copilot CLI ships a native per-platform binary and `npm-loader.js` does
nothing but `spawnSync` it with argv untouched, so resolve
`copilot-win32-{x64,arm64}\copilot.exe` out of the npm layout and spawn it
directly. That leaves exactly one hop, Go -> native binary, and Go's
syscall.EscapeArg is the exact inverse of the CRT parsing that binary uses.
This mirrors resolveOpenCodeNativeFromShim / resolveDevecoNativeFromShim.

Both the nested (current npm) and hoisted (older npm) platform-package
locations are probed; when neither resolves, we keep falling back to the
PowerShell launcher, which is still better than cmd.exe.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 16:09:36 +08:00
Multica Eve
f48bd655bc MUL-5562: optimize application-owned workspace deletion (#6230)
* fix(workspace): optimize application-owned deletion

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

* fix(workspace): address deletion review findings

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 16:09:19 +08:00
Multica Eve
e4b6f7a31b MUL-5581: add Qoder CN CLI runtime (#6232)
* feat(agent): add Qoder CN CLI runtime

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

* fix(agent): address Qoder CN review nits

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

* fix(agent): defer Qoder CN version gate

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 16:01:53 +08:00
Jiayuan Zhang
f13969b996 refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573) (#6214)
* refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573)

Follow-up suggestions were produced by a second, full provider CLI invocation
per chat turn: the daemon resumed the just-finished session and ran a
suggestion-only pass. That pass inherited the main turn's exec options, so its
20s budget had to cover process spawn, every MCP handshake, session replay, and
model reasoning at the agent's own thinking level — typically 8-15s of visible
skeleton, and every turn paid two provider cold starts.

Generate them here instead, through the same pkg/llm layer that backs chat
auto-titling. Suggestions need no tools, workdir, or agent identity — only the
tail of the conversation — so a bounded 8s call on the deployment's small model
replaces the whole resumed turn.

Quality changes that came with the move:

  - The prompt now states the frame explicitly ("you write FOR THE USER"). The
    old pass ran inside the agent's session and inherited the runtime brief's
    identity, which drifted suggestions toward agent-operations actions.
  - Previously-offered labels are replayed as ALREADY SUGGESTED. The old
    architecture had the opposite effect: on providers that append on resume,
    each pass saw its predecessor's JSON and anchored on it.
  - A failed generation broadcasts failed=true. Before, a timeout delivered an
    empty array — indistinguishable from "nothing worth suggesting", so every
    slow pass read as a quality problem.
  - The in-band footer is still stripped from replies but its actions are now
    discarded, so a pre-upgrade session is not pinned to the retired
    suggestions with the replacing pass suppressed.

The refresh path no longer enqueues an agent task: it validates the target and
calls the same generator, which also drops the not-resumable refusal — a session
whose runtime was rebound can now be refreshed. Client contract is unchanged
(chat:done pending flag, chat:quick_actions supplement); the only frontend
change is the pending window, resized from 30s to 12s to match the new budget.

Also removes the daemon's TMPDIR-after-cleanup hazard by construction: the old
pass started after runTask's defers had already deleted the temp dir it was
still pointed at.

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

* refactor(chat): drop the quick-actions opt-out setting (MUL-5573)

Suggestions are always on. The Settings → Chat toggle is removed along with
the whole per-turn opt-out path it fed: the persisted client preference, the
quick_actions_enabled send field, the quick_actions_disabled task stamp, and
the eligibility gate that read it.

The toggle predates server-side generation, when it could only hide chips a
provider pass had already paid for. Now that generation is a bounded call the
server decides on, an off switch buys nothing a user would miss, and it was
the last piece of UI implying the feature might be unavailable.

agent_task_queue.quick_actions_disabled is no longer written (dropped from
CreateChatTask's INSERT; the column keeps its false default). Left in place
alongside regenerate_quick_actions_for for a later drop migration — removing
columns an already-running binary still inserts would break mid-deploy.

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

* fix(chat): address quick-actions review findings (MUL-5573)

Four defects from review of the server-side generation change.

1. Automatic failures were reported as refresh failures. The generator
   broadcast failed=true on any LLM error, but the client turns every
   failed=true into a "couldn't refresh" toast — so an automatic timeout
   popped a toast for an action the user never took. This also contradicted
   ChatQuickActionsPayload.Failed, which documents false for the automatic
   pass. The caller now passes its origin; only an explicit refresh reports.

2. Generation context was not bound to the target turn. The pass re-read the
   session's newest messages while always writing to the task it was handed,
   so a turn landing between the completion callback and the detached read
   supplied the context for a reply it did not belong to. Worse, a user
   typing a follow-up in the second after a reply left the window ending on
   a user row, which the old code treated as "nothing to build on" — that
   turn silently never got pills. The window is now anchored on the target
   assistant message and queried strictly before it.

3. No concurrency or idempotency bound on generation. Refresh stopped
   creating a task, so the busy check could not see a pass already running:
   two refreshes both returned 202, spent two upstream calls, and raced to
   write one row. Nothing bounded generation process-wide either. Adds a
   per-session in-flight guard (refresh now 409s on a duplicate) and a
   process-wide ceiling; a shed pass still resolves the client placeholder
   so no skeleton hangs on work that never started.

4. A new daemon could not safely talk to an older server. The refresh task
   discriminator was deleted, so a regenerate task from such a server fell
   through to the ordinary chat path: no user message, but the agent would
   answer anyway and the server would persist it as a real reply. The field
   is restored as a refusal marker only — the task completes empty, which is
   the shape the retired pass produced and which that server writes no row
   for. Not a restored execution path.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 15:59:50 +08:00
Bohan Jiang
98766cc06a feat(daemon): remove the Linux Codex per-task HOME; default Linux to danger-full-access (MUL-5578) (#6233)
* feat(daemon): default Linux Codex to danger-full-access on the real HOME (MUL-5578)

Linux Codex tasks ran under the `workspace-write` Landlock sandbox with a
generated per-task HOME: the daemon rewrote HOME/XDG_*/npm_config_cache into
`<envRoot>/home`, symlinked a hand-maintained allowlist of seven credential
paths back into it, and granted that directory as a `writable_roots` entry.

That mechanism could not converge. Any host CLI outside the allowlist (aws,
kubectl, gcloud, glab, rclone, cargo, …) started every task as if unconfigured
even though it works in the daemon user's shell, and three open issues asked to
extend it in three incompatible directions (#5636, #5573, #3867). The
containment it bought was also narrower than it looked: workspace-write
restricts writes only — reads and network were already unrestricted, and `.ssh`
was seeded into the task home — so credentials under the real HOME were
readable and exfiltratable regardless.

Linux now runs `danger-full-access` on the daemon user's real HOME and inherited
XDG environment, matching macOS and Windows. The task filesystem boundary is the
boundary the daemon itself runs inside (VM, container, or dedicated Unix user),
which is what the other providers already assumed — Claude Code runs with
`--permission-mode bypassPermissions` today.

This also removes the split-brain the mechanism could enter: the task-HOME
decision read the platform default only, so a `-c sandbox_mode=danger-full-access`
override (which passes arg filtering and wins over config.toml) left a task
running unsandboxed while the daemon still redirected HOME and emitted
writable_roots for a sandbox that was not in effect. With no HOME rewrite there
is only one environment contract left to disagree about.

Removed: prepareTaskHome, prepareCodexSandboxHome, TaskHomeEnv, Env.TaskHome,
both seed allowlists, and the now-unfed WritableRoots plumbing through
codexSandboxPolicy / CodexHomeOptions. Env roots created by older daemons keep
working; their leftover `home/` directory is simply ignored and reclaimed with
the env root.

Task-scoped CODEX_HOME is untouched — it is managed Codex state, not the Unix
HOME. macOS, Windows, and non-Codex providers are unchanged.

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

* docs: document the agent execution security model (MUL-5578)

The docs had no page describing what a task can reach on the machine that runs
it — the sandbox posture was only discoverable from source. Adds one, stating
plainly that tasks run with the full permissions of the daemon user and that
isolation must come from a dedicated Unix user, container, or VM.

Also separates what Multica genuinely isolates (per-task workdir, task-scoped
CODEX_HOME, agent+task-bound API tokens) from what is not a boundary (the coding
tool's own sandbox and approval settings), and links it from step 5 of the
self-host quickstart, where the daemon is first installed.

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

* fix(daemon): address review on the Linux full-access default (MUL-5578)

Docs, both must-fix:

- The Chinese page rendered the product concept as `Agent`; the repo glossary
  in developers/conventions.zh.mdx mandates 智能体. Fixed all nine occurrences.
  ja/ko already used エージェント / 에이전트 and needed no change.
- The security page asserted without qualification that every task runs with
  the daemon user's full permissions and that the Codex filesystem sandbox is
  always off. codexSandboxPolicyForWindows still keeps workspace-write when a
  user explicitly opts into a native windows.sandbox, so an authoritative
  security page contradicted the code. It now states that Multica makes no
  filesystem-sandbox guarantee, names the Windows opt-in as the one current
  exception, and says which combinations sandbox anything is a compatibility
  detail that moves with tool versions. All four locales updated, plus the
  historical callout which now says Linux matches the macOS/Windows *default*.

Both stale comments from the non-blocking notes:

- prepareCodexHome no longer claims to assume workspace-write + network_access;
  it pins GOOS=linux, which now resolves to danger-full-access.
- ensureCodexSandboxConfig's warn-level logging is no longer described as a
  macOS-only fallback; it fires for every danger-full-access resolution.

Adds TestCodexTaskShellEnvInheritsRealHome at the daemon env-assembly layer:
HOME and the XDG base dirs must reach a Codex task's shell tools from the
inherited daemon environment. Verified it fails when that pass-through breaks.
It guards the pass-through, not runTask's decision not to inject a HOME of its
own — that decision is inline in runTask and has no unit seam.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 15:42:06 +08:00
Otis Cui
cee985592e fix(agent): drain trailing ACP notifications in kimi, kiro, qoder, and traecli (#5951) 2026-07-31 15:22:47 +08:00
Multica Eve
51f44873cc fix(labels): always enable resource labels (MUL-5563) (#6225)
* fix(labels): always enable resource labels (MUL-5563)

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

* docs(labels): clarify resource label rollback safety (MUL-5563)

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

* docs(labels): correct compat client range (MUL-5563)

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 14:41:05 +08:00
Multica Eve
c3f5df8bf4 MUL-5492: fix timeline cap dropping newest entries + stop double-broadcasting descriptions (#6175)
* fix(timeline): cap the issue timeline at the newest end and report the clamp

The per-issue timeline cap was applied with ORDER BY created_at ASC LIMIT
2000, so once an issue accumulated more than 2000 comments or activities
the cap discarded the NEWEST rows. The timeline appeared to stop at some
point in the past and every later event was invisible, with nothing in
the response indicating anything was missing.

Activity is machine-paced — description autosave, every agent run, status
and assignee changes all write rows — so this was reachable in normal use,
not only on pathological issues.

Take the window with the keyset ordering (created_at DESC, id DESC) in a
subquery and re-sort ascending in the outer query. This keeps the
chronological contract for every existing caller, including the comment
list endpoint that shares ListCommentsForIssue, and is served as an
index-only scan by the idx_*_keyset indexes already added in migration
068 — no new migration, no call-site changes.

Two things beyond the ordering flip:

- Clamp both lists to a shared window floor. The two caps are applied
  independently, so each list has its own floor. Merging windows with
  different floors produces a timeline that looks continuous but, below
  the higher floor, contains only one of the two kinds — e.g. comments
  with no interleaved activity. That is worse than a timeline that
  visibly stops, because nothing about it looks wrong. Both lists are
  now clamped to the newest floor, so the result is a contiguous,
  correctly interleaved slice.

- Stop truncating silently. The unpaginated response is a bare JSON
  array with nowhere to put a flag, so the clamp is reported via
  X-Timeline-Truncated and X-Timeline-Window-From, added to
  ExposedHeaders because a custom response header is otherwise
  unreadable from browser JS. The legacy wrapped shape's has_more_before
  is now truthful instead of hardcoded false.

Queries read one row past the cap so "hit the cap" is distinguishable
from "holds exactly 2000 rows", which would otherwise report a complete
timeline as truncated and drag the other list's window down with it.

Regression tests cover all four properties and were confirmed to fail
against both the original query and a floor-less DESC flip.

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

* perf(realtime): stop broadcasting two full descriptions on issue:updated

issue:updated carried prev_description alongside the new description in
the issue object, and the WS forwarder reuses the producer's payload map
verbatim. Every debounced description autosave therefore pushed two full
copies of the description to every connection in the workspace, including
users who did not have the issue open. The DB write is O(1); the fanout
was O(connections x description size), and it repeats on every pause in
an editing session.

prev_description and prev_title exist only for in-process listeners —
subscriber_listeners adds newly @mentioned users, notification_listeners
builds mention notifications, activity_listeners records the title
change. No client reads them: IssueUpdatedPayload in
packages/core/types/events.ts does not declare either field.

Project the payload on the way out. The bus dispatches bus.Subscribe
handlers before the SubscribeAll forwarder, so the in-process consumers
are unaffected, and projecting at the forwarder covers both the single-
node Hub and the Redis relays since that is where the frame is
serialized. The producer's map is copied rather than mutated.

The removed keys are listed in a table rather than an if on one event
type. The bug was structural, not a typo: the next large field added to
a published payload inherits the same cost silently, and a declarative
list puts the internal/external payload boundary in one reviewable
place.

issue.description itself is deliberately kept — clients apply it to
their cache, so stripping it would trade fanout bytes for N refetches.
Cutting the remaining fanout needs the per-issue scope routing already
scaffolded server-side for MUL-1138, which is blocked on the client
sending subscribe frames.

Tests assert both halves: the keys are absent from the serialized frame,
and the in-process listener still receives them.

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

* fix(timeline): keep comment threads whole under the newest-N cap

Review found that the newest-N window can orphan a reply, and an orphaned
reply is invisible rather than merely mis-nested: the timeline builds its
top level from "activities + comments with no parent_id" and renders
replies by looking them up under their parent, so an orphan sits in the
map with no card to render it. MUL-1847 / #2263 was exactly this shape —
1 root + 29 replies, root dropped, all 29 vanished from the UI while the
API returned them.

Root cause of the regression: capping with the OLDEST n could never
orphan anything, because a reply is always newer than its parent, so a
prefix of the timeline is closed under "parent of". A newest-n window is
a suffix and has no such property. Flipping which end the cap bites
silently invalidated a structural property the comment tree relies on.

Two changes.

Drop the cross-kind clamp. The previous revision trimmed both lists to a
shared floor so the window was provably contiguous. That was the wrong
trade and it was also the dominant source of orphans. Comments are
human-paced (p99 ~30, max ever observed ~1.1k) and essentially never
reach the cap, while activity is machine-paced and reaches it routinely
— so the shared floor was almost always the activity floor deleting
comments that had been fetched successfully and would have rendered
fine. On an issue with thirty comments it was pure loss. Each list now
reports its own truncation and X-Timeline-Truncated names which kinds
were affected. Not clamping costs only activity density in the older
part of the range, which is metadata rather than content, and it is
reported rather than hidden.

Complete parent chains for the case that remains — comments themselves
exceeding the cap. ListMissingAncestorComments walks parent_id upward via
a recursive CTE and returns the ancestors not already held; the handler
merges them and restores the ascending order. This only ever ADDS rows,
so unlike clamping it cannot hide anything the caller would have seen,
and it is bounded by the number of distinct missing ancestors. Whole-
thread windowing was considered and rejected: a single thread can exceed
any row budget, so its degradation is not definable.

Applied to the shared query's default list path too, not just the
timeline. foldResolvedThreads documents a COMPLETE-thread set as its
precondition and comment.go asserts the default list mode satisfies it;
a half thread made that assertion false and a resolved thread whose root
was cut stopped folding correctly.

Also drops X-Timeline-Window-From. It was second-precision RFC3339 while
the real ordering key is (created_at, id) at full precision, so it could
not resume a read without skipping or repeating rows inside a shared
second. A resumable cursor should be opaque and carry both halves; worth
designing when there is a consumer rather than shipping as a lossy
approximation.

Tests: the reviewer's exact scenario, plus a no-orphaned-replies
invariant on both endpoints, the fold-still-works case, and a guard that
activity truncation does not delete comments. Each was confirmed to fail
with the fix disabled. TestListTimeline_JointWindowHasNoOneSidedRegion
was rewritten rather than deleted — it pinned the clamp behaviour being
abandoned here, so leaving it would lock in the wrong contract and
deleting it would drop the coverage.

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

* fix(comments): bound parent-chain completion and stop folding partial threads

Second review round on MUL-5492. Four must-fixes, all stemming from one
conflation: parent-chain closure, a newest-N window, and a complete
thread are three different things. Closure makes a reply renderable; it
does not license thread-level derivations.

Do not fold a truncated read. fetchCommentsForList closes parent chains,
but older siblings and descendants of a retained reply stay outside the
window, so the set holds partial threads. Folding them produced wrong
answers rather than incomplete ones: a resolution reply outside the
window made a resolved thread look unresolved, and folded_count reported
a total derived only from retained replies. The previous revision claimed
closure restored foldResolvedThreads' COMPLETE-thread precondition; it
did not, and that claim is removed. --recent and untailed --thread still
return whole threads and still fold.

Bound the walk. The recursive CTE climbed to the root with no depth
limit, so a deep chain could drag its entire ancestry back and defeat the
row cap it was meant to preserve. Depth is genuinely unbounded in stored
data: the general write path stores the exact comment being replied to
(only the agent path collapses to the thread root), so chains can run far
deeper than the two levels the UI renders. Replaced with a layered walk
under explicit budgets — 2000 extra rows, 64 levels — making a response
provably bounded by 4000 comments, or 6000 timeline entries with
activities.

Scope every level to the tenant. The CTE's recursive branch matched on
parent_id alone. parent_id carries a foreign key to comment(id) but not
to a matching issue, so a stray cross-issue parent reference is
representable, and the walk would have followed it into another issue's
comments. The replacement filters issue_id and workspace_id on every
level. A negative test confirms the leak: with the filter removed it
reports "a comment from another issue leaked into this issue's response".

Degrade by pruning, not by orphaning. When a budget is exhausted, a
parent row is missing, or a parent is out of scope, keepRootConnected
drops the affected comments instead of returning replies the UI cannot
render. Dropping a node also drops its descendants, since their chains
run through it. Returning fewer new replies is conservative and already
signalled as a truncated read; leaking another tenant's data, returning
an unbounded response, or emitting invisible orphans are all worse.

Also: probe read on the comment list so exactly-2000 is not misreported
as truncated, which would needlessly suppress the fold; CommentsTruncated
is carried on fetchCommentsResult rather than inferred from the result
length, which is meaningless once completion adds rows. The new query
returns db.Comment directly instead of a hand-copied row, which is how
quick_action_id came to be dropped after the rebase — a backfilled
quick-action root would have rendered as a raw prompt.

Corrected three inaccurate comments: the "index-only scan" claim (the
index avoids the sort but does not cover SELECT *), the "write path
collapses replies to root" claim, and a test header still describing the
abandoned contiguous-window behaviour.

Tests cover exactly-at-cap still folding, truncated reads not folding
(both reply-resolved and root-resolved), depth beyond budget pruned not
orphaned, shared ancestors fetched once, cross-issue parents never
crossing the boundary, and quick_action_id surviving backfill. Each was
confirmed to fail with its specific fix disabled; the reply-resolved fold
test was reshaped after the first version passed for the wrong reason.

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

* fix(timeline): preserve complete threads under comment cap

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

* fix(comments): preserve newest bounded views

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 14:40:16 +08:00
Bohan Jiang
f4bf8e2c36 fix(realtime): bound inbound WebSocket message size (MUL-5569) (#6222)
The client-facing realtime hub upgraded a connection and read from it
without ever calling SetReadLimit, so gorilla buffered a whole inbound
message in memory before any application-level check ran. A fragmented
message with interleaved pong frames keeps refreshing the read deadline,
so a single connection could grow that buffer without bound and OOM the
process, taking every workspace on the instance down with it.

Set a 64 KiB limit — matching the daemon hub, three orders of magnitude
above the largest legitimate frame — immediately after the upgrade rather
than in readPump: the token auth path reads its first frame before the
caller is authenticated, so a limit installed any later leaves that read
unbounded. Over-limit closes get their own counter on both paths so the
breach stays visible instead of blending into ordinary churn.

Closes #6210

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 13:38:57 +08:00
Bohan Jiang
abdfd3e28c refactor(skills): make the brief's skill list a names-only index (MUL-5529) (#6207)
* refactor(skills): make the brief's skill list a names-only index (MUL-5529)

Step 3 of MUL-5529. Every runtime CLI discovers the SKILL.md files the daemon
writes and builds its own listing from their frontmatter — verified against 11
locally installed CLIs plus official docs for 5 more. The brief's copy of those
descriptions was therefore the same routing signal paid for twice: measured on
a real task, `## Skills` was 13,295 chars, 40% of the entire brief, against a
16,304-char CLI listing of the same 28 skills.

Now 850 chars for that same set — roughly 3,100 tokens back per brief.

The index itself stays. It is the one skill listing Multica controls; each
CLI's own listing is theirs, and its format — or its existence — can change
with any release.

Three changes:

  - Descriptions dropped from the `## Skills` entries.

  - The per-provider branch is gone. Its fallback told providers outside a
    hardcoded list to read `.agent_context/skills/`, but the only providers
    that ever reached it were grok and traecli, whose files are written to
    `.grok/skills` and `.traecli/skills` and which discover natively. The
    pointer was wrong for everyone it addressed, so removing the branch
    deletes the bug rather than relocating it. This closes MUL-5537.

  - issue_context.md and its quick-create / autopilot variants no longer render
    `## Agent Skills`. That copy duplicated the brief once both were
    names-only, and nothing ever read it: no prompt references the path, and
    grepping the server finds only the writer. `.agent_context/skills/` had the
    same fate for hermes (issue #5242). Quick-create, previously skipped in the
    brief and served only by that unread copy, now gets the brief section like
    every other kind — one index, one place.

Not included: skills carrying `disable-model-invocation` are still written to
disk for every provider. The plan assumed that key needed provider-specific
handling for everything except claude; probing the installed CLIs shows 9 of 11
honor it, and only opencode and hermes do not. The remaining question is
narrow and a genuine product tradeoff — withholding the file honors the
author's intent but also removes explicit invocation — so it is left to a
separate decision rather than folded in here.

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

* docs(skills): align stale comments with the names-only brief contract (MUL-5529)

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Steve Jobs (Multica Agent) <agent-steve-jobs@multica.ai>
2026-07-31 13:27:50 +08:00
Naiyuan Qing
13b06f038e fix(issues): count agents working in the surface, not the workspace (MUL-5525) (#6191)
* fix(issues): count agents working in the surface, not the workspace (MUL-5525)

The "N agents working" chip ran its own workspace-wide
`/api/working-agents` read while the list it filters came from the
surface's own compiled query. Two definitions of the same question, so on
a project page the chip could advertise agents working nowhere near that
project and then open an empty list. Every other narrowing the list knows
about — status, priority, assignee, creator, label, custom property,
date, sub-issue display, the /issues Members/Agents tabs — was invisible
to the count for the same reason. Only /my-issues (relation) and the
issue-detail sub-issue chip (parent) were narrowed, because those were
the two cases the endpoint had grown parameters for.

Rather than add a `project_id` parameter and leave the next dimension to
be discovered the same way, the count now comes from a `working_agents`
facet on the existing issue-table facets endpoint: same scope, same
filters, same compiled WHERE clause the rows come from, joined to running
issue tasks and grouped by agent. Correct-by-construction instead of
correct-by-keeping-two-lists-in-sync.

- Facet is disjunctive like every other one: it drops `working_issue_ids`
  / `working_only`, so the answer is identical whether the filter is on or
  off and the number does not move when you click the chip.
- Facet keys are agent ids, so they pass the same visibility gate as the
  other workspace-wide agent aggregations — a private or non-allow-listed
  agent is not disclosed by id, count, or presence.
- Gantt keeps a client-side count: its canvas projection (scheduled +
  dated + showCompleted) cannot be expressed in the Table query spec, so
  it counts the agents holding canvas rows instead.
- The chip is now presentational; `undefined` renders the existing
  indeterminate label rather than a zero it cannot stand behind.
- Removes the MUL-4884 `workingScopeIssues` plumbing, dead since the count
  moved to the endpoint in MUL-5200, keeping only the Gantt branch that
  still has a real consumer.

Also fixes the empty state that bug dropped you into: a filtered-empty
surface claimed "No issues linked — create one" while 41 issues sat behind
the filter. Shared filtered-empty state now precedes each surface's own
copy and offers to clear exactly the filters it blames.

Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors),
go test ./internal/handler (new facet tests cover project scope, status
and sub-issue narrowing, filter-independence, and the access gate).

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(issues): keep the working-agents unknown state unknown (MUL-5525)

The chip correctly refused to print a number for an unresolved projection,
then handed the hover card `agents ?? []` — so hovering an indeterminate
chip read "No agents working right now". That is the same unearned claim
this issue is about, made by the one surface with room to spell it out:
the label said "—" while the body next to it asserted zero.

- `WorkingAgentsHoverContent` takes `readonly WorkingAgentSummary[] |
  undefined` and distinguishes all three states: `undefined` renders new
  `agent_activity.unknown_hover` copy, `[]` keeps the empty sentence, a
  non-empty list keeps the roster. The chip passes its projection through
  untouched.
- The colour tier had the same collapse: unknown wore the neutral tier
  WITH muted text, which is exactly the "nothing is happening here" tier a
  known zero wears. `chipAppearance` now takes a `ChipActivity`
  ("unknown" | "none" | "some") instead of a boolean, so the three cases
  cannot be written as two, and unknown stays neutral but undimmed.
- The sub-issues chip is unaffected: it passes a resolved array and
  renders nothing at zero, so it never claimed anything either way.

Regression tests cover the hover path specifically — reverting either
downgrade fails "does not let the hover body downgrade an unresolved
projection to zero", "does not dim the chip while the projection is
unresolved", and the chipAppearance unknown case (verified by reverting).
`WorkingAgentsHoverContent` also gets direct unknown / empty / roster
tests, and `chipActivity` one for the three-way split.

Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors).

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 13:04:42 +08:00
Multica Eve
e6610c0831 fix(usage): close the per-agent rollup windows so the leaderboard cannot exceed the totals (MUL-5551) (#6194)
The Usage page showed a single agent with 1021.0M tokens under a workspace
Tokens KPI of 805.9M for the same 1D window.

Both halves read the same rows and disagreed only on the window.
parseSinceParamInTZ deliberately returns N+1 calendar days of headroom, and
the date-bucketed series (usage/daily, runtime/daily) get trimmed back to
-(days-1) client-side before the KPIs and the chart are computed. The two
per-agent rollups behind the leaderboard carry no date column, so nothing
trimmed them and they kept the full N+1 span: at days=1 that is today PLUS
yesterday. One busy agent's two-day total then trivially exceeded the
workspace's one-day total.

Same defect and same fix already applied to failures/by-agent: switch
usage/by-agent and agent-runtime to parseExactSinceParamInTZ. This also
realigns the Run time / Tasks KPI tiles, which are sourced from
agent-runtime and were therefore a day wider than the Cost / Tokens tiles
beside them.

Co-authored-by: Eve <eve@multica-ai.local>
2026-07-31 12:54:25 +08:00
yushen
0a54485ab6 chore(llm): use gpt-5.6-luna by default 2026-07-31 12:04:01 +08:00
Jiayuan Zhang
2e0c599edd fix(agent): avoid H1 headings in issue bodies (#6199)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 23:12:53 +08:00
Bohan Jiang
75c11db048 MUL-5549: feat(agent): discover codebuddy models over ACP instead of scraping --help (#6203)
* feat(agent): discover codebuddy models over ACP instead of scraping --help (MUL-5549)

CodeBuddy speaks ACP, and `session/new` answers with a structured catalog under
models.availableModels plus a currentModelId — exactly the shape the shared
parseACPSessionNewModels already reads for Copilot / Kimi / Kiro / Qoder / Grok /
TRAE. Scraping the `--model` line out of `codebuddy --help` was never necessary.

The help text carried IDs and nothing else, which cost us three things:

- Labels were guessed from the ID and were simply wrong. `kimi-k3-1` rendered as
  "Kimi K3 1" where the CLI says Kimi-K3; `deepseek-v3-2-volc` as
  "Deepseek V3 2 Volc" where the CLI says DeepSeek-V3.2.
- The default model was a "first entry wins" guess rather than the advertised
  currentModelId.
- The effort catalog needed a second regex over the same output.

All three come from the handshake now. The effort catalog rides along in the
same session/new response as the `thought_level` config option, so it costs no
extra process — which also retires the "at most one --help per request"
constraint added in #6196, because --help is no longer run at all.

One trap worth naming: thought_level advertises `enabled` ("On (default)")
alongside the six real levels, but `--effort enabled` is not a valid command
line — the daemon passes the selected level straight to the flag. Advertised
levels are filtered against the flag's accepted set, and a currentValue outside
that set (the default `enabled`) becomes an empty DefaultLevel, which the UI
renders as a generic "Default" instead of a value we cannot pass through.

Two adjacent inaccuracies surfaced while confirming the real level set against
CodeBuddy 2.130.0, both fixed here: the static effort fallback omitted `minimal`
and `max`, and so did the server-side IsKnownThinkingValue gate — so the server
rejected two levels the CLI genuinely accepts.

Discovery keeps its fallback, still marked Fallback so it can never be cached as
authoritative (#6196). That covers the not-logged-in case, which is deliberately
NOT special-cased with an auth step: the catalog came back without calling
authenticate on a logged-in CLI, and inventing an auth branch we cannot exercise
would be speculation.

Removes codebuddyModelRe, parseCodebuddyModels, codebuddyModelLabel,
codebuddyModelProvider, codebuddyEffortRe, parseCodebuddyEffortHelp,
codebuddyEffortSuperset, codebuddyHelpOutput and its 60s help cache.

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

* fix(agent): keep codebuddy's vendor grouping after the ACP migration (MUL-5549)

Review nit, and a real regression in the previous commit. Dropping
codebuddyModelProvider looked like removing dead code, but it was the only thing
populating Model.Provider for CodeBuddy — and the picker groups on that field.

acpModelEntry can only recover a vendor from a `vendor:model` id. CodeBuddy's
are bare (`glm-5.2`, `kimi-k3-1`), so every model came back with an empty
Provider, and model-dropdown renders the empty group with no header at all: all
16 models would have collapsed into one unlabelled list where main shows Zhipu /
Kimi / MiniMax / DeepSeek / Hunyuan sections.

Restores the prefix inference as a post-pass over the ACP catalog, exactly the
shape discoverCopilotModels already uses for the same reason.

Verified against the real CLI: all 16 models land in five vendor groups with none
ungrouped. Tests assert the vendor for every id CodeBuddy 2.130.0 advertises plus
the static fallback ids, and that the fallback entries' hardcoded providers agree
with the inference. Removing the post-pass fails them.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 21:40:23 +08:00
Jiayuan Zhang
0fdc38704e MUL-5149: add agent-generated Chat quick actions (#5766)
* feat(chat): add agent-generated quick actions

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

* fix(chat): preserve mid-response quick-action fences

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

* fix(chat): drop quick actions on empty reply to keep no_response fallback

An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.

Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.

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

* feat(chat): generate quick actions via daemon suggestion pass

Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(chat): deliver quick actions async with skeleton placeholders

Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(chat): make the quick-actions toggle stop generation, not hide pills

The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): renumber quick-action migrations onto current main

Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:

- 219_chat_message_quick_actions        -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled

Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.

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

* fix(mobile): render async chat quick actions via chat:quick_actions

The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.

Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.

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

* fix(chat): cancel in-flight messages refetch before quick-actions patch

The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).

applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).

Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.

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

* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)

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

* fix(chat): address quick-actions re-review (MUL-5149)

- Ack alignment: refresh request carries the target message_id; server
  atomically confirms it is still the session's latest turn (409 stale
  otherwise), so the client marker always matches the resolving
  chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
  (sweeper/orphan) now resolves it, and the daemon reports a failed supplement
  so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
  instead of a component-local flag that only masked the UI; drop the skeleton's
  and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.

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

* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)

Third-round review of the refresh button surfaced three issues; all three
are addressed here.

§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.

§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.

§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.

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

* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)

Two re-review blockers on 2dc9404d.

§1 (deferred window): HasActiveChatTaskForSession only treated
queued/dispatched/running/waiting_local_directory as in-flight, so a chat
auto-retry armed with a backoff fire_at — inserted 'deferred' by
CreateRetryTask, as provider_network's ~5s final attempt is — slipped past
the busy check. In that window the failed turn has no assistant row yet, so
the old turn is still latest-persisted and refreshable; the regen would then
resume a session the retry is about to advance and pin the new turn's
suggestions onto the old one. Add 'deferred' so the set matches the
canonical in-flight status list the rest of the queries already use
(agent.sql has-active-task checks). New regression test covers a deferred
active turn.

CI (text-contrast gate): the refresh icon button used
text-muted-foreground/70 (transparency standing in for a text tone), which
the frontend-test contrast gate rejects. Switch to the solid
text-faint-foreground token — the tone the gate recommends for icons/glyphs,
already used repo-wide and clearing WCAG 1.4.11.

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

* test(chat): assert quick-actions pending marker carries expires_at (MUL-5149)

The chat:done supplement-flow test still expected the 2-field marker from
before the absolute-deadline change; applyChatDoneToCache now stamps
expires_at, so the deep-equal failed on frontend-test. Assert the deadline is
present (expect.any(Number)) rather than a wall-clock-dependent value — its
timing semantics are covered by the pending-timeout hook.

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

* fix(db): renumber regenerate-quick-actions migration 237 -> 240 (MUL-5149)

main merged Issue Quick Actions (MUL-5465) taking migrations 237/238/239
(quick_action, quick_action_workspace_index, comment_quick_action). This
branch independently took 237 for agent_task_queue.regenerate_quick_actions_for.
The two 237s do not textually conflict (different filenames) so the PR reads
mergeable, but the merged tree would carry two migration 237s. Renumber this
one to 240 so it applies after main's chain. The migration is a standalone
`ALTER TABLE agent_task_queue ADD COLUMN IF NOT EXISTS` — order-independent,
touches a column none of main's migrations reference.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
2026-07-30 21:25:03 +08:00
Bohan Jiang
5199278780 fix(skills): give every skill one name across brief, directory, and frontmatter (MUL-5529) (#6189)
* fix(skills): give every skill one name across brief, directory, and frontmatter (MUL-5529)

A skill could answer to three different names at once. The runtime brief listed
`AgentSkillData.Name` verbatim — a workspace skill's human display name ("PR
review") — while the invocable identity on disk is the sanitized slug
(`pr-review`). Separately, ensureSkillFrontmatter returned valid upstream
frontmatter untouched, so a SKILL.md could declare `name: multica-dev-workflow`
inside a directory called `multica-git-workflow`.

That last divergence is the sharp one: runtimes disagree on which field
identifies a skill. Claude routes on the directory name, OpenCode on the
frontmatter `name`. So the same skill is invocable under different names
depending on where it runs, and the brief's instruction to use "only names from
the listing" pointed at names that resolve nowhere.

The slug is authoritative: it is what lands on disk, it derives from the name
users see in the product, and it is the only value with a uniqueness guarantee
(allocateCollisionFreeSkillDir). A frontmatter `name` is author-supplied and two
imported skills may both claim the same one.

- modelVisibleSkills normalizes Name to the slug. All four model-visible
  listings (runtime brief + the three issue_context renderers) already route
  through it, so they cannot drift apart.
- ensureSkillFrontmatter rewrites `name` to the allocated slug and keeps every
  other key byte-identical, so deliberately shaped upstream frontmatter still
  survives. The rewrite follows the collision fallback slug too.
- Name matching is now top-level only. An indented `name:` belongs to a nested
  mapping; treating it as the skill's identity both missed that the block had no
  top-level name and would have spliced a top-level key into the nested one.

Known gap, tracked separately: the listings render the natural slug, so a
collision fallback to `<slug>-multica` still leaves the brief naming the user's
skill. Closing it needs the allocated slug threaded back from Prepare, which the
renderers cannot reach without giving up the byte-identical-brief guarantee.

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

* fix(skills): cover multi-line name values and in-batch slug collisions (MUL-5529)

Two holes in the name-unification change, both found in review, both
reproduced before fixing.

1. setFrontmatterName replaced only the `name:` line, not the rest of the YAML
   value. A value may continue onto indented lines — `name: >-\n  upstream`,
   multi-line plain scalars, wrapped quoted scalars — so the continuation
   survived and YAML folded it into the new value: the block parsed as
   "my-slug upstream-name", not "my-slug". The directory == frontmatter-name
   invariant this change set exists to establish was still broken, just less
   visibly. frontmatterNameSpan now covers the whole value.

   As a side effect this also fixes `name:` with the value entirely on the
   following line, which previously read as "no name" and got a second
   top-level `name` injected above it — two `name` keys, which strict loaders
   reject outright.

2. The listings derived slugs with sanitizeSkillName alone, which is not
   injective: "A B" and "A-B" both reduce to "a-b". writeSkillFiles resolved
   that at write time, so the second skill landed in `a-b-multica` while both
   were listed as `a-b` — the second skill had no invocable name and the model
   was pointed at the first. This needed no user-installed skill and no
   local_directory; two such skills bound to one agent reproduce it in a clean
   workdir. resolveSkillSlugs now deduplicates the batch up front and both the
   listings and the writer derive from it, with skillSlugCandidate shared so
   the in-memory and filesystem allocators cannot disagree on the suffix
   sequence.

   Slugs are allocated over the unfiltered batch: hidden
   (disable-model-invocation) skills are still written to disk and still
   consume a slug, so filtering first would shift every later suffix.

Filesystem-dependent collisions against user-installed directories remain out
of scope and are still tracked in MUL-5550.

Regression tests assert the parsed YAML value rather than the output text —
the first line looked correct in every one of these cases — and set-equality
between listed names and the directories actually written. Both were confirmed
to fail against the previous implementation.

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

* fix(skills): bound the frontmatter name value with the YAML parser (MUL-5529)

Review round two found a valid multi-line name the indentation rule still
mangled. A quoted scalar may wrap onto a line at the *same* indentation as its
key:

    name: "upstream
    continued"

The rule stopped at the key's line, stranding `continued"` and producing
invalid YAML. Probing the parser showed the same holds for flow collections
(`name: [a,\nb]`, `name: {a: 1,\nb: 2}`), so this was not a quoting special
case: indentation simply does not bound a YAML value, and no amount of
patching the heuristic would have made it one.

Value extent now comes from yaml.v3's own line numbers. frontmatterNameValueSpan
locates the `name` key node and ends the span where the next top-level key
begins, stepping back over blank lines and unindented comments so they survive
the rewrite. Unindented is the operative word: block scalar content is always
indented, so an unindented `#` can only be a comment, while `  # text` inside a
block scalar is value and stays in the span.

Detection stays lexical, in lexicalFrontmatterNameSpan. It runs on malformed
blocks too, where there is no parse to consult, and only decides which branch
to take.

setFrontmatterName now re-parses its own output and returns verified=false
unless `name` really is the slug; ensureSkillFrontmatter then routes to the
existing re-synthesis path rather than emitting a block it cannot vouch for.
This adds no new fallback — it feeds an already-present one. The invariant is
the whole point of the change, so an unprovable rewrite is worth less than a
reformatted block: with the span deliberately broken, output stays valid YAML
carrying the right name and loses only upstream formatting.

Tests extend the table to same-indent single/double quoted scalars, flow
sequences and mappings, and name-as-last-key, and now assert the *input* parses
so a case cannot pass by silently taking the re-synthesis path. Two more cover
comment survival and a `#` line inside a block scalar name. All four
same-indent cases fail against the previous implementation.

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

* fix(skills): preserve policy keys when the surgical name rewrite fails (MUL-5529)

Review round three: the post-condition check added last round was routing valid
YAML into bare re-synthesis, and re-synthesis emits only name and description.

An anchor on the name value is one way to get there. Replacing the line drops
`&skill_name`, so `description: *skill_name` no longer resolves, the check
correctly rejects the rewrite — and the block was then rebuilt as just:

    name: my-slug

`disable-model-invocation: true` went with it. That key is the author's
instruction that a runtime must not surface the skill on its own, and the
SKILL.md we write is what native discovery reads, so losing it advertises a
skill that was deliberately hidden. Confirmed on the written output:
skillDisablesModelInvocation went from true to false. That is a semantic
regression, not the formatting loss the fallback was justified by.

The two failure modes are now separate:

  - invalid YAML → re-synthesize, unchanged; there is nothing to preserve.
  - valid YAML, rewrite unprovable → renameFrontmatterNameViaNode rebuilds the
    block from the parsed node with `name` set to the slug, keeping every other
    key. Formatting normalizes; semantics survive.

The anchor is deliberately kept on the name node. Dropping it is what
invalidates a document that aliases it; keeping it means the alias resolves to
the slug — that value changes, but the document still loads and every policy
key is intact. The rebuilt block is re-parsed and checked like the surgical
path, so an unprovable result still falls through to re-synthesis.

Regression test asserts name, disable-model-invocation, and a custom key all
survive, and that the written file still reads as hidden to
skillDisablesModelInvocation. It fails without the new path.

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

* fix(skills): materialize aliases before renaming an anchored name (MUL-5529)

Round three kept the anchor on the name node so aliases would not dangle. That
avoided an invalid document but created a worse one: every alias resolves
through the anchor, so renaming the anchored value silently rewrote whatever
those fields meant.

    name: &shared "true"
    disable-model-invocation: *shared

skillDisablesModelInvocation went true -> false across the rewrite. Same
skill-exposing regression as round three, reached by keeping the key instead of
dropping it — which is the lesson: preserving a key is not the invariant,
preserving each key's resolved value is.

Aliases pointing at the name node are now materialized to the value they
resolved to *before* the rename, and the anchor is dropped afterwards as
unreferenced. Ordering matters: the clones are taken first, so they capture the
original value rather than the slug. Copies are per-alias, since sharing one
node would make the encoder re-emit an anchor/alias pair.

Nested aliases are covered by walking the whole document, not just the top
mapping.

Tests: three shapes (alias carrying the policy value, alias nested in another
mapping, two aliases of one anchor) each assert the fixture starts hidden and
stays hidden, that no anchor or alias survives, and that name is the slug. The
round-three test now also pins its aliased `description` to the pre-rename
value instead of merely tolerating the slug. All four fail without the change.

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

* fix(skills): let the parser decide whether a name key exists (MUL-5529)

The branch that chooses between "rewrite the name" and "inject a name" was
still gated on a lexical scan, which only recognizes a bare `name:` carrying a
value on the same line. Two valid spellings therefore read as nameless:

    "name": upstream        # quoting is syntax, not identity
    name:                   # a key with no value is still the key

Both got a second `name` injected above the existing one, and a duplicate
mapping key is rejected outright — `mapping key "name" already defined`. The
skill does not end up misnamed, it fails to load. Single-quoted keys have the
same problem.

For valid YAML the parsed top-level mapping now answers the question, so any
spelling of the key routes to the rewrite. The lexical scan is confined to the
invalid-YAML branch, where there is no parse to consult and it is only choosing
between re-synthesis and injection. Nesting still reads as absent: a `name`
under another mapping is not the skill's name, so one is added.

Once past the gate the existing paths handle both shapes unchanged, since
frontmatterNameValueSpan already bounds the entry by node line numbers rather
than by how the key is written.

Also tightens the alias tests per review: the nested and two-alias cases now
assert `meta.inner` and `other` still resolve to the anchor's original value,
not merely that visibility survived. The invariant is every key's resolved
value, so every alias should be pinned, not just the one that gates hiding.

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

* fix(skills): detect quoted and valueless name keys in malformed frontmatter (MUL-5529)

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Steve Jobs (Multica Agent) <agent-steve-jobs@multica.ai>
2026-07-30 21:21:13 +08:00
Jiayuan Zhang
5e3b7a8c37 feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465) (#6132)
* feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465)

Preset "who to call and what to say" once in Settings, then trigger it from
any issue's sidebar with a single click.

Running one is NOT a new dispatch path. The server renders the prompt, posts
a `quick_action` comment carrying the target's mention markup, and hands off
to the existing comment -> mention -> task trigger. Permission
(canInvokeAgent), attribution, squad-leader routing, the execution log, and
pending-task coalescing are inherited rather than reimplemented — the
MUL-3375 lesson about four drifting copies of one trigger decision.

Three things the UI has to be honest about, because the backend already
decided them:

- One pending task per (issue, agent) is a DB invariant
  (idx_one_pending_task_per_issue_agent). A second click against a busy agent
  starts no new run; the comment merges into the pending task. The toast says
  "Added to Lambda's current run", not "Lambda started working".
- An offline target defers rather than fails; the run reuses the existing
  dispatch.ReasonCode vocabulary instead of inventing one.
- Private agents are deny-by-default with no admin bypass. The sidebar filters
  by the caller's own invoke verdict, so a dead button is never rendered, and
  a direct API call still 403s with `invocation_not_allowed`.

Visibility is DERIVED from the bound agent's permission_mode on every request,
never stored — so it cannot drift after someone flips an agent between private
and public_to. Binding a workspace action to a private agent is allowed (the
alternative pressures people into making agents public just to satisfy a
config constraint) but the settings form says so at bind time, and the
catalog badges it. The target's name is withheld from callers who cannot see
it, so the response never discloses a private agent's existence.

Prompt templating is flat substitution over a closed whitelist. No
conditionals, loops, or filters — the agent already reads the whole issue, so
natural language is the control flow. One optional runtime input ({{input}})
keeps a single action from splitting into five near-identical variants; both
directions of the input/{{input}} agreement are rejected at write time so a
typo can never land silently.

Surfaces: sidebar (top 5, rest behind More), the `/` menu in the comment
composer (inserts the server-rendered body to edit before sending), and
Alt-click for the same hand-off from the sidebar.

Migrations 234-236: quick_action table, its listing index (CONCURRENTLY, own
file), and comment.type + comment.quick_action_id.

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

* refactor(issues): simplify quick action permissions to a stored public/private intent (MUL-5465)

Replaces the derived four-value visibility model with a two-value choice made
at creation, and collapses permission handling to a single check.

The old model computed visibility per request from the bound agent's
permission_mode and used it to filter the sidebar. That filtering was the
problem: two people on one issue saw different sidebars with nothing to
explain the difference, which is harder to debug than a button that tells you
why it refused. It also required the list endpoint to run an invocation-target
query per action per request.

Now:
  - `visibility` is stored INTENT — 'public' or 'private' — chosen up front.
  - A public action must bind a target every workspace member can invoke
    (public_to carrying a workspace target), enforced at write time. So a
    public action is runnable by construction and dead buttons are eliminated
    at the source rather than filtered out later.
  - A private action allows any target and is returned only to its creator.
    That scoping is what the field MEANS, not a permission check.
  - Permission is checked in exactly one place: RunQuickAction. A refusal is a
    structured 403 the client renders as one dialog. The dialog does not
    distinguish "no permission" from "the binding drifted" — the person
    reading it takes the same next step either way, and the person who can fix
    it looks at settings.

Removed: can_run, position + manual ordering (settings sorted by usage while
the sidebar sorted by position — one list, two orders), the derived
visibility_broken flag, the runnable_only projection and its second cache
entry, target_name redaction, the alt-click composer hand-off (the `/` menu
covers insert-then-edit and is discoverable), and the sidebar_limit response
field (now a shared constant).

Ordering is use_count DESC everywhere. Settings shows the target's current
reachability as plain metadata ("Nova · private"), so a public action pointing
at a now-private agent reads as visibly wrong without a bespoke error state.
The tradeoff — no active signal when that drift happens — was accepted
deliberately: drift is rare and the failure is loud at click time.

Migration 234 is edited in place rather than layered, since the PR is
unmerged and the table has never been deployed.

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

* refactor(issues): drop quick action variables and runtime input (MUL-5465)

V1 ships a preset prompt sent verbatim, triggered from the sidebar or the `/`
slash command. Two features are removed and one guard is kept.

Runtime input goes because `/` already covers it. Typing `/code review` drops
the rendered body into the composer, where any part of it can be edited before
sending — strictly more flexible than one fixed field, and the field was
specified before `/` was in V1. Two UIs for one need.

Variables go because none of them passed their own test. The rule was that a
variable earns its place only if it changes what the agent ATTENDS TO, not what
it KNOWS. Checked one by one — {{issue.title}}, {{issue.identifier}},
{{issue.url}}, {{user.name}}, {{date}} — the agent already has every one from
the issue context and from the fact that the comment is authored by the person
who triggered it. They were inherited from autopilot's title template rather
than justified.

The REJECTION survives the feature: any `{{...}}` is refused at write time,
naming the offending token. Someone carrying the habit over would otherwise
have `{{issue.title}}` rendered literally into an agent's instructions and
never notice — the exact silent-typo failure the whitelist existed to prevent.
The check is a fraction of the interpolation engine it replaces and keeps the
door open to enabling variables later without touching stored data.

Removed: 4 columns (input_enabled/label/placeholder/required),
renderQuickActionPrompt + the variable whitelist + quickActionIssueURL, the
two-way {{input}} agreement logic, the run/render `input` parameter, the
variable insert chips, the entire "Ask for input on click" block, and the
sidebar's Popover branch — every row is now a plain button. The settings
dialog drops from six field groups to four.

Migration 234 is edited in place rather than layered, since the PR is unmerged
and the table has never been deployed.

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

* refactor(settings): align Quick Actions with the Labels/Properties list, then fix what the UI review found (MUL-5465)

The tab used a bespoke card list while its two siblings — Labels and
Properties — share one table layout. These three are the workspace's catalog
of small named things and should read as one surface, so Quick Actions now
uses the same structure: search + primary action row, bordered card, responsive
column grid that collapses to stacked rows under `md`, and an overflow menu
instead of a row of icon buttons. Columns are Name / Runs as / Who / Used /
Updated. The tab joins the max-w-5xl group for the same reason.

A UI review pass over the result found five things, four of which are fixed
here:

- The visibility chooser communicated selection through border and background
  only, so a screen reader announced both options identically. Added
  aria-pressed.
- The editor dialog was max-w-xl while both siblings use sm:max-w-lg, and the
  unprefixed cap applied at every breakpoint.
- The empty-state hint diverged from the Properties tab it was copied from
  (text-sm and no max width vs mx-auto max-w-sm text-xs).
- Two hardcoded `text-amber-600 dark:text-amber-400` usages replaced with the
  `text-warning` semantic token, per the repo's design-token rule.

Also fixed a signal-quality bug the review surfaced: the usage column
highlighted anything with use_count 0, so an action was flagged the instant it
was created. Staleness now means "has had time to be used and wasn't" — 90
days since last use, or 90 days since creation for one never used.

Not fixed here: the overflow trigger is size-7 (28px), under the 44px touch
floor. Labels and Properties use the identical size, so changing only this tab
would break the consistency this commit exists to create; it needs one pass
across all three.

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

* fix(issues): drop the quick_action comment type, widen the mention guard, harden the slash race (MUL-5465)

Second review round on PR #6132. All four remaining findings.

**Comment type removed entirely (#2 blocker + #3).** Adding a `quick_action`
type meant dropping and re-adding comment_type_check, and re-adding a CHECK
holds ACCESS EXCLUSIVE on `comment` for a full table scan — a read/write stall
on one of the hottest tables in the product, every deploy. It was also
forgeable: `type` is client-supplied on POST /comments, so any member could
post type='quick_action' and have an ordinary comment render as an action
audit record with its body collapsed out of view.

Both go away by not having the type. A quick action now posts an ORDINARY
comment marked with `quick_action_id`, and the collapsed card keys off that id.
There is no request field for it, so the marker cannot be forged, and the
migration is a bare nullable ADD COLUMN — metadata-only and instant. Verified
against a fresh database: comment_type_check is untouched.

The generic comment endpoint now also validates `type` instead of letting the
DB CHECK reject it. An unknown type surfaced as a 500 on a constraint
violation, which reads as a server fault for plainly bad input; it is a 400
now. `status_change` and `system` are excluded from what a client may author —
claiming those would be forging system narration.

**Member mentions rejected too (#1).** The first pass allowed
`mention://member/...` in prompts on the reasoning that it "only renders a
link". That was wrong: notification_listeners.go adds member mentions to the
recipient set and creates an inbox item, so a saved prompt pinged that person
on every single click. Only `mention://issue/...` reaches nobody and stays
allowed.

**Slash race, properly this time (#4).** The previous fix checked only that the
range still started with "/". Rewriting `/review` into `/fix` while the request
was open passed that check, and the stale response overwrote the new command.
The exact original text is now captured and compared; if the command was
edited, moved, or removed, the pick is abandoned rather than inserted
somewhere wrong. Adds the three regression tests the review asked for:
delayed resolve, rejection, and edit-during-flight.

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

* fix(issues): stop the quick action card repeating its own prompt, and insert the `/` body as markdown (MUL-5465)

Two fixes, one reported and one found while verifying it.

**The card printed the prompt twice.** The collapsed header previewed the
prompt's first line, and expanding showed the mention line plus that same
prompt again. The header now identifies WHICH action ran — "Code Review via
Lambda" — which is both non-redundant and something the body never told you:
the prompt text alone does not say which action produced it. This is what the
original design called for; previewing the prompt was the implementation
drifting from it.

When the action cannot be resolved — deleted, or another member's private one
and so absent from this viewer's catalog — the header falls back to the
prompt's opening line, which is the previous behaviour.

**The `/` menu inserted its body as literal text.** insertContentAt was called
with a plain string, so Tiptap treated the server-rendered markdown as text
rather than parsing it. The mention never became a node; it serialised back out
with escaped brackets (`\[@Lambda\](mention://agent/…)`) and rendered as raw
markup in the thread. Passing `contentType: "markdown"` — the same option the
description editor already uses — parses it properly. Found by reading the
comment rows while checking the first fix: one had escaped brackets and no
quick_action_id, which is what a slash-inserted comment looked like.

The existing async test now asserts the contentType, so the option cannot be
dropped again without failing.

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

* docs(issues): correct the stale quick actions sidebar comment (MUL-5465)

The comment still claimed the section renders nothing when no action is
runnable by the member. Permission filtering was removed several rounds
ago -- the list is deliberately unfiltered and a refusal is explained at
run time -- so the comment described behavior that no longer exists.

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

* refactor(settings): cut the quick action dialog's helper copy in half (MUL-5465)

The dialog had five blocks of explanatory prose around four fields, and
three of them wrapped to two lines, so the form read as a paragraph with
inputs in it.

Each helper now earns its line or loses it:

- The header explained the implementation ("keeps the same history,
  permissions, and execution log as an @mention") -- an architecture note
  the person creating an action does not need. Reduced to the one fact
  they do: it posts a comment.
- "Who can use it" is a question, so the hints answer it as noun phrases
  ("Everyone in the workspace" / "Only you") instead of restating the
  verb. Both now fit one line, which also makes the two cards the same
  height -- the shorter one used to sit in dead space.
- The target and prompt hints front-load the constraint rather than
  burying it mid-sentence.

70 words to 32 across the dialog, with no fact dropped. Field spacing
goes 4 -> 5 so the gap between groups beats the gap inside one.

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

* refactor(issues): render a quick action comment as an ordinary comment (MUL-5465)

The card had a collapsed one-line header that expanded to reveal the
prompt, on the theory that repeated runs of the same action would bury
the discussion. That was solving a problem the feature does not have:
prompts are a sentence or two, the header restated what the body already
said, and the disclosure only put a click between the reader and the
text.

A quick action posts a real comment through the real mention path, so
the honest rendering is the one every other comment gets. Drops
QuickActionCommentBody, its query for the action catalog, and the
now-orphaned quick_action_ran_via string in all four locales.

quick_action_id stays on the comment: it is provenance, and it was never
the reason the card looked different -- keying the special rendering off
it is what is going away, not the record itself.

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

* fix(settings): use the faint tone token for the empty-state icon (MUL-5465)

main added apps/web/app/text-contrast.test.ts, a guard that rejects
transparency standing in for a text tone. The empty-state Zap used
text-muted-foreground/60, which is exactly the pattern it forbids: an
alpha-dimmed tone lands at a different contrast on every surface it is
composited over, so it cannot be reasoned about the way a token can.

text-faint-foreground is the token the guard names for icons and glyphs.

The rule arrived on main after this branch's last merge, so local runs
never saw it -- CI tests the merge commit, which is why only CI caught
it. Merged main first so the branch is checked against the same rules.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 21:01:48 +08:00
Bohan Jiang
44ce16d9b8 MUL-5549: fix(agent): stop reporting a failed model discovery as a real catalog (#6196)
* fix(agent): stop reporting a failed model discovery as a real catalog (MUL-5549)

Selecting the CodeBuddy runtime showed a model list that shares no IDs with
what the CLI actually supports, so every pick was an ID codebuddy rejects
(GH #6180). The list in the report is codebuddyStaticModels() verbatim: the
daemon had fallen back, but nothing downstream could tell.

discoverCodebuddyModels returned (staticModels, nil) on all three failure
paths, and copilot/cursor/grok do the same. A failed discovery therefore
arrived as a successful one, which defeated every guard built to catch it:
the daemon reported status "completed", the picker's discovery_failed hint
only renders on isError, and cacheableModelCatalog — whose own comment says
an empty list means transient failure — waves through a non-empty stand-in
and stores it as last-known-good for the full 24h serve window. One blip got
pinned as the answer for a day.

Discovery now returns a Catalog carrying a Fallback marker, which the daemon
forwards as an additive `fallback` field (older servers ignore it; an older
daemon omitting it keeps the previous behaviour). A fallback catalog is still
rendered — the picker stays populated and manual entry still works — but it
is kept out of both the daemon's 60s discovery cache and the server's catalog
cache. On the server it maps to Keep rather than Drop: a stand-in is no
grounds to evict a real catalog, matching how a `failed` report is treated.

Also stop codebuddyHelpOutput swallowing the exec error. CombinedOutput folds
in stderr, so a codebuddy whose `#!/usr/bin/env node` interpreter is missing
from a GUI-launched daemon's PATH had `env: node: No such file or directory`
parsed as help text — and cached as such for 60s.

Verified against CodeBuddy CLI v2.130.0: the parser itself is fine (16 models
from real --help), so this fixes the reporting of the failure, not the parse.

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

* fix(agent): run codebuddy --help at most once per model-list request (MUL-5549)

Review catch on the previous commit. Model discovery and effort discovery both
read `codebuddy --help`, and the effort pass called it independently. That was
free while a failed --help was (wrongly) memoised, but once failures correctly
stopped being cached, the failure path ran the 35s command twice in a single
request — past the server's 60s running timeout, so the request timed out and
the late report was then discarded as stale. The user got nothing, not even the
fallback list the previous commit exists to preserve.

discoverCodebuddyModels now owns the thinking annotation, so the one help
capture feeds both catalogs, and the failure path uses codebuddyFallbackCatalog
to apply the static effort levels without exec'ing at all: whatever broke
--help for the model catalog breaks it for the effort catalog too.

Also strengthen the handler tests. They decoded into a struct declared in the
test rather than calling ReportModelListResult, so a wrong JSON tag or a
mis-wired cache branch would have passed. They now drive the real endpoint with
daemon auth and chi params, covering: a fallback report leaving a previously
discovered catalog intact, an older daemon omitting the field still warming the
cache, and an authoritative empty catalog still dropping the snapshot.

Both fixes are mutation-tested — reverting either makes the new tests fail.

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

* docs(agent): correct codebuddy --help comments after the single-capture refactor (MUL-5549)

Review nit. The comments still described the pre-refactor call graph, where
both discoverCodebuddyModels and codebuddyEffortSuperset called
codebuddyHelpOutput and the cache was what stopped the duplicate run. The
effort parser now takes an already-captured string, and the single-invocation
guarantee is structural rather than cache-dependent — which matters, because a
failed --help is deliberately not cached, so a second caller would re-run the
full 35s timeout.

Also note on codebuddyHelpOutput that it has exactly one caller and why a new
one would reintroduce the bug.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 20:34:01 +08:00
Jiayuan Zhang
f0110da555 feat(inbox): mark a notification unread from the row context menu (MUL-5496) (#6137)
The inbox auto-marks a notification read the moment it is selected, so
"opened" and "handled" were the same signal — a row you glanced at and
meant to come back to was gone from the unread count with no way back.

Right-click any inbox row for a shared context menu: Mark as read /
Mark as unread, plus Archive (Unarchive in the archived view).

- POST /api/inbox/{id}/unread + MarkInboxUnread query, publishing
  inbox:unread. Item-scoped, mirroring mark-read: the list renders one
  row per issue carrying that group's newest item, so flipping the whole
  group would resurrect siblings the user already dealt with.
- useMarkInboxUnread patches both lists optimistically and re-pulls the
  cross-workspace unread summary on settle.
- One shared menu per list rather than a Base UI root per row (the same
  shape IssueContextMenuProvider uses): only one is ever open, and a
  per-row root would unmount with its menu when the row scrolls out of
  the virtualized viewport.
- The read toggle is main-view only — archived rows deliberately render
  as read and the unread count excludes them, so a toggle there would
  report success and change nothing on screen.
- Parking the row that is currently open holds the auto-read effect off
  that one item while it stays selected; re-opening it later marks it
  read again.
- Mobile subscribes to inbox:unread so the unread dots agree across
  clients.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 19:18:36 +08:00
Bohan Jiang
f73250654f fix(issues): stop blaming permission for an unresolved mention target (MUL-5548) (#6190)
* fix(issues): stop blaming permission for an unresolved mention target (MUL-5548)

A well-formed but wrong agent mention UUID comes back as
`invocation_not_allowed`, and the UI rendered that as "You don't have
permission to use this target". The server never claimed a permission
cause: `invocation_not_allowed` is deliberately ambiguous so a blocked
reason cannot confirm that a private agent in another workspace exists
(dispatch/reason.go). The copy turned a typo into an access-control
investigation — GH #6181 hit exactly this on a squad handoff.

- Reword the blocked-trigger labels in all four locales to name both
  possibilities instead of asserting permission, and record in
  blocked-trigger-copy.ts why a label must not narrow the wire code.
- Report a mention id that is not a valid UUID at all
  (`mention://agent/-`) as `target_unavailable`, matching the squad
  branch beside it and the autopilot admission path. A non-UUID names no
  entity in any workspace, so it conceals nothing and must not be blamed
  on invoke permission.

The well-formed-but-unresolved case is unchanged and still shares
`invocation_not_allowed` with a private agent — the enumeration boundary
this issue asked us to move stays exactly where it is.

Also refresh the multica-mentioning skill: the mention path gates on
`canInvokeAgent`, not `canAccessPrivateAgent` (split in MUL-3963), and
the skill now tells agents to check a mention UUID against the roster
before touching any visibility setting.

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

* docs(skills): correct the mentioning skill's "silent no-op" framing (MUL-5548)

Review nit on #6190: the source map still titled its table "Guards that
make a valid mention a silent no-op", but a parsed mention is never
silently dropped — it is either blocked with a reason_code or folded into
a running task. Several rows in the same table also pointed at
comment.go:14xx line numbers that had drifted.

- Retitle to "Guards and outcomes for a parsed mention" and split the
  outcome into its own column, so each row states the reason_code it
  produces.
- Replace the drifted line numbers with stable search anchors, matching
  the convention the newer rows in this file already use.
- Correct two rows that were wrong, not just stale: archived / no-runtime
  targets are blocked (target_unavailable, runtime_offline), and an
  already-pending target is a coalesce/defer fold, not a skip.
- Apply the same correction to SKILL.md, where the frontmatter and the
  "What does NOT happen" section told agents an already-pending mention
  was dropped. It is folded into the running task and still gets read —
  worth being exact about, since believing otherwise invites a re-post.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 18:42:14 +08:00
Bohan Jiang
a4bca74c6e refactor(skills): trim 8 built-in skill descriptions to trigger + boundary (MUL-5546) (#6188)
Every runtime CLI scans SKILL.md frontmatter and renders each description into
its own always-loaded skill listing, so those 3,465 characters are paid on every
run. Most of them did no work: each description carried a "Covers A, B, C..."
content inventory that contributes nothing to routing, since the agent reads the
body anyway once it opens the skill.

Keep the trigger sentence and the reverse boundaries, drop the inventory:
3,465 -> 1,353 chars (-61%), roughly 866 -> 338 tokens.

Also tighten maxDescriptionChars from 1024 to 300. The 927-char description grew
because the old cap never pushed back; trimming the text without moving the gate
fixes the symptom, not the system.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 18:27:02 +08:00
Bohan Jiang
99d8f29bde fix(codex): raise the first-turn no-progress ceiling to 60s (MUL-5542) (#6192)
The first-turn watchdog killed healthy turns. Two independent field
reports measured the first progress event landing just past 30s on
gpt-5.5, and ~39s for a WSL app-server, all inside the window the
watchdog treats as "stuck". Raise the ceiling to 60s.

The window's only job is to fail fast instead of waiting out the 10
minute semantic inactivity backstop, so 60s keeps that value while
clearing the observed evidence with margin.

Add a regression test for the clamp, which had none: the configured
semantic inactivity timeout can only shrink this window, never raise it.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 18:24:45 +08:00
Bohan Jiang
1d2a3499c9 test(agent): stop the cursor fixtures racing their own prompt write (MUL-5536) (#6174)
TestCursorExecuteFailsOnCleanEOFWithoutResult failed on main with
"cursor-agent prompt write failed: write |1: broken pipe" where it
expects "stream ended without terminal result".

The fake cursor-agent exits without reading stdin, so the prompt write
races the child's exit: win and the pipe buffer swallows it, lose and the
read end is gone and the write returns EPIPE. writeErr outranks both
exitErr and the generic no-terminal-result error in cursor.go, so a lost
race replaces the asserted failure with the EPIPE one.

A real cursor-agent reads stdin to EOF, so the fixtures now do too. That
removes the race rather than reordering production error precedence,
which is deliberate. Only the two fixtures whose expected error ranks
below writeErr need the drain.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 16:15:36 +08:00
Multica Eve
999e9f93c7 fix(codex): record file edit payload for patch_apply events (#6158)
* fix(redact): scrub secrets nested inside tool input maps and slices

InputMap only passed top-level string values through Text and documented
non-string values as "preserved as-is". Any secret one level down reached
the database and the WebSocket broadcast untouched:

    flat    -> [REDACTED ...]                       (scrubbed)
    nested  -> [map[diff:token=ghp_... path:a.go]]  (leaked verbatim)

This is a prerequisite for recording structured file-edit payloads. Codex
reports an edit as changes[]{path, diff, content}, and the legacy protocol
reports a deletion as the whole outgoing file — so without this, deleting a
.env would persist its full contents in cleartext.

redactValue now walks the composite shapes json.Unmarshal produces, plus
[]string and map[string]string for argv-style inputs. Composites are copied
rather than scrubbed in place, because the caller keeps using the map it
passed in.

Nesting depth comes from daemon-supplied JSON, so the walk is bounded at 32
levels; a pathologically nested payload would otherwise recurse until the
stack blows. Hitting the bound yields a placeholder rather than the raw
value, keeping the fail-safe direction.

Verified: the five new tests each fail against the previous top-level-only
implementation and pass now; full ./pkg/redact suite green.

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

* fix(codex): record file edit payload for patch_apply events

Both Codex protocol paths recorded a file edit as a bare call ID and set no
payload, so a run that edited six files left six blank, unexpandable rows in
the transcript. The same task on Claude or Grok showed a readable diff, and
the branch Codex pushed was the only surviving record of what it changed
(GH #6157).

The omission was specific to this one tool, not to the adapter: the
exec_command handlers directly above already captured command and output.

Both paths are fixed, since the protocol is sniffed at runtime. Their wire
shapes differ more than they appear, and the normalizer reconciles that:

  - legacy patch_apply_begin/end carry map[path]FileChange, internally tagged
    on `type`, where add/delete hold whole-file `content` and only update
    holds a `unified_diff` plus `move_path`. There is no diff for every case,
    so the normalized form keeps diff and content as alternatives.
  - v2 fileChange items carry an ordered array of {path, kind, diff} where
    `kind` is an object, not a string — reading it as a string silently
    yields "" and loses the add/delete/update distinction.
  - status spellings differ too: legacy is snake_case, v2 is camelCase and
    adds inProgress. Both normalize onto one vocabulary, and a legacy event
    predating `status` falls back to its `success` bool.

Legacy map iteration is sorted by path so a replayed event does not reshuffle
the file list.

Completion events now also produce a non-empty output (status, file count,
and any apply_patch stdout/stderr), because an empty output renders as an
unexpandable blank row just like a missing input.

Anything unrecognised — absent, wrongly typed, or malformed changes — returns
no payload, preserving exactly the previous degradation rather than risking
the transcript.

Total diff/content bytes are bounded at 64 KiB with UTF-8-safe truncation,
recording `truncated` and `original_bytes`; paths and kinds always survive,
since they are what a reviewer needs when the body is gone. The bound is
deliberately scoped to this new payload: other providers stream tool inputs
through unbounded, and clamping them here would silently truncate
transcripts that render correctly today. Unifying the limit at the
persistence boundary is left as a follow-up.

Verified: the new tests reproduce the reported symptom (Input:map[],
Output:"") against the previous call sites and pass now; ./pkg/agent and
./pkg/redact green, go vet and gofmt clean.

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

* feat(transcript): render Codex multi-file patch payloads as diffs

The presenter identified an edit by input shape — a top-level file_path plus
old_string/new_string or content — which is Claude's and Grok's shape. Codex
records one patch_apply covering several files as changes[], so even with the
payload now populated it fell through to pretty JSON instead of a diff.

A new `patch` detail kind carries one entry per file, since collapsing them
into a single body would lose which change belongs where. Each file reuses the
existing single-file surfaces, so all bodies behave alike inside the
virtualized list.

Codex hands over a ready-made unified diff, so parseUnifiedDiff maps it onto
diff rows rather than recomputing one — there is no before/after pair to
compare, and reconstructing both sides from the diff just to diff them again
would be circular. Hunk headers become `gap` rows, which is what they denote:
skipped unchanged content.

A deletion renders as all-removals rather than as a whole-file write, because
the legacy protocol reports it as the outgoing file's content and a green
"+N" gutter would state the opposite of what happened.

The collapsed row needed its own fix: with no single path field, the summary
fell through the preference chain and came back empty. It now reads as the
first path plus "+N more".

Anything that is not this shape still falls back to pretty JSON, so a payload
this presenter does not understand stays readable.

Verified: 17 new tests (43 in the presenter suite) pass; repo typecheck and
lint clean. The one failing views test, layout/sidebar-resize, fails
identically on an untouched checkout.

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

* fix(codex): route v2 add/delete payloads as content, not diff

Addresses review on #6158.

Upstream's format_file_change_diff only produces a unified diff for `update`.
For `add` and `delete` it returns the whole file's contents under the same
`diff` field, and for a moved `update` it appends a trailing
"\n\nMoved to: <path>" line:

    FileChange::Add    { content } => content.clone(),
    FileChange::Delete { content } => content.clone(),
    FileChange::Update { unified_diff, move_path } => ...

(codex-rs/app-server-protocol/src/protocol/item_builders.rs, rust-v0.145.0)

Recording that as a diff mislabels every line of an added or deleted file as
context, and actively inverts any line whose content begins with '+' or '-' —
so an added file containing "-minus lead" rendered as a deletion. The payload
is now routed by `kind` rather than by field name, and the "Moved to:"
sentence is stripped since move_path already carries the destination.

The previous v2 tests hid this by using a fixture the real protocol never
emits (an `add` carrying "@@ ... +package main"). They now use upstream's
shape, plus cases for delete, an empty add, and an add whose contents look
like diff headers.

Empty bodies are also kept on both paths: presence of the field, not its
non-emptiness, decides whether a body was reported, so an empty added file
renders as an empty body instead of "no content reported".

Verified: the new assertions fail against the previous normalizer — where an
`add` came through as {"diff": "package main\n"} — and pass now.

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

* fix(transcript): stop treating header-like content lines as file headers

Addresses review on #6158.

parseUnifiedDiff matched "---" / "+++" / "diff --git" / "index " at any
position, so a changed line whose *content* starts with a dash or plus was
silently discarded:

    parseUnifiedDiff("@@ -1 +1 @@\n--- old markdown\n+++ new markdown\n")
    // before: [{ kind: "gap", ... }] — both changed lines gone

A removal of "-- old markdown" is spelled "--- old markdown" on the wire, so
this hit Markdown rules, embedded patches, and comment banners.

File headers only exist ahead of the first hunk, so they are only recognised
there; once inside a hunk every line is parsed strictly by its first
character.

Also localizes the multi-file summary count, which was hardcoded English and
so leaked into the zh-Hans / ja / ko transcript rows. The presenter owns no
React and no i18n by design, so the phrasing is injected by the caller rather
than imported here, keeping the module unit-testable in isolation; the English
form remains the fallback. The three Chinese/Japanese/Korean truncation
strings now use "..." to match the English source they translate.

Verified: both new parser assertions fail against the previous
strip-anywhere behaviour and pass now; 47 presenter tests green, repo
typecheck and lint clean.

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

* fix(daemon): redact nested tool input before it leaves the daemon

Addresses review on #6158.

Recursive redaction ran only in the server's ingest handler. The daemon built
the new nested edit payload and sent msg.Input verbatim, so a daemon that
self-updated ahead of the server — or one talking to a server mid-rollout —
would ship whole-file edit contents to a peer that does not scrub nested
values yet. The legacy protocol reports a deletion as the whole outgoing file,
so that window covered a deleted .env in cleartext.

Ordering three commits inside one PR is not a deployment barrier, and daemon
and server upgrade independently. Deployment order is not a control we have,
so the sending side is now safe on its own; the server keeps redacting on
ingest as the second line of defence.

Scoped to Input, which is the field this PR newly fills with file contents.
Content and Output are plain strings already redacted server-side, and
changing their daemon-side handling would be unrelated to this fix.

Verified: the new daemon test asserts the nested token is masked in the
reported batch while the change metadata survives. It fails without this
change, reporting the full GITHUB_TOKEN= line on the wire, and passes with
it; ./internal/daemon green.

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

* fix(transcript): correct the Chinese multi-file patch count semantics

Addresses review on #6158.

The summary is handed the number of files *beyond* the named one, but the
Chinese phrasing stated a total: "a.go 等 2 个文件" reads as two files including
a.go, so a three-file patch under-reported by one. English hides the
distinction ("+2 more"), which is why it survived the first pass.

Rewords zh-Hans to "另有 N 个文件". Japanese (他) and Korean (외) already read
as "besides", so their wording is unchanged.

Also renames the interpolation variable from `count` to `extra`, for two
reasons. i18next treats `count` as the plural selector — this very namespace
relies on that for events_one/events_other — so a plain number had no business
borrowing it. And the name is what a translator reads: `extra` cannot be
mistaken for a total the way `count` was.

Guards the whole bug class rather than just this string: a locale test asserts
every locale interpolates {{path}} and {{extra}} and never the reserved
{{count}}, and a presenter test pins that the injected number is the count of
additional files, not the total.

Verified: both new locale assertions fail against the reverted string and pass
now; rendering the real locale strings for a three-file patch yields "+2 more",
"另有 2 个文件", "他 2 件", "외 2개". 53 target tests pass, repo typecheck clean,
views lint back to its pre-existing 16 warnings and 0 errors.

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

* fix(transcript): put the patch surface on the type scale

Addresses review on #6158.

The patch surface wrote text-[10px] / text-[11px] / text-[10px], copied from
the sibling transcript surfaces as they looked when this branch started. Since
then MUL-5451 (#6136) introduced a role-named type scale and migrated those
same siblings to text-micro, so these three call sites were the only remaining
arbitrary sizes — and the type-scale guard reports them precisely.

All three become text-micro. That matches the analogues they were copied from
now that those have moved: the FileWriteSurface line-count row, the
DiffDetailSurface header row, and the ToolDetailSurface body. It is also the
only correct target, since micro (11px) is the smallest step the scale defines
— there is nothing at 10px to map to.

Merges origin/main so the guard runs here rather than only in CI.

Verified: apps/web app/type-scale.test.ts 13/13 (it listed exactly these three
lines before), no `text-[` left in the file, repo typecheck clean, views lint
unchanged at 16 pre-existing warnings and 0 errors.

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

* fix(codex): redact patch bodies before applying the size budget

Addresses review on #6158.

The adapter sized and truncated the normalized changes, and redaction only ran
later — in the daemon before sending, then again in the server on ingest. That
order loses secrets that straddle the budget.

The PEM rule needs both markers to match:

    -----BEGIN[A-Z\s]*PRIVATE KEY-----.*?-----END[A-Z\s]*PRIVATE KEY-----

So a 70 KB private key whose BEGIN sits inside the first 64 KiB and whose END
falls past the cut stops matching once truncated. Neither later pass can
recognise what truncation already broke, so the marker and 64 KiB of key
material reach the database and the WebSocket broadcast. Measured on the
previous code:

    stored bytes 65536 | BEGIN marker present | key body present | placeholder absent

Redaction now runs first, and the budget measures the redacted bodies — which
is also the honest measurement, since those are what actually gets stored and
redaction usually shrinks them (that key collapses to 23 bytes, so no trimming
is needed at all). `original_bytes` still reports the pre-redaction size so the
reader sees how large the real patch was. The daemon and server passes stay as
defence in depth; redaction is idempotent, so running three times is safe and
that is now asserted.

Note for callers: codexPatchInput no longer trims its argument in place, because
redaction copies first. Two existing tests were asserting on the caller's
original slice and had silently become vacuous; they now read the returned
payload, and one pins the no-mutation contract. The delete fixture in the
diff-vs-content routing test was also a credential-shaped string, which now
redacts — it is plain text so that test keeps testing routing.

Verified: the new boundary test fails on the previous order, reporting the
surviving BEGIN marker and key material, and passes now. go test ./pkg/agent
./pkg/redact ./internal/daemon green; execenv ByteIdentical green; go vet and
gofmt clean.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 16:01:10 +08:00
Multica Eve
9072cef12c Revert "MUL-5493: feat(chat): add a visible follow-up queue (#6133)" (#6171)
This reverts commit b13657be71.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 15:37:13 +08:00
Bohan Jiang
d6bd4cf7d5 fix(agent): recover from Hermes resumed sessions it refuses without running (MUL-5509) (#6164)
* fix(agent): recover from Hermes resumed sessions it refuses without running

Hermes never reports an unknown ACP session as a JSON-RPC error. Its adapter
answers session/prompt for a session it cannot load with an ordinary success
frame carrying stopReason=refusal, and session/resume echoes nothing back --
ACP's ResumeSessionResponse has no sessionId field at all, unlike
NewSessionResponse -- so resolveResumedSessionID keeps the id we asked for.
Nothing in the exchange is an error, so the isACPSessionNotFound branches at
set_model and prompt time never fire for this backend.

The result was a Result carrying the dead session id with ResumeRejected=false,
which shouldRetryWithFreshSession reads as "not a rejection". GetLastTaskSession
then handed the same dead id to every later dispatch on that (agent, issue)
pair, so a Hermes agent was usable exactly once per issue: the first task
worked, and every following comment, approval or @mention failed the same way
until a manual rerun bought one more turn. When no provider error reached
stderr the turn was worse than a failure -- it reported completed with empty
output, an agent that silently did nothing.

Treat a refusal on a resumed session with zero agent activity as the runtime
telling us the session is gone: clear the session id, set ResumeRejected so the
existing fresh-session retry and session-retirement path take over, and fail
the turn instead of reporting an empty success. Both conditions are required --
stopReason=refusal alone is a legitimate model refusal, and a refusal after
real work is not a lost session.

The reason is applied after promoteACPResultOnProviderError so a captured
provider error stays the user-visible message; the generic fallback only fills
in when nothing more specific was seen.

Verified against the real hermes CLI (upstream main ba7d214b6) on an isolated
HERMES_HOME with a local mock endpoint: a fresh task completes, and resuming it
now yields status=failed, SessionID="", ResumeRejected=true with the provider
error preserved -- previously the dead id came back with ResumeRejected=false.

Refs GH #6150, MUL-5509

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

* fix(agent): decide Hermes resume loss after the pipe drain, not at quiescence

The notification quiet window closing does not end a turn — stdin EOF and the
pipe drain do, and Hermes legitimately delivers a turn's final chunk in that
gap (TestHermesBackendDrainsLateFinalNotificationAfterPromptResponse exists
because of it). Freezing the resume-loss decision at the quiescence boundary
therefore read turnActivity == 0 while a real answer was still in flight: a
runtime that merely answered slowly after stopReason=refusal had its healthy
session id cleared and ResumeRejected set, discarding a live conversation
pointer and, with no tool use recorded, triggering an unnecessary fresh-session
retry that re-ran the turn.

Move the evaluation to the point where the turn has settled — after
waitForHermesPipeDrain and streamingCurrentTurn.Store(false), where every
accepted update has been counted. This also drops the resumeLost variable and
the ordering hazard that came with it.

The new regression sends the late chunk 600ms after the refusal, past the 250ms
quiet window and inside the 2s drain grace, and asserts the session id survives
with ResumeRejected=false. Confirmed to fail against the previous ordering with
exactly the reported symptoms (session id emptied, ResumeRejected=true).

Refs GH #6150, MUL-5509

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

---------

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 15:23:36 +08:00
TeAmo
b13657be71 MUL-5493: feat(chat): add a visible follow-up queue (#6133)
* feat(chat): add a visible follow-up queue

Add a visible, manageable FIFO follow-up queue for Web and Desktop chat while preserving the existing per-session scheduler and backward-compatible pending-task response.

* fix(chat): preserve queue after deferred cancellation

---------

Co-authored-by: TeAmo <liu.junhui3@iwhalecloud.com>
2026-07-30 15:14:23 +08:00
YYClaw
ee7ba83f53 fix(self-host): apply setup config to daemon (MUL-5269) (#5880)
Fixes two connected self-host setup failures in local worktree development.

Generated worktree environments now expose the backend HTTP origin through
MULTICA_PUBLIC_URL, and existing generated worktrees derive the missing value
at startup through both scripts/local-env.sh and the Makefile. Explicit
values, including an intentionally empty same-origin setting, are preserved.

setup and setup self-host now reconcile an existing same-profile daemon after
authentication so it loads the newly written server URL and token. An idle
daemon is restarted behind the existing restart preflight; when active tasks
exist, setup leaves the daemon running to avoid cancelling work and prints an
actionable profile-aware restart command instead.

Closes #5879
2026-07-30 15:10:40 +08:00
Multica Eve
74d5fc41d8 fix(daemon): discover qodercli via the login shell (MUL-5524) (#6163)
Qoder is a fully supported provider, but a GUI-launched daemon could never
detect it. Two gaps, both in agent discovery:

- probeAgentCLIs called resolveAgentExecutablePath directly for qoder instead
  of going through the shared probe() helper, so qoder was the only provider
  with no login-shell fallback. A daemon started from Finder/Launchpad (the
  apple.dmg desktop build) does not inherit the interactive shell PATH, so a
  qodercli in an npm global prefix or any ~/.zshrc-added dir stayed invisible
  no matter how often the daemon restarted.
- "qodercli" was missing from defaultAgentCommandNames, which is the only list
  cachedShellResolvedAgents asks the login shell about. Even with the fallback
  wired up, the resolver would not have looked for it.

TestDefaultAgentCommandNamesCoversAllProbes was supposed to catch exactly this,
but it parsed config.go for probe() calls and silently became a no-op when
probeAgentCLIs moved to agents_probe.go. It now parses agents_probe.go and
asserts it found at least one probe() per default command, so a future move
fails loudly instead of passing vacuously.

Pinned-path semantics are unchanged: an absolute/relative MULTICA_QODER_PATH
that does not exist stays a hard miss rather than silently resolving a
different binary.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 14:50:02 +08:00