Commit Graph
291 Commits
Author SHA1 Message Date
20ceaccc43 MUL-6471: fix(daemon): let pi and opencode reach custom providers (#7312)
* fix(daemon): let pi and opencode reach custom providers (MUL-6471)

A self-host setup whose pi/opencode providers point at an
OpenAI/Anthropic-compatible gateway could not run a Multica task on either
runtime, while the same work succeeded when the CLI was invoked by hand in
the same container (GH #7300). Gateway-style providers register model ids
that themselves contain a slash — `claude/claude-opus-5` under provider
`multica-anthropic` — and both failures came from treating that slash as a
provider boundary, or from having no provider at all.

pi: buildPiArgs split the model id on the first slash and passed the leading
segment as --provider, so a slash-shaped id became `--provider claude --model
claude-opus-5`. pi answers an unknown --provider with a hard error before it
ever looks at --model. The selector now goes to --model whole and --provider
is never synthesized: pi's own resolver accepts `provider/id`, a bare id, and
an id containing a slash, so passing less is strictly more capable. Verified
against pi 0.67.2 — the old argv reproduces `Unknown provider "claude"`, the
new one resolves to provider=multica-anthropic, model=claude/claude-opus-5.

opencode: --model is `provider/model`, and an id persisted without its
provider is rejected outright (`UnknownError`, reproduced against opencode
1.18.14). The daemon cannot invent the missing segment, so it asks the
runtime's own catalog: an id that exactly one provider claims is promoted to
the catalog's canonical selector, and anything uncertain — a static fallback
catalog, an already-canonical id, two providers exposing the same bare id —
is passed through untouched for the CLI to judge.

Two reported side effects fall out of the same root cause. `thinking_level`
was dropped for both runtimes because the capability lookup is keyed on the
model id and missed every catalog entry; it now matches. And `starting agent`
logged entry.Model, the env-var tier alone, so every task whose model came
from agent.model announced itself with an empty model — the resolution now
happens before the log.

The task row's `model: null` is not part of this: it is read from the run's
usage rows, which a run that fails before its first provider call never
produces.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): gate selector qualification on the runtimes that can use it

Review follow-up on MUL-6471. The first pass ran catalog discovery for every
task that pinned a model, on the argument that the path already takes one
memoized lookup for thinking_level / service_tier. That argument does not
hold: cachedDiscovery deliberately skips the cache for empty and fallback
results (#3729, MUL-5549), precisely so a transient failure can retry. A
logged-out or failing runtime would therefore pay its full discovery ceiling —
15s for opencode/pi/omp, 30s for openclaw — on every task, including runtimes
whose bare model ids could never be rewritten anyway.

Qualification is now an explicit runtime capability. ModelIDsAreProviderQualified
is true only where Model.Provider is literally the ID's own prefix: pi builds
`provider + "/" + id`, opencode reads the provider back off the id. Runtimes
carrying a bare id next to a display-name Provider — claude's "anthropic",
copilot's "openai", dsh's "DeepSeek" against a `deepseek-official/...` id —
never reach discovery. Built-in runtime identities resolve through their
protocol family, so a pi-family fork like omp inherits the capability from its
descriptor rather than a name check.

The daemon side moves into qualifyTaskModel so the call boundary is testable.
TestQualifyTaskModelSelectorsAndDiscoveryBoundary pins both halves: pi,
opencode and omp resolve to the selector their CLI accepts, and claude, codex
and an empty model never reach listModels at all.
TestModelIDsAreProviderQualifiedMatchesCatalogShape keeps the capability
honest by deriving the expected answer from the real catalog producers instead
of restating the list — it fails if the predicate and the emitted ID shape
ever disagree.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): read a runtime's model catalog at most once per task

Second review round on MUL-6471. Two findings, one root cause: the daemon
treated catalog discovery as cheap when cachedDiscovery deliberately makes it
anything but — an empty or fallback result is never memoized (#3729,
MUL-5549), so every read costs a logged-out or timing-out runtime its full
15-30s ceiling again.

A single task could pay that twice. qualifyTaskModel read the catalog to
promote the model selector, then ValidateThinkingLevel read it again to check
the level — back to back, on exactly the reporter's configuration (pi or
opencode plus thinking_level=high). The two now share one lazily-loaded,
task-scoped catalog: resolveTaskModelSelection owns it, and the validators
take that loader through ValidateThinkingLevelWith / ValidateServiceTierWith
rather than reaching for ListModels themselves. The global immediate-retry
contract is untouched — the reuse lives and dies with the task.

The second finding removes the read entirely for pi. Qualification is not a
launch requirement there: buildPiArgs already proves pi's resolver takes a
canonical selector, a bare id, and an id containing a slash, so a pi task with
no capability override now starts without spawning a discovery subprocess at
all. ModelIDsAreProviderQualified is replaced by
ModelSelectorMustBeProviderQualified, which states the contract that actually
justifies the cost — the CLI refuses an unprefixed id — and is therefore true
only for opencode and its DevEco fork. That also retires the catalog-shape
claim the old predicate made, which deveco was already a counterexample to.

The resulting matrix, pinned by
TestResolveTaskModelSelectionReadsTheCatalogAtMostOnce: pi/opencode with a
thinking level qualify and validate on one read; pi, omp, claude and
unpinned-model tasks read nothing; opencode without an override still reads,
because it cannot launch otherwise; codex without a model still fails both
checks closed without reading. A failed read is not retried inside the task.
Verified the count assertion bites by removing the memoization and watching
the pi and opencode cases report 2 reads.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 18:01:53 +08:00
e1be1625cb MUL-5959: feat(agent): add Dim (DimCode) ACP runtime (#6675)
* feat(agent): add Dim (DimCode) ACP runtime

Add Dim (dimcode, the `dim` CLI) as a first-party agent runtime, driven
over the ACP (Agent Client Protocol) transport via `dim acp`.

Key integration points:
- New dim backend (pkg/agent/dim.go): spawns `dim acp`, performs the
  ACP initialize/session/new handshake, and raises the runtime's
  hardcoded read-only permission preset to full-access (plus agent mode)
  via session/set_config_option before the first prompt — without it every
  file write is denied by a capability rule. Model override uses
  session/set_model; the model catalog is read from session/new.
- Session resume is intentionally skipped: Dim binds sessions to the
  creating process, so a later process's session/load is rejected with
  "held by another process" even after a clean session/close. The backend
  always starts a fresh session and reports ResumeRejected so the daemon
  classifies the run correctly. A best-effort session/close keeps Dim's
  own session list free of orphaned entries.
- Registration: SupportedTypes whitelist, New() factory, launch header,
  daemon probe (MULTICA_DIM_PATH / MULTICA_DIM_MODEL), default agent
  command name, protocol_family CHECK migration (255), model discovery,
  MCP config support, metrics label, and runtime display.
- Docs/UI: provider logo, landing i18n (en/zh/ja/ko), providers and
  install-agent-runtime docs, README, CLI_AND_DAEMON, SELF_HOSTING.
- Tests: dim backend unit tests (fresh session + resume behavior), shared
  ACP deliverable case, logo and MCP-support tests.

* feat(views): use official DimCode app icon for Dim provider logo

Replace the placeholder inline SVG with the official DimCode desktop
client app icon (from /opt/DimAgent/resources/build/icon.png, resized
to 128px), imported as a static asset the same way the Qwen Code mark
is handled.

* test(agent): add real dim ACP smoke tests (agentintegration)

Two gated tests behind MULTICA_RUN_REAL_AGENT_SMOKE=1:

- TestDimRealACPSmoke: full end-to-end against real `dim acp` —
  initialize, session/new, set_config_option (permission/mode), prompt,
  and assert completed output.
- TestDimRealResumeRejected: pass a fake ResumeSessionID, assert the
  backend starts a fresh session (no session/load), and reports
  ResumeRejected=true so the daemon classifies the run correctly.

Verified against dimcode 0.3.2 and 0.3.8.

* fix: renumber dim migration 265 → 271 (upstream added 265-270)

* fix: renumber dim migration to 272 (upstream added 271)

* fix(agent): dim cross-run resume via session/load + review fixes

Address review feedback on the Dim ACP runtime:

- #1 Deliver runtime brief to Dim: add dim to the AGENTS.md provider
  list (verified dim 0.3.8+ reads AGENTS.md from the session cwd).
- #2 Cross-run session continuity: dim 0.3.10+ releases its per-process
  session lock ~5s after the owning process exits, so resume now goes
  through the standard ACP session/load (same as traecli/kiro/grok)
  instead of always starting fresh. A loaded session retains its
  permission/mode, so set_config_option runs only on fresh sessions.
  Add a two-run regression (TestDimRealCrossRunResume) proving run B
  recalls context established only in run A.
- #3 Process lifecycle races: the deferred cleanup now cancels the run
  context before cmd.Wait (a child ignoring stdin EOF no longer hangs
  Result delivery) with a bounded force-kill fallback; the success path
  waits for the final prompt notification with a grace window instead
  of a non-blocking read that could miss the last message.
- #4 Isolate model discovery by executable path (discoveryCacheKey).
- #6 Real smoke test now writes a sentinel file to prove full-access is
  effective; a best-effort session/close is sent before teardown.
- #7 gofmt.

* test(agent): add dim process-lifecycle regressions (review #3)

- TestDimCleanupKillsHangingChild: a child that ignores stdin EOF and
  SIGTERM after an early failure is force-killed within
  dimProcessWaitTimeout, so Result still closes.
- TestDimPromptMissingNotificationStillCompletes: when session/prompt
  returns without stopReason (onPromptDone never fires), the bounded
  final-notification wait falls through and Result completes instead of
  hanging.

* fix(migrations): renumber dim runtime_profile migration 272 → 273 (upstream added 272)

* fix(agent): review round 2 — process tree, quiescence, fail-closed, version check

Address all remaining blockers from the second review:

- #1 Require dim >= 0.3.10 (version check at initialize); bounded retry
  on session/load when the lock is not yet released instead of silently
  starting fresh; integration test no longer sleeps before resume.
- #2 Process-tree cleanup: configureProcessGroup + signalProcessGroup +
  waitProcessGroupGone; cmd.Cancel returns nil so we own all signalling;
  regression asserts the descendant PID is gone.
- #3 Notification quiescence: onActivity + waitForACPNotificationQuiescence
  (same as hermes); Dim equivalent of the late-final-notification test.
- #4 Permission fail-closed: set_config_option runs on BOTH fresh and
  resumed sessions; on config failure, session/close is sent before
  returning so a partially configured session is not resumed; regression.
- #5 Migration renumbered 273 → 274 (upstream added 273).
- #6 AGENTS.md mapping regression, two-executable cache isolation test,
  session/close unit assertion, smoke test now executes a command.
- #7 README.md + CLI_AND_DAEMON.md updated with 22 CLIs including Dim.

* chore: remove temporary review actions doc (not for PR)

* fix(migrations): renumber dim migration 274 → 310 (upstream added 274-309)

* review: fix retry break scope, dedup isACPSessionNotFound comment, update stale notes

* fix(agent): pass *exec.Cmd to signalProcessGroup/waitProcessGroupGone (upstream signature change)

* fix(agent): close session on set_model failure + resume regression test

Audit-found gaps from self-review:
- set_model failure now sends session/close before returning (same as
  set_config_option failure) — reviewer #4 said 'permission, mode, or
  model'.
- Add TestDimConfigFailThenResumeReestablishes: run A config fails →
  session/close sent → run B resumes and re-applies set_config_option
  (fail-closed), completing successfully.
- Fix two stale comments that contradicted the code (config block now
  runs on both fresh and resumed sessions).

* fix(agent): add WaitDelay, use labeled break in retry loop

Self-audit improvements:
- Add cmd.WaitDelay=10s for consistency with claude.go (hard backstop
  if a process somehow survives SIGKILL).
- Use labeled break (break loadRetry) so runCtx cancellation during the
  retry delay exits the for loop directly, not just the select.
- Update PR description: migration 273→310, set_config_option now
  re-applied on both fresh and resumed sessions.

* fix(migrations): renumber dim migration 310 → 313 (upstream added 310-312)

* fix(migrations): remove stale 313 dim migration (replaced by 314 after upstream added dsh at 313)

* fix: DSH compatibility + thinking levels + reviewer round 3

Systematic fix of all 39 items from the compatibility gap analysis:

Rebase + migration:
- Rebase to latest upstream/main (resolves all DSH conflicts)
- Migration 314_runtime_profile_add_dim (whitelist includes both dsh + dim)
- Remove stale 313 dim migration

DSH + Dim coexistence (provider lists, counts, docs):
- SupportedTypes, config.go, metrics labels: both dsh + dim
- i18n (4 files): count 23, lists include DSH + Dim + Oh-My-Pi
- README.md/README.zh.md: count 23
- CLI_AND_DAEMON.md: 23-row table
- environment-variables.mdx (4 langs): MULTICA_DIM_PATH/MODEL callout
- display.ts, mcp-support.ts, types/agent.ts, provider-logo.tsx: both dsh+dim

Thinking levels (was MISSING — dim supports thought_level):
- dim.go: call applyACPEffortOption after set_model
- thinking.go: add dim to acpCatalogThinkingProviders
- dim.go: retain sessionResult for effort option

Reviewer round 3:
- Version check fail-closed: empty/malformed → reject (not allow)
- Cache test through ListModels call site with fake executables
- set_model failure regression test
- Test companions: sidecar_manifest_test, runtime_config_test add dim

Code quality:
- agent.go: fix unreachable duplicate return (rebase artifact)
- dim.go: labeled break in retry loop, WaitDelay, session/close on set_model fail

* fix: deliverable test fake version + provider-logo rebase artifact

* fix(migrations): renumber dim migration 314 → 315 (upstream added 314_workspace_mcp_config)

* fix(migrations): renumber dim migration 315 → 319 (upstream added 315-318)

* fix: Windows process tree, MinVersions, retry tests, migration 327

Address all 5 remaining blockers from review round 4:

1. Windows process-tree ownership: use startOwnedProcessTree instead of
   cmd.Start(), releaseProcessGroup in cleanup defer. This attaches the
   child to a Job Object on Windows (no-op on Unix), ensuring descendants
   are captured and terminated.

2. Migration renumbered 319 → 327 (upstream added 319-326).

3. Add dim: 0.3.10 to MinVersions in version.go so the daemon registers
   old Dim binaries as offline and refuses triggers (defense in depth on
   top of the ACP agentInfo.version check in dim.go).

4. Session-lock retry contract tests: success after bounded retries,
   exhaustion without falling through to session/new. Fake script
   supports DIM_LOAD_HELD_N (held for N calls then succeed) and
   DIM_LOAD_HELD_ALWAYS (always held).

5. README.md:206 20→23 runtimes. PR description updated.

* test: add missing reviewer-requested regressions (round 4)

- Windows Job Object regression: dim_windows_test.go proves the Dim
  backend captures and terminates descendants via startOwnedProcessTree
  (Windows-only build tag; companion to TestStartOwnedProcessTree).
- MinVersions registration: add dim test cases to TestCheckMinVersion
  (0.3.10 ok, 0.3.9 rejected, invalid rejected).
- Retry cancellation: TestDimSessionLoadRetryCancelled cancels the
  context during the retry delay, asserts no session/new fallback.

* fix: sync upstream Command signature changes + restore dim additions

Upstream changed ListModels/discoverXxxModels/detectCLIVersion to accept
Command (struct with Path+Prefix) instead of string. The previous rebase
kept our old-signature versions, causing widespread compile failures.

Restored all affected files from upstream, then re-applied dim-specific
additions:
- agent.go: dim in SupportedTypes, New(), launchHeaders
- hermes.go: isACPHeldByProcess + -32002 in isACPSessionNotFound
- thinking.go: dim in acpCatalogThinkingProviders
- models.go: dim case in ListModels + discoverDimModels
- version.go: dim in MinVersions
- All dim test files preserved (dim_test.go, dim_integration_test.go,
  dim_windows_test.go, models_test.go dim cache test)

* fix: rebase to latest main, migration 327→341, resolve DetectVersion conflict

* fix: remove unused os/exec import in dim_windows_test.go

* fix(migrations): renumber dim migration 341 → 342 (upstream added 341)

* fix: use Command.exec instead of exec.CommandContext (upstream GH #7046)

Upstream's TestOnlyLaunchGoSpawnsRuntimeProcesses requires all backends
to build processes through Command.exec (launch.go), so a custom runtime's
fixed_args are carried into the subprocess. Dim was the only backend still
using exec.CommandContext directly.

* fix: launch prefix policy + remove empty test files + deterministic retry tests

Address all 4 findings from review round 5:

1. Add "dim": dimBlockedArgs to launchPrefixBlockedArgs so protocol-breaking
   flags (--help/--auth-setup/--remote) are filtered from fixed_args.
2. Remove 77 zero-byte *_test.go files accidentally added at repo root.
3. Make retry tests deterministic: TestDimSessionLoadRetryCancelled waits
   for the first session/load request before cancelling (no fixed sleep);
   TestDimSessionLoadRetryExhausted asserts exactly 4 load attempts.
4. PR description will be updated separately.

* fix: rebase to latest main, migration 342→343 (upstream added mcode at 342)

* chore: trigger CI

* chore: refresh PR

* fix: restore mcode that was lost during merge conflict resolution

Merge took 'ours' side which predated mcode. Restore mcode in:
- SupportedTypes, New() factory, launchHeaders
- agent_supported_types_test.go want map
- metrics/labels.go
- config.go defaultAgentCommandNames
- migration 343 up/down whitelists

* fix: restore mcode probe in agents_probe.go + agent-cli-command-names.txt

* fix: restore all remaining mcode references lost during merge

Files fixed:
- README.md: mcode row in runtimes table
- mcp-support.test.ts: mcode assertion
- config.go: mcode in Agents comment + error message
- runtime_config.go: mcode in AGENTS.md case
- version.go: mcode in MinVersions
- version_test.go: mcode test cases

Verified: no file has fewer mcode references than upstream.

* fix: restore mcode in environment-variables docs + CLI_AND_DAEMON ACP list

- environment-variables.{mdx,ja,ko,zh}: restored 'MiniMax Code' in the
  QwenPaw model-variable sentence
- CLI_AND_DAEMON.md: added MiniMax Code to ACP-family list + loadSession
  fallback description

Verified: every changed file now has >= mcode references vs upstream.

* fix(migrations): renumber dim migration 343 → 344 (upstream added 343)

* fix(migrations): renumber dim migration 344 → 348 (upstream added 344-347 plugin migrations)

* fix(migrations): update down migration comment 344 → 348

* chore: retrigger CI (flaky TestOpenclawDiscoveryCacheConcurrentPreparations — upstream test, passes locally x5)

* fix(migrations): renumber dim migration 348 → 352 (upstream added 348-351)

* fix: Dim launch-prefix regression test + version.go comment repair (review #6)

- Add TestDimLaunchPrefixFiltersBlockedFlags: proves allowed prefix
  reaches command before acp, and --help/--auth-setup/--remote/-h are
  stripped from Dim's launch prefix.
- Add dim to TestLaunchPrefixReachesACPFamilies family list.
- Repair version.go: dim's cross-run session/load comment was appended
  to mcode entry during a rebase conflict; restore two independent
  accurate comments.

* fix(migrations): update down migration comment 348 → 352

* chore: retrigger CI (backend-tests stuck 50+ min)

* fix(migrations): renumber dim migration 352 → 362 (upstream added 352-361)

* fix: use logAgentCommand for dim argv logging (upstream redaction requirement)

Upstream's TestOnlyLaunchGoLogsAgentCommandArgs enforces that all
runtime argv logging goes through Config.logAgentCommand so sensitive
args are redacted. dim.go was still using Logger.Info directly.

* fix(migrations): renumber dim migration to 370

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 12:54:46 +08:00
3c3e77b00c MUL-6362: redact secrets from provider command logs (#7206)
* fix(agent): redact provider command values

Co-authored-by: multica-agent <[email protected]>

* fix(agent): preserve safe command diagnostics

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 16:07:53 +08:00
Bohan Jiang 4040345e09 perf(ci): path-filter the backend jobs to skip irrelevant PRs (MUL-6389) (#7198)
Adds a `backend` path filter to the `changes` job and gates backend-tests, go-vulnerability-scan and windows-execenv on it, so a PR that never touches server/ no longer pays for a `go test -race` run, govulncheck and a Windows runner.

The filter is deliberately wider than `server/**`: four Go tests read three files outside the module (packages/core/runtimes/cli-version.ts, scripts/agent-cli-command-names.txt, scripts/go-test-with-agent-cli-guard.sh), and each must be listed or the PR that breaks such a contract is exactly the PR that skips the test.

Steps are gated rather than jobs, matching the existing frontend/sqlc-check pattern: the aggregate check reports a genuine green instead of staying pending, and still fails when a job goes missing for a reason other than the path filter.
2026-08-19 14:39:33 +08:00
5f4ba41d13 MUL-6323 fix(views): stop blaming the daemon when the backend is the stale half (#7126)
* fix(views): stop blaming the daemon when the backend is the stale half (MUL-6323)

A self-hosted user on macOS Desktop could not pick worktree mode, and the
dialog explained why: "the Multica on that machine is v0.4.28; parallel needs
0.4.24 or newer". v0.4.28 was the newest release at the time. There was no
version left to upgrade to (#7113).

Nothing has gated on a version since MUL-5707 moved all three gates onto the
declared local-worktree-v1 capability. The floor survived only as the number
this copy interpolated, so every capability miss — whatever caused it — was
reported as a version shortfall the user had already cleared.

Underneath was a second problem the copy could not express. The capability is
recorded by the SERVER at registration, and servers before v0.4.25 write
runtime metadata with no capabilities key at all. Desktop ships its own
renderer AND its own daemon and updates both together, so the client routinely
runs ahead of a backend the operator upgrades by hand: the daemon advertises
the capability, the old backend drops it, and the UI blames the machine that
was already newest. Upgrading it changes nothing.

So the gate now answers with LocalWorktreeSupport instead of a boolean, and
the discriminator is the PRESENCE of the capabilities key rather than its
contents — a capability-aware server always writes it, null when the daemon
sent no header, so a row without it can only have come from an older server.
The two negatives get their own copy pointing at the half that can actually be
fixed, and no message quotes a daemon version any more; the server's own 422
was rewritten the same way in #6904.

Also records capabilities on runtime-profile registration failure rows. Those
are written after the successful runtimes of the same request, so until the
first heartbeat they are the newest row for the daemon — which is the row
every worktree gate reads. A failed custom profile briefly made a capable
daemon look incapable, and would now have made a current backend look stale.

Removes localWorktreeSupported and MIN_LOCAL_WORKTREE_CLI_VERSION from the
frontend: the first had no callers left, the second only fed the copy this
change deletes.

Co-authored-by: multica-agent <[email protected]>

* fix(views): ask the live backend whether it records capabilities (MUL-6323)

Review follow-up. The first pass read a missing `capabilities` key as "this
backend predates the handshake", but the key's absence only proves the server
was old WHEN THE ROW WAS WRITTEN. Upgrading the backend does not rewrite
runtime metadata and heartbeats touch only last_seen_at, so an upgraded
deployment would keep being told it was too old — and upgrading it again would
change nothing. The daemon does not re-register on a timer, so that state can
sit there indefinitely.

The question is now asked of the server actually in front of us, via
/api/config's server_version, and the stale-row case gets its own answer: both
halves are fine, the machine just has to register again. An ABSENT version
reads as capability-aware, which is the load-bearing default — /api/config
omits the field on the managed cloud by design and leaves it empty for
unstamped dev builds, while the field itself shipped in v0.4.2, 23 patches
before the handshake. So every self-hosted release that could genuinely be
blind still names itself, and cloud users are never told to upgrade a backend
they do not run.

Replaces the cross-side version-floor test rather than deleting the contract.
It pinned MIN_LOCAL_WORKTREE_CLI_VERSION against a frontend twin that this
branch removed, and the number it guarded is one the UI no longer shows. What
the two sides must still agree on is the capability token: the daemon
advertises that exact string, the server stores it, the UI looks for it, and a
typo on either side disables worktree mode for everyone with no error anywhere.

Also corrects a comment that claimed no version numbers appear in this copy —
the backend floor does, deliberately, because it is what an operator upgrades
past.

Co-authored-by: multica-agent <[email protected]>

* fix(views): stop assuming an unnamed backend records capabilities (MUL-6323)

Review follow-up. Reading an absent server_version as "capability-aware" was
right for two of its three sources and wrong for the third: a self-hosted
server older than v0.4.2 — where the field itself shipped — reports no version
AND predates the handshake, so it records no capabilities either. Such a
deployment got told to restart Multica on the machine, which re-registered
against the same blind server and wrote the same key-less row. A loop whose
only real exit, upgrading the backend, the copy never mentioned.

So awareness is three-valued now, and an unnamed server is resolved from
evidence rather than assumption: if ANY runtime row in the workspace carries a
capabilities key, some capability-aware server wrote it and servers move
forward, so the deployment is aware whoever the row belongs to. That settles
the managed-cloud shape — no version reported, several machines, one of them
registered before the handshake — without a version to read. The converse is
never inferred; every row missing the key is equally what an old self-hosted
server and a one-machine workspace look like.

When nothing identifies the backend the UI says so and names both remedies,
still failing closed. Guessing wrong in either direction sends someone to
upgrade a backend they do not run, or to restart a machine that was never the
problem.

Also rewrites the re-register copy, which explained itself in the vocabulary of
the handshake rather than the user's.

Co-authored-by: multica-agent <[email protected]>

* refactor(views): let the server answer whether a machine can run worktree (MUL-6323)

The client-side gate kept growing because it was trying to predict an answer
the server already gives. Each round added a state: capability present or
absent, then the backend's own version to interpret the absence, then evidence
from other rows to interpret an unnamed backend. Three layers of inference over
data the backend wrote, to avoid asking the backend.

So it asks. The UI no longer blocks worktree mode on capability at all — only
on `not_git`, which is local, certain and already checked by the picker. The
mode stays selectable, the save is validated server-side, and the rejection
lands in the dialog's existing errorMessage slot with the wording #6904 wrote
for it. That deletes the awareness tri-state, the workspace-wide evidence scan,
the server-version plumbing, three UI reasons and four copy strings in four
languages.

This also fixes the reported case outright rather than explaining it: a
self-hosted backend older than the handshake runs the version gate it shipped
with, which accepts a v0.4.28 daemon, so the user simply gets worktree mode
instead of an instruction to upgrade something.

The capability signal survives for one job — deciding whether to PRESELECT
parallel mode — where being wrong costs a radio button rather than a blocked
user. A machine that has not advertised is not preselected into a mode whose
rejection would fail the whole project creation, but it stays selectable,
because an old row is not proof the machine cannot.

For any of that to be true the bundled create path had to actually ask: POST
/api/projects attaches resources in one transaction and never ran the worktree
gate that POST/PUT /resources have always run, so it would happily store a mode
the machine cannot run and leave the claim gate to cancel the task later. It
runs the same gate now, before the transaction opens.

Co-authored-by: multica-agent <[email protected]>

* fix: refuse worktree mode until the server says it will honour it (MUL-6323)

Review caught a safety regression in the previous commit, and it is the worse
half of this issue rather than a variant of it.

Deferring to the server assumed the server would object. Releases before
v0.4.25 do not: `validateLocalDirectoryRef` unmarshals the ref into a struct
with no ExecutionMode field and re-marshals it, so `execution_mode: "worktree"`
is silently DROPPED and answered 201. The daemon then reads a ref with no mode,
treats that as in_place by design, and edits the working copy the user asked it
to stay out of. No save gate, no claim gate, no error — on the exact
new-Desktop-old-backend pairing this issue is about. My earlier claim that such
a server would run its own version gate was wrong: worktree shipped in v0.4.25,
so v0.4.24 has no concept of the mode at all.

The client cannot tell that success from a real one, so it asks first.
/api/config now carries local_worktree_supported, a property of the build: if
this code is running, the save gate is running with it. Absent — which is what
every too-old server sends — blocks the mode with copy naming the server, and
the schema parses the missing key to false rather than undefined so a consumer
cannot forget to coerce it.

That keeps the simplification: one declared boolean from the live server, no
inference over rows or version strings, and per-machine capability still left
to the server's save-time gate.

Also clears review nits: a comment still naming the removed localWorktreeSupport
and stray blank runs in two test files.

Co-authored-by: multica-agent <[email protected]>

* fix(views): rename a local directory without resending its ref (MUL-6323)

Review follow-up, and the same failure through a door the last commit left
open. The client now refuses worktree mode when the server cannot promise to
honour it — but renaming an EXISTING worktree resource sent the whole
resource_ref back, capability check or not.

The server replaces the ref with whatever it can parse. On a backend rolled
back below v0.4.25 — documented as supported while the runtimes stay current —
that means execution_mode is dropped and answered 200, and the next task edits
the working copy the resource asked to isolate. The user changed a display
name; nothing told them isolation went with it.

So rename stops sending the ref at all. The update endpoint has always treated
an omitted resource_ref as "keep the stored one", including in v0.4.24, so the
mode survives on every server version and no capability check is needed for a
rename. It also closes the general shape of this: any unrelated edit that
resends a ref gets it re-normalised by whatever the server understands today.

The label therefore moves to the top-level column, which is what the API
already offered for exactly this, and display now reads that first and the
ref's copy second — so rows created before this keep the name they were given
until someone renames them.

Also corrects wording review flagged as misleading: the config flag is absent
on every server that predates the SIGNAL, not only on those that predate the
fix. Releases between the two do gate the save but cannot say so, and are
treated as unable — the client cannot tell them apart, and only one of the two
guesses is safe.

Co-authored-by: multica-agent <[email protected]>

* fix(server): converge the two homes of a local directory's display name (MUL-6323)

Review follow-up. Renaming stopped resending the ref so old servers cannot
strip execution_mode — but that left the name with two writable homes: the
top-level label column (what newer clients write) and the copy inside
resource_ref (what desktop <= v0.4.28 both reads first and writes renames
into). One row could show different names on different devices, permanently,
and clearing a name resurrected the ref copy.

The server is the only writer that sees both client generations, so it now
converges the copies on every local_directory write:

- a label-only rename patches the ref's copy too — as a raw-map edit around
  the other keys, so execution_mode and fields written by a NEWER server
  survive byte-for-byte (re-marshaling through this binary's struct is the
  exact silent-drop this PR exists to close);
- a resent ref whose embedded label CHANGED is the legacy rename shape, and
  the column follows it; an unchanged embedded label (the mode dialog spreads
  the stored ref back) never overwrites a column rename — the write heals the
  row toward the column instead;
- clearing removes both copies, while a column that was merely never set
  keeps its hands off the ref: for rows created by older clients the ref
  copy IS the name.

The display-order helper moves to its own pure module shared by the resources
section and the chat hint (review nit), with the read-order matrix pinned in a
node-environment test beside it.

Co-authored-by: multica-agent <[email protected]>

* fix(server): tell a rename apart from a ref that went stale (MUL-6323)

Review follow-up. Convergence read "the ref arrived with a different label" as
"an old client renamed this", but that is also what a stale snapshot looks
like. The execution-mode dialog captures the whole ref when it OPENS and sends
it back on save, so: device A opens the dialog, device B renames the folder,
device A saves a mode — and the server writes B's rename back to the old name,
from a dialog that has nothing to do with naming.

Only a ref whose SOLE difference is its label counts as a rename now. Anything
else is an edit carrying a name it happens to have been holding, so the row
keeps whatever it is called today — in both homes, which also heals the stale
copy the request brought. Compared as decoded values, and unknown keys count:
a difference this binary cannot interpret is still a difference, and reading
such a request as a pure rename would be a guess.

The same classification fixes a second complaint from the other direction. An
old client's rename resends the ref, so the worktree capability gate refused it
whenever the machine's runtime registration had drifted — failing an edit that
changes nothing about what runs, which is exactly what that gate's comment
already promised label-only updates would be spared. A rename skips it; the row
already says worktree, and the claim gate is what keeps it from running
somewhere that cannot.

TestProjectResourceUpdateLifecycle pinned the old reading for a request that
changes path, daemon and label at once. No client sends that shape — renames
change only the label, the mode dialog changes only the mode — and honouring an
embedded label there is indistinguishable from honouring a stale one, so it now
pins the conservative answer. Callers that mean to rename have the top-level
label field.

Also softens a claim review flagged as overstated: the label patch preserves
every unknown key and value, not the raw bytes, which JSONB does not promise
either.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:49:01 +08:00
c175530284 MUL-5879: fix(agent): surface every in-flight built-in Hermes tool call (#7127)
* fix(agent): emit ACP tool use when the start frame carries the command (MUL-5879)

Hermes' tool_call start frame carries the whole invocation in content but
no rawInput, which fell through to the kimi deferral path: nothing was
emitted until tool_call_update completed. An in-flight Hermes tool was
therefore invisible in run-messages, so a stalled tool rendered as a
blank run with no visible tool call at all (GH#6583). It also kept the
daemon's in-flight tool counter at zero, since that only advances on
MessageToolUse, so Hermes tool calls were never charged to
AgentToolWatchdog and were judged by the much shorter AgentIdleWatchdog.

Emit at the start frame when its content already holds complete args.
The discriminator is the shape of the text rather than the backend,
because nine ACP backends share this client: only an unterminated JSON
object or array can be a mid-stream kimi fragment. Rendering is
unchanged - the same text produces the same Input map the deferred path
built at completion - only the timing moves.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): narrow early ACP tool-use emit to the terminal invocation

Review of the first commit was right: content-shape was the wrong
discriminator. In ACP, `content` is display output produced by the tool
call and `rawInput` is the input, and a later update may still supply the
real rawInput - so treating any complete non-JSON content as the
invocation mis-records it and freezes the wrong value.

Hermes makes this concrete: it suppresses rawInput for its whole polished
tool set and renders each one differently. `terminal` renders
`$ <command>` (the invocation), but `write_file` and `patch` render
prose like "Preparing write to <path>", and the browser/web/media tools
render their own summaries. Only the terminal rendering is the command.

Recognise exactly that shape and defer everything else, so the nine
backends sharing this client keep their previous behaviour. Also let a
rawInput arriving on the update win over buffered start-frame text, which
is what makes the deferred path record the real input rather than the
rendering.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): surface every in-flight Hermes tool, not just terminal

Second review was right that the previous commit only fixed the terminal
subset. Hermes omits rawInput for its whole polished tool set (~40 tools:
write_file, patch, read_file, execute_code, web_*, browser_*, ...), so
those calls still stayed invisible while running and still never advanced
the daemon's in-flight tool counter - leaving long browser/code runs on
the 30-minute idle budget instead of the 2-hour tool budget.

Gate the early emit on a dialect flag instead of the frame's content.
Hermes sets it because its start frame is the only place input ever
appears: build_tool_call attaches rawInput for ordinary tools and omits
it for polished ones, and build_tool_complete never sends rawInput at
all, so waiting cannot yield more input. The other eight backends sharing
this client leave the flag false and keep deferring - Kimi genuinely does
stream its args across updates.

Content is still not passed off as input: only the terminal command
rendering becomes Input, and every other polished tool reports none.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): scope the Hermes tool-start dialect to the built-in binary

The hermes backend also serves custom runtime profiles declaring
protocol_family: hermes (jcode is the documented example), so switching
toolStartCarriesFinalInput on unconditionally applied a Hermes-Agent-only
observation to unrelated implementations. A custom runtime whose start
frame has no rawInput and whose later update supplies the real one would
have had Input=nil recorded at start with emitted=true, and
emitDeferredToolUse would then drop the real input on completion.

Scope it to Config.BuiltinRuntime, the same signal
acpToleratesOmittedMcpCapabilities already uses to fence a
vendor-verified exception, so an unset caller fails closed onto the
deferred path.

Cover it at the construction layer rather than with a hand-built client,
since the leak was in how the client is built: one turn driven through
hermesBackend.Execute against an ACP stub whose start frame carries
display text only and whose completion frame carries the real rawInput,
asserted both ways round.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 15:35:45 +08:00
XuSt 0b9dcc6799 fix(agent): preserve Pi RPC stdin on Windows (#7111)
Windows Pi model discovery reused the task-execution launcher rewrite to
`powershell -File pi.ps1`. That path suits normal JSON/task execution, where the
prompt is written to stdin and stdin is then closed. Pi RPC model discovery
instead needs a bidirectional session and sends requests while keeping stdin
open, so npm PowerShell wrappers that wait for stdin EOF made discovery time out
and fall back to the legacy model table without thinking metadata.

- Route Windows Pi RPC invocations through `powershell -Command "& '<pi.ps1>' @args"`
  so the wrapper receives forwarded args while stdin stays live.
- Keep ordinary Pi task execution on the existing `-File` path.
- Add a Windows launcher argv regression test plus an end-to-end test that drives
  a real PowerShell host and asserts the RPC responses arrive before stdin EOF,
  verified on both Windows PowerShell 5.1 and pwsh in `windows-execenv`.

Fixes #7108
2026-08-18 14:46:08 +08:00
de902a4aab MUL-6273: feat(agent): add MiniMax Code ACP runtime (#7035)
* feat(agent): add MiniMax Code ACP runtime

* fix(agent): terminate mcode process trees

* fix(agent): address mcode review blockers

* test(agent): keep mcode model contract API-stable

* fix(migrations): resolve mcode prefix collision

* fix(agent): launch mcode through runtime command

Assisted-by: MiniMax Code

* fix(migrations): renumber mcode migration

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 12:44:27 +08:00
adaianddamon.zhou 4383497898 fix(agent): fail a Pi task when Pi ends the turn on an error without retrying (#7059)
Pi reports a provider call it could not complete by ending the turn with
stopReason=error and the provider text in errorMessage. When it also
decides the error is not worth retrying — 401, 429 for an exhausted
quota — it emits neither a standalone error event nor auto_retry_end,
and exits 0. Those two events were the only failure signals the adapter
recognized, and piMessage discarded stopReason at decode time, so the
run was reported completed with empty output and no error. The task was
never retried and nothing alerted; a production task closed in 6s while
the gateway was returning 429.

Decode the two fields and remember the error of the turn that just
ended. Pi emits the same stopReason before an automatic retry, so this
is cleared on turn_start and only a trailing failed turn survives to
fail the run — a retry that recovers still reports completed.

Co-authored-by: damon.zhou <[email protected]>
2026-08-17 16:25:22 +08:00
mxbao063-bao a17857e638 fix(agent): parse tab-separated Antigravity model catalog (#6654)
Fixes #6562. Related Multica issue: MUL-5856.
2026-08-17 16:18:04 +08:00
3b08423684 MUL-6260 fix(daemon): treat custom runtime fixed_args as the argv prefix (#7052)
* fix(daemon): treat custom runtime fixed_args as the argv prefix (MUL-6260)

A custom runtime profile configured as `ccms start q36` failed every task
with `unknown option '-p'`. The profile's fixed_args were merged into
ExecOptions.ExtraArgs, which each backend appends *after* the protocol
flags it hardcodes, so the wrapper saw `-p` before its own `start`
subcommand had been selected and rejected it (GH #7046).

Ordering was only half the defect. ExtraArgs is opt-in — six of the
twenty-one backends read it — so on the other fifteen families, including
every ACP one, fixed_args were silently discarded rather than misordered.
Model discovery and `--version` probing carried the profile's path but not
its arguments, so even a fixed launch would have left the model picker and
the reported version describing the wrapper instead of the CLI behind it.

fixed_args now travel as agent.Config.LaunchPrefix and are spliced in
directly after the executable, ahead of everything a backend builds. The
prefix is applied by a new Command boundary in launch.go through which
every runtime process in the package is constructed — launches, version
probes and discovery alike — so a backend cannot forget it. A guard test
fails the build if any file outside launch.go calls os/exec directly;
distributed opt-in is what let ExtraArgs rot, and repeating it would rot
the same way.

Flag-style commands (`agent --model composer-2.5`) are unaffected: a CLI
takes its own flags in any position, which is why prefix-first can be the
single order rather than a per-runtime setting.

One deliberate behaviour change. The prefix used to sit last and win every
last-wins parse, so a profile pinning `--model` silently overrode the model
chosen on the agent — the UI showed one model and ran another. Prefix-first
inverts that: the more specific per-agent choice is the later token and
wins. Overlaps are logged. Protocol-critical flags are filtered out of the
prefix once in New(), which knows the family; positional tokens always pass
through. Codex additionally strips `-c` overrides aimed at daemon-managed
config namespaces, since those beat config.toml from any argv position.

The order was never documented, and every shipped fixed_args example is
flag-style, so this fills in a contract rather than breaking one.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): close the launch-prefix gaps found in review (MUL-6260)

Four defects from the first round of review on the fixed_args-as-argv-prefix
change.

Hermes could escape its own task overlay. The profile selection was resolved
from custom_args alone and stripped from custom_args alone, so a profile
configured as `hermes -p research` kept that flag in the launch prefix and
re-pointed HERMES_HOME past the overlay the daemon had just built. Resolution
now parses the argv hermes actually sees — launch prefix then custom args, in
process order — and the strip covers both regions. It also removes every
selection rather than the first: taking out the first promotes the second, and
two separately-configured argv regions make "two selections" an ordinary
configuration rather than a user mistake.

Positional tokens were not passing through as documented. filterLaunchPrefix
delegated to filterCustomArgs, whose blocklists deliberately carry positional
tokens — hermes and qwenpaw block `acp`, traecli blocks `acp`/`serve`, grok
blocks `agent`/`stdio`/`serve` — because in custom_args those are someone
re-issuing the backend's own subcommand. In a launch prefix the same token is
the command's identity, so `hermes-wrapper acp tenant` was being rewritten to
`tenant`. Only a leading `-` now makes a token eligible for the blocklist; a
blocked flag still consumes its separate value so no stray value survives to be
read as a subcommand.

Codex fast mode lost to the prefix. `--disable fast_mode` beats `--enable
fast_mode` regardless of argv order and `-c features.fast_mode=false` beats
config.toml from any position, so prefix-first does not protect an explicitly
selected priority tier. The removal half of enforceCodexFastMode is now a
separate helper applied to the prefix too; the managed enable is still appended
once, on the final args.

Windows CI could not compile the daemon tests: the Command migration left an
unused `path := runtimeCmd.Path` in a hook whose body never referenced it.
Verified with GOOS=windows and GOOS=linux vet, which is what should have caught
it the first time.

Also softens the docs and the Command doc comment: flag-style prefixes usually
parse the same in either position, but a CLI that separates global from
subcommand flags can care, so the runtime docs now name the position change and
tell operators with existing fixed_args to re-run one task after upgrading
instead of promising the move is invisible.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): resolve and strip the Hermes profile from the real launch argv (MUL-6260)

Second-round review found the Hermes profile chain still reasoning about a
hand-assembled approximation of the command line instead of the command line
itself. The backend launches `<prefix> acp <filtered custom args>`, but the
daemon assembled `<prefix> <custom args>` — no `acp`, no filtering — and that
token participates in Hermes' own argv scan.

With fixed_args `--model` and custom_args `-p research`, the approximation
reads `-p` as `--model`'s value and finds no selection, so the overlay is
seeded from the default home; the real `--model acp -p research` skips
`--model acp` and selects `research`. The selector was then stripped, so the
task stayed inside the overlay — but an overlay built from the wrong profile,
silently.

Stripping had the matching defect, and this one was still an escape rather than
a mismatch. A launch prefix ending in a bare `-p` captures the backend's `acp`
as its profile value, so neither region holds a complete selection and
per-region stripping removes nothing: `-p acp research --yolo` reaches Hermes
intact and re-points HERMES_HOME straight out of the overlay.

Both halves now go through one assembly. agent.HermesLaunchArgv builds exactly
what hermesBackend.Execute builds — the backend calls the same helper, so they
cannot drift — and the daemon resolves from it. StripHermesProfileSelectors
strips against that same argv and hands surviving tokens back to the region
they came from, iterating to a fixed point because removing one selection
promotes the next. The `acp` token is never removed; the backend re-adds it,
and dropping the flag that captured it is what breaks the selection.

StripAllHermesProfileArgs, hermesLaunchArgs and hermesEffectiveArgs are gone:
each was only ever correct on a complete argv, and keeping them would leave the
same footgun for the next caller.

Tests cover the case the review named (prefix ending in a value-taking flag,
custom args opening with a selector), the straddling-selection escape, multiple
occurrences across both regions, and an invalid value left untouched because
Hermes discards it anyway.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 15:11:36 +08:00
4d495056e8 [MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add private Exa example

Co-authored-by: multica-agent <[email protected]>

* docs(plugins): remove private plugin guide

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): stabilize remote MCP tool digests

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): preserve remote MCP notification status

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make Exa example runnable end to end

Co-authored-by: multica-agent <[email protected]>

* fix(codex): record MCP tool calls in agent log

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add community skills example

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): support multi-file skill bundles

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add Mobbin MCP example

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): bundle full Matt Pocock skill set

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add remote MCP OAuth connections

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): gate enable on remote MCP readiness

Co-authored-by: multica-agent <[email protected]>

* fix(plugin): authenticate remote mcp credential broker

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden remote mcp oauth flow

Co-authored-by: multica-agent <[email protected]>

* refactor(plugins): use a single feature flag

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): clean up remote MCP OAuth state

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): remove Matt Pocock example

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-16 04:54:32 +08:00
b30a3ae55c MUL-6164 feat(runtime): take an unusable agent CLI offline and refuse its triggers with the fix (#6966)
* fix(daemon): take a runtime offline when its CLI cannot be executed (MUL-6164)

A CLI that cannot exec was classified as a transient probe failure, so the
runtime stayed online and every task it claimed died at fork/exec. The user in
MUL-6164 hit the same error once per task with no signal that the runtime was
the problem — an npm placeholder stub passes exec.LookPath, so nothing else the
daemon checks can tell it apart from a working install.

ENOEXEC (and its Windows spelling, ERROR_BAD_EXE_FORMAT) is a fact about the
bytes on disk, not a hiccup: it will reproduce until someone reinstalls. Give it
its own probe verdict and route it down the track below-minimum already uses —
claim barrier, seq-stamped hold, deregistration — so the provider goes offline
with its reason instead of collecting failed tasks. Recovery needs no new
machinery: a demoted provider has no runtime, which puts it back in
providersMissingRuntimes, and the first healthy probe clears the hold and lets
converge register it again, with no restart.

One thing the below-minimum track did not need: a confirmation window. The
repair we tell users to run overwrites the bin entry in place, so a probe landing
mid-copy sees a truncated file. Demotion therefore requires two sightings a
window apart; a genuinely broken install pays one extra probe round, and a
spurious verdict would in any case recover on its own.

detectBuiltinRuntimes' second return value is now "providers condemned this
round, with the evidence against them" rather than "below-minimum versions" —
same shape, both kinds, and the value is the reason /health already shows.

Co-authored-by: multica-agent <[email protected]>

* feat(runtime): refuse triggers for an unusable agent CLI instead of queueing them silently (MUL-6164)

Taking the runtime offline stopped the per-task failures, but it replaced them
with silence: the next @mention or assignment queued as if the machine were
merely asleep, and nothing ran for two hours until queued_expired. Waiting only
makes sense when waiting is a plan — a sleeping laptop comes back on its own; a
CLI that cannot be executed does not.

dispatch/reason.go already draws exactly that line between runtime_offline
("bring the machine back, queued work waits") and agent_runtime_required
("nothing will ever claim this"), and warns that collapsing the two sends users
to fix the wrong thing. This adds the third member of that family,
runtime_unusable, and routes the new verdict to it end to end:

- The daemon reports WHY on deregistration — a stable code plus the repair
  command read from the package's own manifest, since only that side knows the
  path. Stored on agent_runtime.metadata, which registration overwrites
  wholesale, so a repaired runtime drops the explanation structurally.
- AgentReadiness now returns a verdict (available / waitable / blocked) instead
  of a bool plus prose, and the direct-agent trigger paths start consulting it —
  blocking ONLY on the blocked verdict, so an offline machine still queues.
  agentReadinessReasonCode, which re-derived the same code from agent state, is
  gone: one source, no drift.
- Every path that refuses without a human watching the response leaves the
  reason on the issue: an assignment, or another agent's @mention. The
  interactive surfaces already carry the code back to the composer, which now
  localizes it in all four languages.

Below-minimum keeps today's behaviour deliberately. It is equally futile to wait
on, but changing when THAT refuses a trigger is a separate product decision.

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): keep the unusable verdict through a late register, and repair Windows (MUL-6164 review)

Two ways the refusal path failed to reach the user.

The verdict could be erased by an older answer. A register whose request was
still in flight across the demotion upserts the runtime row afterwards, and that
upsert overwrites metadata wholesale — including the reason just stored. The
local hold refuses the response correctly, but the cleanup that took the revived
row offline again sent no reason, so the server ended up "offline, cause
unknown". Every admission path reads that as "the machine will come back" and
queues, which is exactly the behaviour this change exists to remove. The
demotion record now keeps the structured cause and the revived-row cleanup
re-attaches it per row. The regression test drives the real interleave — a
register held inside the server handler across the verdict — and fails without
the fix.

The repair command was POSIX-only. Windows had just gained detection
(ERROR_BAD_EXE_FORMAT), so a Windows user was correctly refused and then handed
`cd 'C:\...' && node install.cjs`: cmd.exe treats the quotes literally and
PowerShell 5.1 has no `&&`. The command is now rendered for the shell of the
machine that reported it — Set-Location on two lines for PowerShell, with
PowerShell's own quote escaping — and carries that shell so the notice fences it
truthfully instead of labelling everything bash.

Also corrected the notice's own docs: it is written for every author, not only
unattended ones. The chip and toast carry the reason code but not the repair
command, so a human needs the durable copy too. The behaviour was already this;
the comment claimed otherwise.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 18:56:40 +08:00
f51ee62b53 MUL-6164 fix(agent): diagnose an unrunnable agent CLI instead of leaking "exec format error" (#6963)
* fix(agent): diagnose an unrunnable agent CLI instead of leaking "exec format error" (MUL-6164)

npm-distributed agent CLIs ship a placeholder text stub at their bin entry
point and rely on a postinstall script to overwrite it with the platform-native
binary. npm 12 blocks install scripts unless allowlisted, pnpm 10 needs
`pnpm approve-builds`, and --ignore-scripts / --omit=optional do the same — all
of them leave the stub in place with its executable bit set. exec.LookPath
accepts it, execve rejects it, so the CLI reads as installed everywhere the
daemon looks and only fails at launch with a bare
"start claude: fork/exec <path>: exec format error".

Add ExplainExecError: on ENOEXEC it names the path, the owning npm package, and
the exact repair command read out of that package's own scripts.postinstall.
The executable path is recovered from the error itself, so this is one
provider-agnostic call at each boundary — the shared version probe and the
daemon's single backend.Execute site — rather than a wrap in every backend.
claude and opencode ship this layout today; a CLI added later is covered
without a code change.

Also classify "exec format error" as runtime_missing_executable: a stub that
cannot exec means this host has no usable CLI, and that reason already tells
the daemon a fresh-session retry cannot help.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): keep the ENOEXEC diagnosis to one copy (MUL-6164 review)

The openclaw preflight explained the error, then returned it through
openclawBackend.Execute into the daemon's launch boundary, which explained it
again — %w keeps ENOEXEC reachable, so the second pass fired and the user got
the same paragraph twice.

Drop the openclaw wrap: its error already travels through the daemon boundary,
so the extra site bought nothing. Make ExplainExecError idempotent as well, so
the next nested boundary cannot reintroduce the same regression silently.

Also quote the package root properly in the repair command — it is meant to be
pasted into a shell, and a path holding a quote or a space has to survive that.

The chat copy for runtime_missing_executable said the CLI "couldn't be found /
check it's on PATH", which is the opposite of this failure: the file is right
there on PATH and cannot run. Reworded to cover both across all four locales;
the compact labels ("Runner CLI not installed") are left for the follow-up that
reworks the reason's presentation.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 15:31:24 +08:00
Danila Katalshov 3025c38027 MUL-6019: fix(codex): add MULTICA_CODEX_FIRST_TURN_TIMEOUT to raise the first-turn ceiling (#6753)
* fix(codex): add MULTICA_CODEX_FIRST_TURN_TIMEOUT to raise the first-turn ceiling

The first-turn no-progress watchdog caps at the 60s default that the
configured semantic-inactivity timeout can only shrink, never raise. An
app-server that is legitimately slow to its first event (heavy MCP boot,
cold model catalog) is killed at 60s with no way to extend it.

Add a dedicated MULTICA_CODEX_FIRST_TURN_TIMEOUT knob (default 0 = unset)
threaded through ExecOptions to codexFirstTurnNoProgressTimeout, which now
honors an explicit value upward and is otherwise byte-identical. Default
behaviour and the clamp contract are preserved.

Supersedes #5959. Refs #3262.

* fix(codex): pin and document the first-turn/semantic timer interaction

The MULTICA_CODEX_FIRST_TURN_TIMEOUT override only raises the first-turn
watchdog, not the semantic-inactivity timer that the same first status:running
arms concurrently. An override above the semantic timeout is therefore
truncated to it, and the resulting failure is reclassified as semantic
inactivity — which also skips the #3291 model-catalog startup retry. The prior
docs, comment, and test wording implied the override "wins outright / unbounded
above", hiding this real upper bound and the configuration-dependent loss of
automatic recovery.

- Warn at config load when MULTICA_CODEX_FIRST_TURN_TIMEOUT exceeds the
  semantic timeout, naming both the truncation and the disabled startup retry.
- State the effective bound (semantic timeout + overall execution timeout) in
  all four environment-variables*.mdx pages, and that both must be raised
  together when the desired first-turn wait exceeds the semantic limit.
- Narrow the "wins outright / unbounded above" resolver comment and test name
  to describe only the first-turn watchdog ceiling.
- Add a runtime regression test that drives the real run loop with an override
  above the semantic timeout and asserts the semantic timer wins the race and
  the startup retry is skipped; add a LoadConfig empty/explicit/zero test
  (this knob is environment-only, no Overrides parity).

Addresses review on #6753. Refs #3262 / #5959 / #3291.

* fix(codex): warn at equal first-turn/semantic timeouts, require strictly greater

multica-eve's re-review on #6753: the warning fired only when the first-turn
timeout was strictly greater than the semantic timeout, and told operators to
raise the semantic timeout "to match". But the semantic timer is armed first
(codex.go resets it before creating the first-turn timer), so at equal durations
the semantic deadline is reached first and Go's select does not deterministically
favour the first-turn branch — the failure can still be classified as semantic
inactivity and skip the #3291 startup retry. An operator following the old
guidance to equal values got no warning and silently lost the retry.

- Warn when first-turn >= semantic (was >), and tell operators to set the
  semantic timeout strictly above the first-turn timeout (with margin), not
  "to match".
- Update all four environment-variables*.mdx locales accordingly.
- Add TestLoadConfig_CodexFirstTurnTimeoutEqualToSemanticWarns pinning the
  equal-duration warning and the strictly-greater no-warning boundary.

Addresses review on #6753. Refs #3262 / #3291.
2026-08-14 15:30:42 +08:00
AriesL e3ec3f8b57 MUL-6162 docs: sync landing copy and docs for the DeepSeek Harness runtime (#6940)
Completes the user-facing surface for the DeepSeek Harness runtime merged in #6923: landing copy (21 -> 22 tools), README, CLI_AND_DAEMON, SELF_HOSTING, provider comparison and install docs across en/zh/ja/ko, plus the provider logo.

Review follow-ups folded in: the mark is inlined so currentColor tracks the app theme (an <img>-loaded SVG pinned it to black in dark mode), the landing enumerations and environment-variable docs are complete in all four locales, and the new rows sit in their tables' existing order.
2026-08-14 02:51:56 +08:00
Jiayuan Zhang 4f10a944b1 feat(runtime): add DeepSeek Harness support (#6923)
* feat(runtime): add DeepSeek Harness support

* docs(runtime): reflect public DSH release
2026-08-13 23:18:17 +08:00
063698f571 fix: gate worktree mode on a declared capability, not a version string (MUL-5707) (#6904)
* fix: gate worktree mode on a declared capability, not a version string (MUL-5707)

Two tasks ran in the user's own directory despite the resource being set to
worktree mode. The daemon was v0.4.23 — built before the worktree code — so
it json-skipped execution_mode and took the in_place path. The claim gate
existed to stop exactly that and did not fire: the runtime reports
`v0.4.21-24-gcd3c0bb89`, a git-describe dev build, and the version floor
deliberately exempts those so `make daemon` stays unblocked. The exemption
is fine for a gate whose failure mode is a missing field; it is wrong for
one whose failure mode is writing to somebody's working copy.

Version strings cannot answer "does this binary implement worktree mode".
The daemon now answers it directly: `local-worktree-v1` joins the existing
X-Client-Capabilities negotiation, and all three gates read that instead.

- Claim time (authoritative): decided from the capability on that very
  request. A daemon without it gets the task cancelled with the reason on
  the row — never a silent fallback to editing the directory, which is the
  outcome this mode exists to prevent.
- Save time and the UI cannot see a live request, so registration now
  records the advertised capabilities on the runtime row and both read that.
  One signal everywhere instead of three different guesses.

Absent capabilities means an older daemon, so every path fails closed. The
error names the machine's directory and says to update the Multica app
there, rather than quoting version numbers the user did not choose.

MIN_LOCAL_WORKTREE_CLI_VERSION stays only as the number shown in the
existing 422 payload; nothing gates on it any more.

Co-authored-by: multica-agent <[email protected]>

* fix(handler,views): judge daemon capability by the newest runtime row (MUL-5707)

Review follow-up. Both gates asked "did ANY runtime row for this daemon
advertise the capability", which never stops being true: deregistering a
runtime only flips the row to offline, its metadata survives, and
ListAgentRuntimes returns every row. A machine that once ran a capable
daemon and then downgraded keeps an old capable row next to the fresh
incapable one, so the save gate and the UI would go on offering worktree
mode. The claim gate still refused the task — no directory was ever at risk
— but the user only found out after the run instead of at the moment they
could act on it.

Both sides now read the daemon's most recently seen runtime row, and a row
missing the capability is never skipped: being newest is what makes it
authoritative, not whether its answer is convenient. A row that never
reported sorts oldest so a live one always wins. The selection lives in one
place per side (daemonAdvertisesWorktree / daemonSupportsLocalWorktree) with
matching semantics, because a UI that disagrees with the API offers a mode
the API refuses.

Also pins the bridge the whole gate rests on, which nothing covered:
DaemonRegister round-trips the advertised header onto the runtime row, and
re-registering WITHOUT the header clears it — the exact downgrade the save
gate has to notice. The daemon's own identity-header test now asserts
local-worktree-v1, so dropping it from the advertised list can never be a
silent change.

Corrects three places that still described the version floor as the gate:
version.go, the projects-and-resources source map, and the floor test, which
now pins the two surfaces that merely DISPLAY the version to quote the same
release.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 17:23:51 +08:00
740ebdb8ef MUL-5991 feat(agent): enable ACP thinking_level for the hermes provider (jcode) (#6814)
* feat(agent): enable ACP thinking_level for the hermes provider (jcode)

The reporter confirmed from jcode source (v0.71.1 and v0.73.0) that its
advertised effort is genuinely applied, not just echoed: session/set_config_option
waits for an `effort_changed` ack, and the provider request carries
`reasoning.effort` upstream. That was the one open question blocking this
half of GitHub #6720.

The wrinkle is that `hermes` is one provider type over two unrelated
binaries with opposite answers here:

  - jcode advertises option id `reasoning_effort` under category
    `thought_level` and applies it. The shared parser already matches on
    category, so the id being unrecognised costs nothing and the id we
    send back is jcode's own.
  - Hermes Agent has no effort surface on ACP at all. Re-verified against
    v0.20.0 today by driving `hermes acp` directly: session/new still
    returns only `_meta`, `models`, `modes`, `sessionId`, unchanged from
    the v0.18.2 finding in MUL-5770.

A version gate cannot separate those — they are different projects, not
different releases. The catalog can, and does: an empty catalog means no
picker and no set_config_option call, so admitting `hermes` to
acpCatalogThinkingProviders is safe for both. This is the case the
catalog-driven design was built for.

Also corrects the docs that stated hermes has no reasoning control. That
is now binary-specific rather than universally true, and Copilot takes
over as the example of a runtime with no effort dial at all.

Co-authored-by: multica-agent <[email protected]>

* test(handler): retarget the no-reasoning-control fixture from hermes to copilot

These tests used hermes as the canonical runtime with no effort dial, so
enabling it broke them in CI — they passed locally only because the
handler suite skips without a database.

Copilot takes over the role: it discovers over ACP but executes through
its own CLI, so there is genuinely no live ACP session to carry an
effort. The behavior being pinned is unchanged — a capability gap must
not be reported as an unrecognised token.

Adds the inverse assertion in the same test: hermes now accepts a level
at the provider gate, since jcode applies one. Whether a picker appears
is settled per session by the discovered catalog, not here.

Verified against a real Postgres this time rather than the skip path.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): keep the capability 400 for hermes runtimes with no effort dial

Opening the thinking_level gate for the hermes provider regressed the
majority of that provider's users. Hermes Agent, not jcode, is what most
people run there, and it advertises no effort option — so a CLI
`--thinking-level high` went from an immediate, accurate 400 to a 201
followed by the daemon silently dropping the level at task time.

The provider name cannot distinguish the two binaries, but the model
catalog the daemon already reports can: a hermes runtime whose catalog
advertises no thinking levels is a Hermes Agent install. Consult it in
the create and update paths and restore the capability message for that
case, leaving jcode unaffected.

Fails open on purpose. A cold cache, an offline daemon, or a
never-discovered runtime all skip the check, so a valid jcode agent is
never blocked before its first discovery; the daemon's pre-execution
guard remains the backstop.

Verified against a real Postgres, including the fail-open paths.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): refuse a thinking level on an undiscovered hermes runtime

Review caught that the fail-open branch was not the transient cold start I
assumed. The model catalog is written only by ReportModelListResult, i.e.
only after a client explicitly asks for a model list, so a caller who works
through the CLI and never opens a model picker stays undiscovered
indefinitely. Reading "unknown" as "supported" therefore left the majority
Hermes Agent case exactly where it started: `--thinking-level high` accepted
and persisted, then silently dropped by the daemon at task time.

Replace the boolean with three states — present / absent / unknown — and
refuse on unknown, but only for providers whose name does not determine the
binary. `hermes` is the sole member; reasonix names one binary that does
support an effort, so an undiscovered reasonix runtime stays allowed rather
than being blocked before its first discovery.

Unknown gets its own message rather than reusing "does not support a
per-agent reasoning effort". That sentence would be actively false for a
jcode runtime, sending its owner to look for a limitation that isn't there;
naming the missing evidence points at what actually resolves it.

Also adds the execution-chain coverage the review asked for: jcode-shaped
ACP stubs prove set_config_option(reasoning_effort) goes out before
session/prompt on both session/new and session/resume, and a Hermes
Agent-shaped payload produces no config call at all. That is the substitute
evidence for having no real jcode to test against.

Skill and source map updated so the documented guarantee matches the code
rather than being stronger than it.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): word the undiscovered-runtime 400 for create as well as update

The message pointed at "the agent's model picker", which does not exist on
the create path — the agent is not created yet. "the model picker for this
runtime" is accurate on both paths (review nit, PR #6814).

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 16:42:47 +08:00
e7e01638ad MUL-6118: fix(agent): own Codex process trees with a Job Object on Windows (#6897)
* fix(agent): own Codex process trees with a Job Object on Windows

Windows has no process groups, so configureProcessGroup was a no-op,
waitProcessGroupGone always reported false, and signalProcessGroup killed
only the direct child. That child is usually a cmd.exe wrapping a .cmd
shim, so the real tree — the Node wrapper, the native app-server, and the
sandbox helpers under them — was left running as orphans.

Assign the started process to a Job Object with KILL_ON_JOB_CLOSE, which
every process it later creates inherits. Termination now targets the job,
emptiness is read from job accounting, and ownership is dropped only once
the tree has been reaped. The process handle is held for the lifetime of
the entry so Windows cannot recycle the pid out from under the key.

Assignment failure is not fatal: cleanup degrades to the previous
kill-the-child behaviour, and the cleanup_confirmed gate keeps suppressing
initialize retries on any host where ownership could not be taken.

Refs #6883

Co-authored-by: multica-agent <[email protected]>

* fix(agent): close the pre-attach window and stop keying trees by pid

Two defects in the Job Object ownership added by the previous commit.

Windows grants job membership only to processes created after the
assignment, never retroactively. Assigning after cmd.Start left a window
in which the direct child — usually a cmd.exe wrapping a .cmd shim — had
already spawned the real agent outside the job. That is worse than owning
nothing: the job then holds a wrapper that exits immediately, so
accounting reports the tree empty while the escaped app-server is still
running and cleanup is reported as confirmed. startOwnedProcessTree now
creates the child suspended, assigns it, and only then resumes it, so it
cannot create anything before it belongs to the job. A child is never
left suspended: ownership that cannot be taken is logged and the child
runs unowned, and a child that cannot be resumed is killed and reported.

Ownership was also keyed by pid. A pid is unique only while its process
is alive, so a launch that outlived its process could look up — and
terminate — an unrelated task that inherited the number. Key by the
*exec.Cmd of one launch instead; signalProcessGroup and
waitProcessGroupGone take the command on both platforms.

The tests assert job membership through the job's own accounting rather
than through timing, so an escaped descendant fails deterministically.

Refs #6883

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 16:27:24 +08:00
c9727e6b2b feat(daemon): worktree mode for local_directory resources (MUL-5707) (#6759)
* feat(daemon): worktree mode for local_directory resources (MUL-5707)

Local directory resources serialise every task on a per-path mutex, so two
tasks on one folder queue behind each other. This adds an opt-in worktree
mode: each task gets its own git worktree of the user's repo inside the
daemon's env root, so tasks run concurrently and deliver their work as a
branch instead of editing the user's working copy.

Opt in per resource with execution_mode=worktree (default stays in_place,
so existing resources are unchanged). Requires a git repo with at least one
commit; a non-git directory fails with an actionable message rather than
silently falling back to serial execution.

Design points that make the mode safe to use:

- The agent starts from what the user sees, not HEAD. Tracked edits are
  captured with `git stash create` (which writes a commit object without
  touching the index, working tree, or stash list) and replayed into the
  worktree; untracked non-ignored files are copied, bounded at 2000 files /
  200MB with the skipped count logged at warn level.
- That replayed state is committed as a baseline, so "did this task change
  anything?" has an exact answer and `git diff <baseline>..<branch>` is
  precisely the agent's work.
- Nothing is silently discarded: leftovers are committed to the branch
  before the worktree is removed, including when the task fails partway.
- A task that changes nothing leaves no branch behind.
- Sidecars stay out of the branch by running the existing
  CleanupRuntimeConfig + CleanupSidecars pass over the worktree before
  finalizing. The .git/info/exclude approach repocache uses is not usable
  here: a linked worktree resolves info/exclude to the user's own common git
  dir, so it would change what `git status` hides in their checkout.
- Worktree-mode env roots do not get the local_directory GC exemption, so
  they age out normally instead of accumulating one env root per task.
- `git worktree prune` on create self-heals registrations orphaned by a
  crashed daemon.

Adds --execution-mode to `project resource add/update`, and updates the
docs and the projects-and-resources built-in skill.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): close data-loss and isolation gaps in worktree mode (MUL-5707)

Review follow-up on #6759. Six issues, all on failure or skew paths.

1. Preserve work when the final commit fails. Finalize logged a warning and
   then ran `git worktree remove --force`, destroying the agent's changes.
   commit.gpgSign with no key available to the daemon triggers this, and
   --no-verify does not disable signing. Finalize now returns an error and
   deliberately keeps the worktree — it stays registered, so `git worktree
   list` points at the work — and the daemon fails the task instead of
   reporting success with a branch that is missing the changes.

2. Fail closed on an unsupported execution_mode. Treating an unknown mode as
   in_place was wrong: execution_mode is how a user asks for ISOLATION, so
   the "safe" fallback would edit the working copy they asked us to stay out
   of. An unrecognised mode now fails the task naming the version skew.

3. Keep Codex conversations alive across turns. The per-issue session store
   was keyed on LocalWorkDir != "", so worktree tasks — which also never
   reuse an env root — got a fresh empty CODEX_HOME per turn and silently
   lost the thread. Covered by a two-turn regression test that fails without
   the fix.

4. Fail closed on an incomplete snapshot. A failed stash create/apply, a
   failed untracked copy, or hitting the copy bounds all degraded to "agent
   sees HEAD" with only a daemon log line. A half-replayed tree looks like a
   working checkout, so nothing downstream questions it while the agent reads
   different code than the user has. All of these now abort the prepare and
   tear the partial worktree back down.

5. Do not snapshot a directory mid-mutation. The same real path can be
   attached to another project as an in_place resource; snapshotting under a
   running in_place task captured a half-written tree plus that task's
   in-flight .agent_context/.multica sidecars, which then reached the branch
   and could feed the agent another issue's brief. Worktree prepare now takes
   the per-path mutex for the snapshot alone (released before the run, so
   tasks still overlap), and the untracked replay skips Multica's own
   sidecars.

6. Stop the test helper from masking regressions. gitRun skipped on any git
   failure, including post-finalize assertions, so a lost branch could report
   as a skip. Git availability is now checked once at repo setup; every later
   git failure is fatal.

Also rebased onto main, keeping both MulticaConfigRoot and LocalWorktree in
the Environment initialiser.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): close sidecar-commit and wait-visibility gaps in worktree mode; gate worktree saves on daemon version (MUL-5707)

- runTask: register the sidecar-cleanup defer immediately after the Finalize
  defer. Previously two early returns (temp-dir setup, StartTask failure) sat
  between them, and on those paths Finalize auto-committed the sidecars
  Prepare had just written — delivering a branch whose only content is
  Multica's own runtime files (in_place: leaving them in the user's tree).
- runTask: give the worktree snapshot wait the same plumbing as the in-place
  acquire — waiting_local_directory status, resourceWaitTasks accounting, and
  a cancellation poller. The holder can be an hours-long in_place task; the
  wait was invisible and a user cancel went unnoticed until the 5-minute
  prepare timeout.
- isMulticaSidecarPath: match sidecar dirs as whole path segments at any
  depth, not only at the repo root. An in_place resource pointed at a
  SUBDIRECTORY leaves its sidecars below that subdirectory; replaying them
  committed another issue's brief into the delivered branch.
- handler: refuse saving execution_mode=worktree while the owning daemon's
  registered cli_version is below MinLocalWorktreeCLIVersion (422,
  daemon_version_unsupported). An old daemon json-skips the field and runs
  tasks IN PLACE — the daemon-side unknown-mode refusal cannot protect a
  daemon that predates the field, so the server has to. Residual (documented):
  a daemon downgraded after the save needs a claim-time gate.
- copyUntrackedFiles: read the -z listing raw instead of TrimSpace-ing it,
  which corrupted filenames beginning/ending with whitespace into paths that
  silently vanished from the replay.
- docs + skill: describe the save-time version gate; drop the stale claim
  that old runtimes fail the task themselves.

Co-authored-by: multica-agent <[email protected]>

* feat(views): UI switch for local_directory execution mode (MUL-5707)

Worktree mode shipped CLI/API-only, which for its target user — someone
running several tasks at once from Desktop — means it does not exist. This
adds the switch to the resources panel.

The choice is framed by what the user gets back, not by the mode
identifiers: "Edit this folder directly" (one task at a time, changes land
in your working copy) vs "Run in parallel, isolated" (tasks run at the same
time and each hands back an agent/… branch). `in_place` / `worktree` stay
visible as a secondary hint for anyone cross-referencing the CLI or docs.

Two entry points, matching where the decision is actually made:

- The add-a-folder flow now asks for the mode after the folder is picked and
  validated, instead of creating the resource and leaving the mode to be
  discovered later.
- Every local_directory row has a mode control. It is deliberately NOT gated
  on being the owning machine — switching the mode only rewrites a field, so
  it works from the web app too, unlike rename or the folder picker.

Both blocked states are handled where the user chooses, not after they
commit:

- Non-git folder: the desktop validator now reports whether the path is
  inside a git working tree (walking up for `.git`, so a subdirectory of a
  repo counts), and the option is disabled with that reason. Unknown — an
  older desktop build, or an existing row — stays permissive; the daemon
  re-checks authoritatively.
- Daemon too old: the version floor is mirrored from the server as
  MIN_LOCAL_WORKTREE_CLI_VERSION and checked against the runtime for the
  resource's OWN daemon, not the machine the browser happens to be on. A
  server rejection is rendered inline in the dialog rather than as a toast,
  so the dialog stays actionable.

A "Parallel" badge on the row is always visible (not hover-only): without it
there is no way to tell whether tasks on a folder edit it directly or hand
back a branch, which is the first thing anyone asks when a task queues.

Also adds `execution_mode` to LocalDirectoryResourceRef — the field existed
server-side but had no TypeScript counterpart — and brings the zh/ja/ko
project-resources docs in line with the English ones, which were the only
locale updated when the mode landed.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon,handler): close worktree isolation and delivery gaps (MUL-5707)

Second review round. Five must-fix items, all on paths where the mode's
promises could still break.

1. A failed sidecar cleanup no longer ships Multica's own files as the
   user's branch. The cleanup errors were logged and Finalize then ran
   `git add -A`, so a permission/disk/lock failure could deliver a branch
   whose entire content was the runtime brief and issue context. Worktree
   mode now escalates a cleanup failure to an abort: nothing is committed,
   the worktree is kept for inspection, and the task fails with the
   preserved path. In place it stays a warning — no commit, no branch, and
   the leftovers are visible in the user's own tree.

2. The untracked replay no longer drops files silently. `Lstat` failures
   and symlinks were `continue`d without counting, so the caller read a
   partial snapshot as complete. Both now count as skipped, which the
   existing fail-closed check turns into a refused prepare. Non-content
   entries (sockets, FIFOs, devices) stay uncounted deliberately.

3. Prepare rolls back a worktree it created. Any later failure — context
   files, provider home, MCP config — returned without an Environment, so
   nothing downstream knew a worktree existed, leaving a registration and
   a branch no task ever ran in. A deferred Discard now runs unless Prepare
   reached its successful return.

4. The daemon-version gate moved to where it can actually hold. Save-time
   validation cannot see a machine downgraded afterwards, and an old daemon
   json-skips execution_mode entirely — it would run the task in place,
   editing the working copy the user asked to isolate. Claims are now
   checked against the runtime that is actually claiming, and a blocked
   task is cancelled with a reason rather than left to be redelivered to
   the same too-old runtime forever.

5. branch_name is delivered end to end. The daemon sent it, but
   TaskCompleteRequest had no field so it vanished at decode, and the
   failure path never sent it at all. It now has a column, travels on both
   terminal callbacks, and renders as a copyable row in Run details. The
   failure path matters most: worktree mode commits the agent's leftovers
   before tearing the worktree down, so a run that died partway still has
   work worth finding.

Docs (all four locales) and the projects-and-resources skill are updated
for the claim-time gate and the branch surface.

Co-authored-by: multica-agent <[email protected]>

* fix(handler,daemon): migration renumber, cancel reasons, branch on every terminal path (MUL-5707)

Third review round.

1. Rebased onto main and renumbered the migration to 285 — main had taken
   273 and is now at 284. sqlc regenerated on top of the rebased queries;
   the conflicts were confined to generated files.

2. The claim gate now persists why it cancelled. It called CancelTask,
   which writes only status='cancelled', and returned the reason as a 4xx
   that reaches the daemon's log and nothing else — batch claim drops the
   task from its response entirely. A run refused for a durable reason the
   user has to act on cannot present as a bare "cancelled", so this adds
   CancelAgentTaskWithReason and records error + failure_reason on the row.

3. branch_name now survives every terminal path, not just the two direct
   callbacks. Three were dropping it:
   - the context-exhausted complete→fail reroute did not copy it;
   - the daemon's complete→fail fallback (server rejected the complete,
     agent had succeeded) did not set it;
   - both cancellation paths discarded the result wholesale, even though
     the worktree is finalized — and its work committed — before the daemon
     learns of the cancel. The branch now rides the cancel-ack and is
     recorded by SetAgentTaskBranchName, which never overwrites a name a
     terminal callback already wrote.

4. Rollout/rollback compatibility. Isolation is enforced by two server-side
   gates, so a daemon downgraded at any point after the resource was saved
   is caught. The gap they cannot close is rolling the SERVER back past this
   change while sub-floor daemons are still connected: an old server has no
   gate and an old daemon ignores execution_mode. That constraint is now
   documented where an operator will meet it (the migration and the docs),
   and the version floor is pinned across server and frontend by test so the
   two gates cannot silently drift apart.

Co-authored-by: multica-agent <[email protected]>

* fix(server,views): route claim-gate cancels through TaskService, make cancel reasons visible, harden cancel-ack branch delivery (MUL-5707)

Closes the fourth-round review findings on worktree mode's cancellation
chain:

- Claim gate: cancel through a new TaskService.CancelTaskWithReason instead
  of a raw CancelAgentTaskWithReason query, so a gate-cancelled task gets the
  full cancellation side effects — audit capture, chat settle, agent status
  reconcile, task:cancelled broadcast, NotifyTaskFinished. Added REAL claim
  integration tests: singular claim (422 + row cancelled with reason), batch
  claim (task omitted + row cancelled with reason), and a current-runtime
  control.
- Cancel reasons are now user-visible: execution log rows, agent activity
  rows, and the transcript dialog label a SERVER-cancelled task (persisted
  error/failure_reason) like a failed one, with the actionable text on hover
  and a Reason row in Run details. User-initiated cancels stay a plain
  "Cancelled". New cancelReasonLabel helper + local_directory_error label;
  details_reason locale key in en/zh-Hans/ja/ko; widened
  AgentTask.failure_reason to the open string the wire actually carries.
- Cancel-ack is now a terminal delivery: the daemon retries it on transient
  errors (same schedule as complete/fail), the server fails LOUD (5xx) when
  persisting the branch/error fails instead of warn-and-200, and re-broadcasts
  task:cancelled after a successful write so clients that already refetched a
  branchless row pick up the delivery. Replays are idempotent (never
  overwrite).
- Cancel + Finalize-abort no longer swallows the preserved-worktree pointer:
  the finalize error is wrapped in worktreePreservedError (joined onto an
  existing run error, never displacing it), extracted on the cancel path, and
  delivered via the ack into agent_task_queue.error through the new
  SetAgentTaskErrorIfEmpty query — never overwriting a reason recorded by a
  fail callback or the claim gate. Other run errors on a cancelled run stay
  discarded as before: they are expected noise.
- Docs: the self-hosted rollback constraint callout now exists in zh/ja/ko,
  not just English.

Co-authored-by: multica-agent <[email protected]>

* fix(handler): guard cancel-ack writes to cancelled rows; requeue when the claim gate's cancel fails (MUL-5707)

Fifth-round review fixes:

- SetAgentTaskBranchName / SetAgentTaskErrorIfEmpty now carry a
  status='cancelled' CAS. The daemon acks on EVERY terminal status it
  observes, so a late or replayed ack from a stale run could previously
  backfill its branch or preserved-path error into a completed/failed row
  whose own callback — the authoritative channel — recorded nothing, without
  any event invalidating the change. A CAS-refused write is a deliberate
  no-op (still 200: nothing to retry), and the rebroadcast stays guarded by
  the same status check. Regression tests: a late ack against a completed row
  with empty fields and against a failed row leaves both untouched.
- When the worktree claim gate's CancelTaskWithReason fails, the claimed row
  no longer strands in dispatched (daemon refused, nothing to release it but
  stale reclaim). The gate requeues via RequeueTaskAfterClaimFailure — the
  next claim re-runs the gate and retries the cancel — and reports 5xx so the
  singular daemon reads a transient server problem, not a claim refusal; the
  batch path omits the task as before, now with the row requeued. Fault
  injection tests (row-scoped trigger raising on the cancel UPDATE while
  letting the requeue through) cover singular and batch.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 14:52:01 +08:00
28a7176328 MUL-6053: fix(daemon): give Hermes conversations a session that survives the task (#6845)
* fix(daemon): give Hermes conversations a session that survives the task

Hermes keeps every ACP session in `<HERMES_HOME>/state.db`. Because the
daemon takes over HERMES_HOME to inject bound skills, that database lived
inside the per-task overlay, so a conversation lasted exactly one task.

The failure was silent, which is what made it expensive. `session/resume`
against a database that does not hold the session does not fail: Hermes
creates a fresh session and answers with an ordinary success frame, and
ACP's ResumeSessionResponse has no `sessionId` field to contradict it. The
daemon therefore believed it had resumed, stored the same dead id again,
and every turn restarted from zero with nothing in the logs (GH #6806).

Three changes, in the order they matter:

- The backend now reads `_meta.hermes.sessionProvenance.acpSessionId`, the
  only place Hermes reports the session it actually served. A turn that
  landed on a different session continues under the new id and carries the
  existing ResumeContinuityNotice, so the agent can read the transcript
  back instead of pretending it remembers.

- `state.db` is linked to a per-conversation store at
  `<profile dir>/hermes-sessions/<agent>/<hermes profile>/<conversation>/`.
  Sharding per conversation is what makes this safe where #6693 left it out
  of scope: tasks of one conversation are serial, so a shard has a single
  writer, while two issues never share a database. SQLite resolves the link
  when deriving the WAL paths, so the journal sidecars live in the store and
  a task teardown cannot take the write-ahead log with it. Windows without
  symlink privileges keeps the database task-local rather than copying it.

- `gateResumeToReusedWorkdir` no longer treats a matching workdir as proof
  the session is reachable. The two keys came apart in the local_directory
  flow, where workdir reuse is disabled but envWorkDir still equals
  PriorWorkDir, so the gate forwarded an id that could never resolve.

Stores are GC'd on their own retention (`MULTICA_GC_HERMES_SESSION_TTL`,
default 14d, matching the Codex session store) and held against the GC for
the duration of a task.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): never destroy a Hermes session database on a failed mount

Review of #6845 found three ways the new session store could lose or
misreport a conversation. All three are on failure and reclaim paths that
the original tests did not reach.

- **The degradation path was destructive.** The mount migrated the
  task-local state.db into the store and deleted it, and only then tried
  to create the symlink. On a host that cannot symlink, that turned a
  conversation which previously survived via workdir reuse into an empty
  one — a regression on exactly the hosts the fallback exists to protect.
  The link is now created under a staging name first, and nothing
  destructive runs until it exists; a host that cannot link is left byte
  for byte as it was found.

- **The migration was not atomic.** It copied state.db and its journal
  sidecars straight into the store, so a failure after the main database
  landed left a store that looked migrated. The next attempt skipped the
  migration, deleted the source, and took the un-checkpointed tail of the
  conversation with it. It now stages the whole family and publishes with
  one rename, reusing the memory store's staging helpers (renamed to drop
  their `memory` prefix now that both stores share them).

- **"Mounted" was read as "resumable".** A store the GC reclaimed between
  turns, a switched Hermes profile, an operator's `rm`, or a dangling link
  from an older overlay all mount cleanly onto nothing, and the resume gate
  forwarded a dead session id into them — the same silent restart this
  change set out to fix. The mount now reports two separate facts, and the
  gate reads whether the store actually holds a transcript, not whether the
  plumbing succeeded. A zero-length database counts as no history.

Tests cover each path with injected failures rather than host-dependent
skips: a link failure preserves the database and its WAL and still mounts
once linking works again, a migration that dies between the database and
its WAL publishes nothing and loses nothing on retry, and a mounted-but-
empty store (fresh, reclaimed, dangling, zero-length) reports no history.
Verified non-vacuous — reverting each fix reproduces the reviewed symptom.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): make the Hermes session store agree with itself on "empty"

Review of the previous commit found a certain data-loss branch left between
the migration and its publish step. The two used different definitions of an
occupied store:

- the migration asks hermesStoreHasSessionDB — a zero-length state.db, or
  journal sidecars with no database, carry no transcript, so it proceeds;
- the shared promote asked hermesStorePopulated — any directory entry at
  all — so the same remnants read as "a competitor published first".

With a non-empty source and a zero-length state.db in the store, the staging
copy completed, os.Remove(storeDir) failed on the non-empty directory,
promote reported a lost race, the migration returned success, and the caller
deleted the source database and its un-checkpointed WAL. The link then
pointed at a database with nothing in it.

Occupancy is now the caller's predicate: the memory store keeps "any entry",
the session store passes its own "holds a real database". The session publish
clears the remnants it has already classified as nothing before the atomic
directory rename, and a store it cannot clear fails closed — the migration
returns the error and the source stays put — rather than passing for a lost
race.

The regression test drives the whole matrix through prepareHermesHome: a
zero-length database, orphan sidecars, a real database that must never be
overwritten, and an unclearable store that must fail closed with the source
intact. It fails on the previous commit with the reviewed symptom
(HistoryPresent=false after the source was deleted).

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): never delete a competitor's freshly published Hermes transcript

publishHermesSessionStaging decided the store held no history, then deleted
the state.db family by path name. Between those two steps a competing task
could publish its real transcript, and the stale decision would remove the
winner's database — after the winner had already reported success and
discarded its source.

The check, the remnant cleanup and the promote now hold one process-wide
mutex. In-process exclusion is the real thing here, not an approximation:
the daemon is the store tree's only writer (even the GC guard is in-memory
refcounts on the Daemon), so two publishers can only ever be two goroutines
of this process. A deterministic barrier test parks a publisher inside the
critical section and proves a competitor cannot enter the window.

The family cleanup also stops using RemoveAll: regular files and symlinks
are unlinked (never followed), while a directory or any other irregular
type under a state.db name is content this code cannot identify, so it
fails closed and the caller keeps its source.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Steve Jobs (Multica Agent) <[email protected]>
2026-08-13 14:20:52 +08:00
0c69f1f95f fix(daemon): recover in-turn when a resumed session cannot resolve provider auth (#6798)
A resumed Hermes session whose persisted provider identity can no longer
resolve its credentials fails with the SDK's "Could not resolve authentication
method" message. The server-side resume guards already blacklist that session,
so the NEXT turn starts fresh and succeeds — which is exactly why Chat
alternated success / failure instead of staying broken (GH #6777).

Curing the failing turn needs the daemon's in-turn fresh-session retry, which
never fired: nothing identified this error as fatal-to-resume. Add it as a
third form of positive evidence in shouldRetryWithFreshSession, alongside
ResumeRejected and UnresumableHistory.

Deciding in the shared gate rather than in each ACP adapter is what makes it
correct and narrow:

- priorSessionID != "" already proves the run was a resume, which is the whole
  distinction between "the session's provider copy is broken" (curable) and
  "the credentials are wrong" (not curable). An adapter cannot tell those
  apart from the error text alone, which is why Result.ResumeRejected still
  documents auth errors as out of scope.
- The failure surfaces at session/resume, session/set_model (MUL-5029
  provider re-routing, the likelier path on a resumed session) and
  session/prompt; only two carry any resume-failure signal today. The final
  error text carries the phrase on all three.
- tools == 0 is untouched, so a turn that already acted is never replayed.

The phrase now has a single home in taskfailure.AuthMethodUnresolved, shared
with ResumeUnsafeFailure. The SQL guards stay as-is: they are the only
protection for rows written by a daemon too old to have this retry.

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-12 12:48:03 +08:00
7eeaaaa05e MUL-6015: WS-2: update Codex gpt-5.6 model labels (#6749)
* WS-2: update Codex gpt-5.6 model labels

Co-authored-by: multica-agent <[email protected]>

* WS-2: normalize Codex dynamic model labels

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: zhuchangmin <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-12 11:57:57 +08:00
6e6682539c fix(agent): make readOpenclawStdout's cut-short decision atomic (MUL-6024) (#6765)
* fix(agent): make readOpenclawStdout's cut-short decision atomic (MUL-6024)

TestReadOpenclawStdoutWaitsForCompleteResult failed on #6757's CI with
"cutShort = true after a clean EOF, want false" — a flake unrelated to
that PR, which only touched server/cmd/multica/.

The test's reader deliberately returns data and io.EOF from one Read so
that completion and clean EOF are a single observation, but the reader
goroutine split that observation across two critical sections, and the
poll loop split its own decision across two more. Either seam lets a
tick see a buffer that has just become parseable while atEOF is still
false, and report a stream that ended cleanly as cut short:

  - append bytes / set atEOF were separate lock regions, so a tick
    landing between them saw the completing bytes without the EOF;
  - the tick snapshotted (size, lastByte, atEOF), released the lock,
    then re-locked to copy acc — so bytes arriving in between could
    complete a buffer whose silence check had already passed. This one
    needs no starvation at all, just an unlucky interleaving, and is
    the likelier cause of the CI failure.

Both are now single critical sections. Injecting a 400ms delay at
either seam reproduces the exact CI failure before this change and
passes after it; the test is now deterministic rather than a race the
reader construction merely makes unlikely.

Production impact beyond the flake: cutShort on a cleanly exited
openclaw logs "openclaw delivered its result but did not exit" and
cancels an already-dead process. The delivered result was still
correct, so this was invisible to users but wrong in the logs.

Adds TestReadOpenclawStdoutCutsShortWhenCLILingers, which pins the
other half — a complete result on a stream that never reaches EOF must
still cut short. Without it the suite would pass if the shortcut
stopped firing entirely.

Verified: go vet; go test ./pkg/agent -run TestReadOpenclawStdout
-race -count=50; go test ./pkg/agent/... -race.

Co-authored-by: multica-agent <[email protected]>

* docs(agent): correct what kind of reader returns data with io.EOF

Review nit on #6765. The comment claimed a pipe returns its last bytes
and io.EOF from one Read; it does not. *os.File only reports io.EOF on
a zero-byte read, so os/exec's StdoutPipe always splits the final bytes
and the EOF across two adjacent reads.

The atomicity the comment sits on is still required — readOpenclawStdout
takes any io.Reader, and n > 0 with io.EOF is a legal return — so only
the justification was wrong, not the code. Says instead what production
actually sees: the same seam one Read wider, which still has to outlast
idleGrace to fool the poll, while the race that needs no starvation at
all is the one in the tick branch.

Comments only; no behaviour change.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 18:40:13 +08:00
84a02cc2ac MUL-5991 feat(agent): drive ACP thinking_level from the backend's own configOptions (reasonix first) (#6752)
* feat(agent): drive ACP thinking_level from the backend's own configOptions

Per-agent reasoning effort was wired per provider, so ACP runtimes that
advertise an effort selector over the standard protocol still got no picker
and a hard rejection from the server. reasonix is the reported case
(GitHub #6720): its session/new advertises option id `effort` with
auto|disabled|low|high|max, and set_config_option applies it — but users
could only change it by editing config.toml on the daemon host, per daemon
rather than per agent, invisible to Multica.

Generalize the half of this that ACP actually standardises:

- parseACPEffortOption reads the selector from any ACP session/new result,
  matching option id or category against `effort` / `thought_level` and
  taking the advertised values verbatim. CodeBuddy keeps its flag whitelist
  as an overlay because it applies the level through `--effort`, not ACP.
- annotateACPThinkingFromSession turns that into a model catalog off the
  same handshake, so discovery costs no extra process.
- applyACPEffortOption sends session/set_config_option with the id the
  session advertised, then re-reads currentValue.

The read-back is the load-bearing part. ACP does not require an agent to act
on an option it accepts: Kimi <=0.28.1 confirms "on" after being set to
"max", and Hermes records the value without wiring it into the agent. So the
catalog alone is not treated as proof, and runtimes opt in explicitly via
acpCatalogThinkingProviders once their Execute applies the level. Speaking
ACP is not sufficient — Copilot discovers over ACP but executes through its
own CLI, and a catalog there would render a picker with nothing behind it.

reasonix is the first runtime on this path. Hermes stays closed until its ACP
adapter carries reasoning onto the session (MUL-5770).

Co-authored-by: multica-agent <[email protected]>

* test(agent): keep the ACP effort tests off the Windows build

The windows-execenv job builds the whole ./pkg/agent test binary before
applying its -run filter, so an untagged test file may only reference
symbols that exist on Windows. acp_effort_test.go used
codebuddyACPSessionResult, which lives behind //go:build !windows, and
broke the compile.

Split on that line: the provider-neutral parser, annotate, apply and gate
cases stay untagged and now genuinely run everywhere, while the two cases
needing a POSIX shell stub and the one needing the CodeBuddy capture move
to acp_effort_unix_test.go. Verified with GOOS=windows/linux/darwin go vet
./... rather than by waiting on CI again.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): scope the ACP effort catalog to the model that advertised it

Review caught a real defect: the first cut treated ACP configOptions as
session-scoped and copied one catalog onto every model. That is not what
the protocol says, and reasonix v1.21.5 contradicts it directly --
SessionConfigState resolves the provider entry for the current model and
derives the levels with EffortCapabilityForEntry, so deepseek-v4-flash
advertises `low` while deepseek-v4-pro does not, GLM / OpenAI / MiniMax /
Ollama Cloud each carry their own vocabulary, and a model whose protocol
resolves to none gets no effort option at all.

The consequence was the exact failure this feature exists to prevent: if
discovery happened to land on flash, `low` was offered on pro too,
ValidateThinkingLevel waved it through, the runtime refused it at apply
time, and the task ran at the default while the UI showed `low`.

- annotateACPThinkingForSessionModel now annotates only the model marked
  Default (the advertised currentModelId). Every other model keeps a nil
  Thinking and shows no picker. Reading real per-model catalogs means
  driving the session's own `model` config option once per model and
  consuming each refreshed response; that is a follow-up, not something to
  fake by duplication.
- applyACPEffortOption takes stateIsCurrent instead of resumed. The
  advertised list stops describing the session once set_model runs --
  reasonix returns an empty set_model result and publishes refreshed
  options only through a config_option_update notification the shared
  client drops -- so the local vocabulary check is skipped rather than run
  against the previous model's list, and the runtime's answer decides.
- Drop the resumed branch: reasonix's session/resume does return full
  ConfigOptions, so the delayed-apply caveat it guarded against does not
  exist for the one runtime on this path.
- Narrow the read-back claim in code and docs. It proves the session
  reports the new value, not that the runtime threaded it into its
  provider request. acpCatalogThinkingProviders is where offline
  verification is recorded; the read-back is diagnostics on top.

Adds a three-model regression (flash with `low`, pro without, GLM with no
effort) covering the catalog, ValidateThinkingLevel, and the stale-state
apply path.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 16:22:51 +08:00
807b8706bb MUL-5992 fix(daemon): deny the Reasonix ask tool per task (supersedes #6745) (#6746)
* Revert "fix(agent): keep Reasonix off the ask tool in unattended tasks (#6745)"

This reverts commit 8eaa0d229f.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): deny the Reasonix ask tool per task

`ask` puts a multiple-choice question to a human mid-turn. An unattended
task has no human, and Reasonix wires an asker for every ACP session, so
its own "no interactive user — decide for yourself" fallback is
unreachable: the daemon can only refuse the question, and Reasonix reads
a refusal as a dismissal and cancels the whole turn.

Write a project-scoped reasonix.toml into the task workdir that denies
the tool. Reasonix resolves config as flag > project reasonix.toml > user
config.toml, rooted at the session cwd, so this binds to the task and
leaves the runtime owner's interactive sessions alone. It also covers the
whole turn: deny rules are checked before the read-only allow
fallthrough, and the executor, the planner — which keeps `ask`
deliberately — and sub-agents all decide against the same policy. The
model gets "denied by permission policy" back as an ordinary tool result
and keeps working.

A reasonix.toml that came with the repository is left untouched, since
sidecar cleanup must stay a pure deletion of paths the daemon created;
that task keeps today's fail-closed question handling.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): stop excluding reasonix.toml from managed checkouts

The exclude was at the wrong layer and hid the wrong file. The daemon
writes reasonix.toml at the WorkDir, while a managed checkout is a
directory inside the WorkDir, and this list only reaches the checkout's
.git/info/exclude — so it could never hide the daemon's own copy. What it
did hide, for every provider, is a project config an agent legitimately
creates inside the repository: invisible in git status, impossible to
commit.

The sidecar needs no exclude. For a repo task it sits outside the
checkout entirely; for a local_directory task it is untracked in the
user's repo for the duration of the run and removed by CleanupSidecars,
which is how AGENTS.md and the other WorkDir sidecars already behave.

Also stop the deny-list comment implying that --workspace-only and the
ACP permission policy make the replaced global deny list equivalent —
they don't; it is an accepted widening.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 15:15:06 +08:00
2c74143096 fix(agent): preserve effort for Claude context variants (#6748)
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 15:08:44 +08:00
8eaa0d229f fix(agent): keep Reasonix off the ask tool in unattended tasks (#6745)
Reasonix registers `ask` unconditionally and its ACP server wires an asker
for every session, so the runtime's own "no interactive user — decide for
yourself" fallback is unreachable over ACP. The daemon can only refuse the
question, and Reasonix reads an unanswered question as a dismissal and
cancels the whole turn, failing a task that could have continued.

Tell both agents in the turn, on the only surface they share. The notice
rides the per-turn user message rather than the AGENTS.md runtime brief
because the planner — which deliberately keeps the real `ask` tool — runs
on its own system prompt and never reads the workdir brief.

The existing fail-closed permission policy stays as the backstop: a
question that still arrives is refused, never answered on the member's
behalf by selecting the model's first option.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 14:31:14 +08:00
feigandBohan-J 3dd128532c feat(agent): add oh-my-pi (omp) as a supported agent runtime (#6514)
* feat(agent): add oh-my-pi (omp) as a supported agent runtime

oh-my-pi (omp, https://omp.sh) is a separate CLI that is a drop-in fork
of pi and speaks the same JSON event protocol. It is registered as an
independent provider so a host with both pi and omp installed gets two
runtimes instead of one.

omp is a runtime identity on the pi protocol, NOT a new protocol family.
It is declared in a single BuiltinRuntimes descriptor
(server/pkg/agent/builtin_runtimes.go) that carries id, protocol_family,
default_command, env_prefix, display_name, skills_dir, user_skills_dir,
launch_header, backend overrides, and model_discovery strategy. Every
consumer (agents_probe.go, config.go, daemon.go, execenv/context.go,
local_skills.go, agent.go New/LaunchHeader, models.go ListModels) derives
from this descriptor — no per-file hardcoded omp special cases.

NewRuntime() is the typed entry point for runtime identities: it resolves
id → descriptor → protocol family backend → applies overrides through a
backendOverrideApplicator interface (piBackend implements it). New()
delegates to NewRuntime() for built-in runtime identities, keeping New()
meaning exactly one thing: the protocol-family factory.

Model discovery: parseOmpModels reads the real {"models":[...]} wrapper
with separate provider/id/selector/name fields. Model.ID is the selector
(provider/id), matching parsePiModels' convention so buildPiArgs emits
both --provider and --model — not just --model. A regression test
(TestOmpSelectorSurvivesToBuildPiArgs) pins this end-to-end.

Frontend: provider-logo.tsx maps omp to PiLogo. The transcript dialog uses
the shared providerDisplayName helper. Landing copy updated to 21 tools
in all 4 locales.

Tests (omp_test.go): descriptor dispatch, NewRuntime entry point, binary
resolution, omp-labeled errors, event-stream completion, pi+omp
side-by-side registration, selector→buildPiArgs regression, model parser
coverage (real JSON/empty wrapper/invalid stderr/duplicate), and a guard
that every descriptor field is non-empty and consumed.

Closes #3989

* fix(views): keep Claude Code label in the transcript run details

Switching the provider row to the shared runtime formatter renamed every
Claude run: the daemon has no display-name override for claude, so the
shared formatter answers "Claude", and the legacy claude-code value
title-cased into "Claude-code". Both read as "Claude Code" before.

Keep those two aliases in a table local to this view and defer everything
else to the shared formatter, so the row stays in lockstep with the
runtime list (#5260) without renaming Claude runs.

* test(daemon): assert pi and omp both reach the register payload

The existing omp test stopped at probeAgentCLIs, so nothing covered the
part a user actually sees: whether both runtimes are registered with the
server, each under its own display name. Discovery finding two entries and
New() building two backends both stop short of version detection and the
registration payload.

Add a daemon-level test that runs real discovery off a fake PATH, drives
syncWorkspacesFromAPI, and asserts the payload carries type=pi and
type=omp with names "Pi" and "Oh-My-Pi". Rename the discovery test to
say what it covers, and record the registered display name in the batch
fixture so the name can be asserted.

* docs(agent): correct descriptor comments that describe the old behaviour

Three doc comments still described behaviour the fail-closed rework
replaced:

- ModelDiscovery claimed a nil strategy falls back to the family's
  discovery; ListModels deliberately returns an empty catalog instead,
  because omp exits non-zero on pi's --list-models.
- backendOverrideApplicator claimed backends without it are returned
  unchanged; NewRuntime returns an error rather than dropping the
  descriptor's executable and label.
- ProtocolFamily credited New() with applying the ID-specific defaults;
  that moved to NewRuntime.

Also repair the truncated first sentence of the NewRuntime doc, and fix
discoverOmpModels still calling the output a JSON array when
parseOmpModels right below it documents the {"models":[...]} wrapper.

* style(server): gofmt the files this branch touched

Four files were left unformatted: the descriptor literal and the omp test
struct lost their key alignment, execenv/context.go had its new import out
of order, and local_skills.go kept the old indentation after its switch
moved inside an if/else. Whitespace only — no behaviour change.

---------

Co-authored-by: Bohan-J <[email protected]>
2026-08-10 16:34:07 +08:00
Bowser cd9f4147a5 MUL-5853 feat(agent): support Kimi thinking levels (#6429)
Discovers the effort levels each Kimi model advertises, exposes them through the
existing per-model catalog, and applies an explicitly selected level over ACP
`session/set_config_option` before the prompt is sent.

Per-model efforts come from `kimi provider list --json`, not from a `session/new`
snapshot: that snapshot only ever describes the one model the session was created
with, so using it would leak one model's levels onto the rest of the catalog.

Effort selection is gated on Kimi CLI >= 0.29.0. Kimi 0.28.1 reports the same
supportEfforts as 0.33.0 but its ACP implements only an on/off toggle, answering
set_config_option("max") with success while confirming "on" — so the catalog alone
cannot tell whether the installed CLI can act on what it advertises. The gate reads
agentInfo.version from the ACP initialize response that discovery already makes, so
it costs no extra process. Below the floor, and on any build reporting no parsable
version, model discovery and task execution are unaffected and only the Thinking
control is hidden; Kimi is deliberately not added to the global MinVersions map.

Applying a level never blocks a task: a failed, unconfirmed, or mismatched
set_config_option logs a warning and sends the prompt anyway.

Closes #6059
2026-08-09 12:29:46 +08:00
Chan Nyein Thaw f97db4fc5a fix(agent): support Pi thinking levels through RPC model discovery (MUL-5924) (#6629)
Discover Pi models through an ephemeral RPC session so per-model thinking levels come from Pi's own reasoning/thinkingLevelMap data, and pass the selected level through as --thinking.

Splits the 15s discovery budget (7s RPC / 8s table) and matches RPC responses on the echoed command as well as id, so an RPC surface that accepts the mode but never answers cannot starve the --list-models fallback.

Closes #6628
2026-08-08 23:53:18 +08:00
9914d51040 MUL-5846 fix(agent): stop launching CodeBuddy with MCP disabled (#6556)
* fix(agent): stop launching CodeBuddy with MCP disabled (MUL-5846)

`--strict-mcp-config` means "only use MCP servers from --mcp-config".
buildCodebuddyArgs passed it unconditionally, so every CodeBuddy agent
without a Multica-managed mcp_config was launched in strict mode with no
config at all — zero MCP servers, and the user's own ~/.codebuddy.json
silently ignored. claude.go already gates the flag on hasManagedMcpConfig;
do the same here, and gate the --mcp-config temp file on the same
three-state check so a JSON `null` no longer counts as a managed set.

Also split CodeBuddy off Claude in runtime_mcp.go. CodeBuddy is a Claude
Code fork but keeps its own config file and plugin root (~/.codebuddy.json,
~/.workbuddy/), so reading ~/.claude.json leaked Claude's servers into
CodeBuddy launches while dropping CodeBuddy's own.

Add kimi to the runtime MCP inventory (<KIMI_CODE_HOME>/mcp.json), which
previously reported this runtime as not supporting MCP. Inventory only:
`kimi acp` already merges that file with the ephemeral `mcpServers` array
the ACP backend sends in session/new, and a duplicate name spawns the
server twice, so the task-local merge stays a passthrough for kimi.

Verified against CodeBuddy 2.x and kimi-code 0.33.0 with a local stdio MCP
probe server: before, the strict launch line answered NO_MCP_TOOL and never
spawned the server; after, it returns the probe's payload.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): resolve CodeBuddy's real MCP config path (MUL-5846)

Review follow-up. The previous commit pointed CodeBuddy at ~/.codebuddy.json,
which is only the last fallback in its chain. Verified against the installed
CodeBuddy CLI:

  - the config dir is $CODEBUDDY_CONFIG_DIR, defaulting to ~/.codebuddy
  - user-scope MCP is the FIRST existing of <configDir>/.mcp.json ->
    <configDir>/mcp.json -> ~/.codebuddy.json (a fallback chain, not a merge);
    `codebuddy mcp add --scope user` writes the first
  - those files are JSONC: comments and trailing commas parse fine
  - plugins live under <configDir>/plugins, not ~/.workbuddy

This is not just a missing inventory row. When an agent has a managed
mcp_config, codebuddy.go turns on --strict-mcp-config, so the merged document
the daemon writes is the entire world CodeBuddy sees. Reading the wrong file
meant "add one cloud MCP server" silently dropped every server the user had
configured locally, which is exactly what mergeRuntimeAndAgentMcpConfig exists
to prevent.

stripJSONC rewrites comments to spaces and drops trailing commas without
touching string literals, so a `//` or a comma inside a command argument
survives.

Also adds the session/new counterpart to TestKimiResumeIncludesMcpServers:
Kimi takes MCP over ACP rather than as a CLI flag, so a bare `kimi acp`
launch line is not evidence the servers were dropped.

Regressions added: path precedence, CODEBUDDY_CONFIG_DIR, JSONC (including
comment-like text inside strings), managed + local merge, same-name override,
explicit empty set, JSON null passthrough.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): let CodeBuddy merge its own MCP scopes natively (MUL-5846)

Round-2 review follow-up. Pointing the daemon at CodeBuddy's user-scope file
only fixed one of three scopes: --strict-mcp-config drops user, project AND
local config, so an agent with a managed mcp_config still lost every server
the user had configured locally.

Measured against CodeBuddy 2.x with one real MCP server registered per scope,
using process spawns as the oracle (the model's own tool list is not
trustworthy here — it happily names servers that failed to connect):

  --mcp-config only ....... managed + user + local
  --mcp-config + strict ... managed only
  strict only ............. nothing at all

So stop passing --strict-mcp-config entirely and let CodeBuddy build the
union itself. A managed entry already wins a same-name collision natively
(verified), which is exactly what mergeRuntimeAndAgentMcpConfig promises, so
codebuddy also drops out of the daemon-side merge and becomes a passthrough
like kimi.

This removes the class of bugs the previous approach kept generating: the
daemon no longer has to reproduce CodeBuddy's config-root resolution, scope
precedence, or JSONC parsing on the launch path, and the review's second
must-fix goes away with it — agent custom_env CODEBUDDY_CONFIG_DIR is now
read by the CLI itself, so the daemon resolving a different directory can no
longer desync the launch. Project-scope servers also keep their native
approval gate instead of being rewritten into daemon-generated CLI config.
(Measured: a project-scope server sits at "Needs approval" and never starts
headless, with or without our flags.)

codebuddyUserMcpConfigPath and the JSONC reader stay, now used only by the
read-only runtime inventory the Agent > MCP tab renders.

stripJSONC also tightened per the review: only the last comma before a closer
is dropped, so `[1,,,]` stays invalid instead of being silently repaired, and
output is now exactly the same length as input so parse-error offsets line up.

Co-authored-by: multica-agent <[email protected]>

* test(agent): pin kimi MCP delivery against the real CLI (MUL-5846)

Users reported that MCP configured in Multica never reaches kimi, citing the
bare `kimi acp` launch line as evidence. That line carries no MCP flags
because the CLI has none — `kimi acp --help` lists only --login and --help —
so kimi takes MCP over ACP session/new instead, and only an end-to-end run
against the real binary can settle the claim.

Drives this package's own kimi backend with a Multica-shaped agent.mcp_config.
The oracle is the MCP server process itself: a stdio fixture that appends to a
log when spawned and returns a sentinel from its one tool, so neither a
hand-written ACP fixture nor the model's own account of its tools can fake a
pass. Verified against kimi-code 0.33.0: server spawned, tools/list served,
tools/call answered, sentinel in the task output.

Behind the agentintegration tag and MULTICA_RUN_REAL_AGENT_SMOKE, so the
default suites and CI are unaffected.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): reject unterminated JSONC block comments (MUL-5846)

Review nits.

stripJSONC blanked an unterminated `/*` all the way to EOF, so
`{"mcpServers":{}} /* oops` became valid JSON plus whitespace, and the `/*/`
near-miss let the opener's own `*` double as the closer's. Either could make
the Agent > MCP tab list servers out of a file CodeBuddy itself rejects —
verified: the CLI answers "No MCP servers configured" for both. It now
consumes the opener before scanning and returns an error when no closer is
found. Only the inventory is affected; the launch path stopped depending on
this reader last commit.

Also refreshes the launch-path comment, which still described the managed file
as a "controlled set" gated by a strict-mode three-state check that
buildCodebuddyArgs no longer performs. The flag is gone; --mcp-config now adds
to CodeBuddy's own user/project/local scopes.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 15:22:01 +08:00
Bohan Jiang 3eff25401d MUL-5785 fix(agent): return an empty output when a stream-json run has no final text (#6557)
A successful stream-json run with no deliverable text returned the sentence "The agent completed without a final response." as its output. That string is a signal, not a message to a user, and shipping it as content defeated every no-response decision the delivery layer already owns — all three branch on the output being EMPTY: an issue task skips the synthesized fallback comment, a direct chat writes a localized no_response outcome, and a Slack/Lark channel drops the turn instead of pushing anything out.

Return the empty string instead, and classify each assistant event explicitly so the fallback answer only survives turns we positively understood: a tool-using turn clears it, an unparseable or unknown-shape event clears it (fail closed), and a genuinely silent thinking-only turn leaves it alone. The decision lives in one shared assistantTurn.resolveFallback across claude, codebuddy and qwen. Unreadable events are counted as unreadable_assistant_count so a CLI schema drift surfaces as a number rather than silently empty outputs.

Status stays completed: an empty output is a legitimate terminal result for a turn that ended on a tool call, and re-running it would repeat side effects that already happened.

Fixes #6462
2026-08-07 14:38:03 +08:00
Bohan Jiang ad2d2eb8b4 MUL-5843 fix(agent): stop silently dropping MCP servers on ACP runtimes (#6555)
Built-in Hermes declares no mcpCapabilities in its ACP initialize response yet accepts both http and sse McpServer entries on session/new, so applying the ACP v1 default there discarded every remote MCP server users configured — visible only in the daemon log. Carve a narrow exception for genuine silence on the provider's own verified binary, keeping fail-closed for malformed responses, custom runtime profiles, and the seven unverified ACP backends.

Also warn when mcp_config carries servers under a runtime-native top-level key instead of mcpServers, which previously forwarded nothing with no log at all, and document how mcp_config reaches ACP runtimes.

Closes #6540
2026-08-07 13:14:54 +08:00
3c7221492b fix(agent): deliver the OpenCode prompt on stdin, not argv (MUL-5841) (#6552)
* fix(agent): deliver the OpenCode prompt on stdin, not argv (#6538)

The daemon appended the whole task prompt as an argv element, so on Windows
every OpenCode task whose command line cleared CreateProcess's 32,767
character lpCommandLine limit failed to start at all. Go surfaces the
resulting ERROR_FILENAME_EXCED_RANGE (206) as "The filename or extension is
too long", which reads as an exe-path problem and is not one. A workspace
with a realistic set of models and skills clears that ceiling on the prompt
alone (39,629 bytes in the report), leaving no usable in-app workaround.

`opencode run` merges its variadic [message..] positional with whatever is
piped in, so passing no positional makes the piped text the entire run
message. That path is present in the reported v1.18.14 and back to v1.4.10.

Write the prompt from its own goroutine, as the Pi and Cursor backends
already do: a prompt larger than the pipe buffer blocks mid-write until
OpenCode drains it, and OpenCode cannot drain while nobody consumes its
stdout. OpenCode reads stdin to EOF, so stdin is closed exactly once as the
end-of-prompt signal — including on the cancellation path, where closing it
releases a writer still blocked on a full pipe. The existing process-group
SIGTERM -> SIGKILL ordering from #4533 is unchanged.

Keeping the prompt off argv also stops it being echoed into the "agent
command" log line; prompt_bytes replaces it there.

Scoped to OpenCode. DevEco, OpenClaw (#6032), Qwen, Copilot and Antigravity
inline their prompts the same way, but each CLI's input contract needs
verifying on its own before it moves.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): do not fail a completed run on a benign prompt-write EPIPE

The backend CI run for this PR failed in TestPiExecuteRetainsOnlyLastTurnOutput
with "pi prompt write failed: write |1: broken pipe". The cause is a latent
race shared by pi and the OpenCode code added in the previous commit, which
copied pi's error handling.

A child that exits without draining stdin closes the read end while the parent
is still writing the prompt, so the parent's write returns EPIPE. Both backends
converted that into a failed result even when the agent had already emitted a
complete, successful stream — making the outcome depend on whether the write
landed before or after the child exited, i.e. on machine load.

OpenCode reads stdin to EOF before it does any work, so a run that produced a
complete stream necessarily received the whole prompt; an EPIPE recorded after
that only means the pipe closed on the way out. Report the write error only for
a run that did NOT complete, where it can actually explain the failure, and
append rather than overwrite so the stream's own diagnosis survives. cursor.go
already guards this correctly by ignoring writeErr once a result was seen.

Test fakes now drain stdin, matching what the real CLIs do. A fake that exits
without reading is what manufactures the spurious EPIPE, and that is what broke
CI — pi's piEventStreamScript is the fixture that failed.

Adds a deterministic regression test: a child that emits a full successful
stream and never reads a 1.2 MB prompt. Against the previous commit it
reproduces the CI signature exactly (status "failed", "broken pipe"); with this
change it passes.

pi.go's production handling has the same defect and still needs the equivalent
fix, but that belongs to whoever owns #6485 — this change keeps to OpenCode
plus the test fixture that was breaking the build.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): gate prompt-write suppression on a positive terminal signal

Review caught a false-success path opened by the previous commit. It
suppressed a failed prompt write whenever status was "completed", on the
reasoning that a completed run must have received the whole prompt. That
reasoning does not hold: processEvents starts finalStatus at "completed" and
only fails closed on structural evidence — an open step, or a step awaiting a
continuation. A child that emits NOTHING and exits 0 sets neither, so the
default survives.

The full path: the parent writes a large prompt, the child exits 0 without
reading a byte, the write returns EPIPE, stdout hits EOF with no events, and
the run is reported as completed with empty output and no error. The prompt
never reached the agent and nothing said so. Reproduced deterministically
before the fix: status="completed" output="" error="".

Absence of a failure signal is not proof of success, so record the positive
one instead. eventResult.sawTerminalSignal is set only when a step_finish
closed the last step with no continuation pending, and the write error is
suppressed only on that evidence. It is deliberately not the negation of
noTerminalSignal: an empty stream sets neither, because there is nothing to
fail closed on and nothing proving completion either. When the signal is
absent the write error now also flips the default "completed" to "failed".

Adds the empty-stream regression alongside the existing complete-stream one,
so both directions of the branch are pinned.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 12:31:55 +08:00
37e2b07094 MUL-5831 fix(agent): fail opencode runs that end on an empty step (#6545)
* fix(agent): fail opencode runs that end on an empty step (MUL-5831)

`opencodeBackend.processEvents` required a positive terminal signal but only
failed closed on two shapes: EOF while a step was still open, and a step that
finished with reason "tool-calls" whose continuation never started.

A final step that opens, emits only a reasoning part, and closes with reason
"unknown" and an all-zero token block matches neither, so `opencode run` exited
0 and the daemon reported `completed` with an empty `agent_error` — a run with
no deliverable that leaves the issue stalled until something re-wakes the agent.
Zero input tokens means the provider round-trip never happened; that is a dead
stream wearing a clean finish.

Track whether each step produced anything at all — text, a tool call, or any
non-zero token counter — and fail closed when the run ends on a step that
produced none of them. The criterion is deliberately "this step produced
nothing", not "the run produced no text", so a task whose only deliverable is a
tool side effect stays green. The finish reason is not consulted: a missing or
unrecognised reason must stay terminal for protocol compatibility, and voidness
is orthogonal to it.

The run now enters the existing failure path with a diagnosable error, so the
task's retry rules apply instead of a silent false-green completion.

Refs #6522 — this fixes the first of its three symptoms; see the PR body.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): count every opencode usage field as round-trip evidence (MUL-5831)

The void-step guard claimed "any non-zero token counter keeps the step
productive", but only checked input, output and cache. OpenCode's protocol
keeps reasoning and the aggregate total in fields of their own and reports cost
as a sibling of the token block, and none of the three were parsed at all — so
a real step that landed with reasoning or cost positive while input and output
happened to be zero would be called void and fail a healthy run.

Parse reasoning, total and cost, and move the check into stepReportedUsage so
the criterion is stated once. Reasoning and total are read as evidence only:
total is derived, and TokenUsage has no reasoning bucket, so folding either
into the usage accumulator would change billing figures rather than fix a bug.

Also renames the guard's error text to "no reported usage", which is what it
now means.

Co-authored-by: multica-agent <[email protected]>

* fix(taskfailure): make opencode stream-ended failures retryable (MUL-5831)

Turning a false-green completion red only helps if the failure then retries.
It did not: none of the three errors the OpenCode terminal-signal guard raises
was on the retry allowlist. The two "terminal signal" variants matched rule
13's "signal" substring by accident and landed in agent_error.process_failure;
the new empty-step one fell through to agent_error.unknown. Either way the task
died on the first attempt and max_attempts never applied.

All three mean one thing — the provider stream died and `opencode run` still
exited 0 — which is agent_error.provider_network by definition. Route them
there on the shared "opencode stream ended" prefix. That bucket is retryable
and resume-safe, so the retry child inherits the session and continues the
truncated conversation instead of redoing the work already paid for.

Covered end to end: the classifier cases, and a service-level test that walks
each guard error through Classify into retryEligible — retrying with an attempt
left, and still terminating at the ceiling so a deterministically broken
provider cannot loop.

Co-authored-by: multica-agent <[email protected]>

* fix(taskfailure): upgrade legacy opencode stream-ended reasons (MUL-5831)

Rule 7 only decides where these failures land when THIS server classifies
them, and FailTask classifies only when the daemon sent no reason at all. An
installed daemon predating that entry reports a non-empty
agent_error.process_failure instead — its rule 13 matching the word "signal" in
"terminal signal" — so the empty-reason branch skips it and the run stays off
the retry allowlist. The fix reached only hosts that happened to update, which
is the installed-daemon cadence problem NormalizeDaemonReason exists for.

Upgrade process_failure / unknown / coarse agent_error to provider_network when
the error opens with the guard's own prefix. Wider than the context-overflow
rule above it on purpose: that one leaves refined reasons alone because a
phrase somewhere inside an error blob says less than the bucket an earlier rule
picked, whereas this witness is the guard's message from its first character,
so the old bucket cannot be describing a better-identified cause.

The retry test becomes a matrix over both wire shapes — current daemon (server
classifies) and each legacy reason — since the previous version called Classify
directly and never crossed the normalize boundary where the gap was. A
negative case pins the prefix: an unrelated crash that merely mentions the
phrase keeps the daemon's label.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 11:46:44 +08:00
YYClaw 0453cdb903 MUL-5844 fix(agent): tolerate coarse Kimi wire log mtimes (#6542)
* test(agent): stabilize Kimi resume usage test

* fix(agent): tolerate coarse Kimi wire log mtimes
2026-08-07 10:54:59 +08:00
882666d478 fix(runtimes): document QwenPaw and close its frontend drifts (MUL-5828) (#6521)
* docs(runtimes): document QwenPaw and fix its hidden MCP tab (MUL-5828)

QwenPaw shipped in SupportedTypes but reached the docs only through the
two READMEs. Add it everywhere the other 19 runtimes are enumerated:

- providers / install-agent-runtime (en, zh, ja, ko)
- SELF_HOSTING.md and CLI_AND_DAEMON.md, including
  MULTICA_QWENPAW_PATH / _MODEL / _ARGS and the launch contract
- environment-variables (4 locales): _ARGS covers five tools now that
  MULTICA_QWENPAW_ARGS exists, not four
- landing i18n (4 locales): the "15 supported tools" list was missing
  DevEco Code, Grok, Qoder CN, Qwen Code, and QwenPaw

QwenPaw is the only runtime where ModelSelectionSupported returns false,
so document that its model list is empty by design rather than a symptom
of an offline runtime.

Also add qwenpaw to MCP_SUPPORTED_PROVIDERS. qwenpaw.go forwards
opts.McpConfig, but the omission hid the MCP tab in the agent inspector,
which would have made the new "Multica-managed MCP: yes" row false.

While in these tables, move the Reasonix row back into runtime-id order.

Co-authored-by: multica-agent <[email protected]>

* fix(runtimes): close the remaining QwenPaw drifts and stale runtime lists (MUL-5828)

Frontend-side follow-ups to the QwenPaw docs pass:

- RUNTIME_PROFILE_PROTOCOL_FAMILIES was missing qwenpaw, so the custom
  runtime profile picker could not select it even though migrations
  253/254 and agent.SupportedTypes both allow it.
- PROVIDER_DISPLAY_NAMES was missing it too. The daemon's
  runtimeDisplayNameOverrides maps qwenpaw to "QwenPaw"; without the
  mirror an aliased runtime rendered "Qwenpaw", which is exactly the
  drift the comment on that map warns about.
- provider-logo had no qwenpaw case and fell through to the generic
  Monitor icon. Adds the standalone mark from the official wordmark
  (agentscope-ai/QwenPaw, Apache-2.0), drawn with currentColor so one
  path covers both themes.

Also corrects two stale runtime lists in the root docs:

- Drop the Gemini row and MULTICA_GEMINI_PATH / _MODEL from
  CLI_AND_DAEMON.md. Migration 126 dropped the gemini protocol family
  and no gemini probe exists, so the runtime has been gone for a while.
- Add Antigravity, CodeBuddy, and DevEco Code to CLI_AND_DAEMON.md and
  SELF_HOSTING.md, plus their PATH / MODEL / ARGS variables.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): make MULTICA_QWENPAW_ARGS work and stop advertising _MODEL (MUL-5828)

Review caught two env vars the docs promised but the runtime did not
honour.

MULTICA_QWENPAW_ARGS was read by config.go and forwarded by daemon.go as
ExecOptions.ExtraArgs, but the backend consumed CustomArgs only, so the
value was plumbed all the way down and then dropped. Consume ExtraArgs
before CustomArgs, matching the precedence CLI_AND_DAEMON.md documents
and the five other backends that accept both. The new test fails on the
old backend with "acp --per-agent".

MULTICA_QWENPAW_MODEL cannot work by design: the backend never calls
session/set_model because that rewrites QwenPaw's shared agent config,
so ExecOptions.Model is ignored (pinned by TestQwenpawUsageModelIgnored).
The probe read it into AgentEntry.Model, which only ever feeds that
ignored field. Drop the read and stop documenting the variable, and note
the exception on the four environment-variables pages, which otherwise
claim every tool takes a _MODEL override.

Co-authored-by: multica-agent <[email protected]>

* docs(agent): stop enumerating ExtraArgs consumers in the field comment (MUL-5828)

The list said "claude and codex backends only", which was already wrong
for codebuddy, antigravity, and qwen. An enumeration here has to be
updated from a different file every time a backend opts in, and going
stale is exactly what let MULTICA_QWENPAW_ARGS ship plumbed but dropped.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 10:44:20 +08:00
b24634e6eb MUL-5810: fix(agent): reconcile ACP terminal usage (#6503)
* fix(agent): reconcile ACP terminal usage

Co-authored-by: multica-agent <[email protected]>

* fix(agent): make ACP usage reconciliation deterministic

Co-authored-by: multica-agent <[email protected]>

* fix(agent): normalize merged ACP usage fields

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 17:02:02 +08:00
25d26c2bf7 fix(agent): send Pi prompts over stdin (MUL-5779) (#6485)
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 16:26:23 +08:00
7bc1e3c36a test(agent): make OpenClaw EOF test deterministic (MUL-5802) (#6501)
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 16:14:02 +08:00
Bohan Jiang 7a7de4fa56 MUL-5773 fix(agent): report kimi token usage from its session wire log (#6495)
kimi-code 0.33.0 exports no token counters over ACP, so every kimi task landed on the usage dashboard with no row at all. Read the counters from kimi's per-session wire log instead, the same fallback codex.go uses for Codex rollouts.

The scan is bound to the ACP session id, buckets usage by the model each record names, covers subagent logs, filters records by timestamp so a resumed session is not re-billed, and sums only usage.record (the sibling step.end event repeats the same numbers).

Verified end-to-end against the real CLI: the task reports input:5075 output:26 cacheRead:17664; with the fallback disabled the same run reports no usage at all.

Fixes #6448
2026-08-06 15:44:25 +08:00
96d567d517 fix(agent): pair failed tool results for OpenCode (MUL-5802) (#6497)
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 15:22:32 +08:00
Bohan Jiang 941aa41898 fix(daemon): discover models from the custom runtime profile's own binary (#6488)
handleModelList resolved the executable from d.agents()[rt.Provider], which
holds built-in CLIs only, while runTask launches the custom runtime profile's
own command (MUL-3284). A host with only the custom command installed answered
the model list with `no agent configured for provider "hermes"`, so the picker
stayed empty even though the same runtime ran tasks fine; a host with both
installed enumerated one binary and executed the other, so the picker could
advertise models the launched CLI rejects.

Discovery now mirrors runTask's resolution order: the profile owns the path when
the runtime is profile-backed, the built-in entry (with its MUL-4486 self-heal)
otherwise, and the original failure is kept for the case it was written for.

The 60s discovery memo carried the same defect — only codex / opencode / deveco
keyed on the executable path, so a built-in runtime and a same-family profile
runtime on one host shared an entry. Every dynamic-discovery branch now uses
discoveryCacheKey; an empty path still maps to the bare provider key.

Also stops pi's parser coining CLI usage text into model IDs: a pi-family
profile pointing at a fork without --list-models printed a usage hint that the
field splitter turned into a `Run/`omp` model. The filter is narrow enough that
a real catalog row alongside a usage hint still parses, so reading a catalog off
a non-zero exit (#3729) is unchanged.

fixed_args still does not participate in discovery — tracked in MUL-5807.

Fixes #6466. Fixes #4482. Fixes MUL-5789. Fixes MUL-5471.
2026-08-06 14:21:46 +08:00
ca2541a2ae fix(agents): explain why a runtime refuses thinking_level (MUL-5770) (#6447)
Setting a reasoning effort on a Hermes agent failed with "thinking_level
\"high\" is not a recognised value for runtime \"hermes\"", which reads
like a typo. It is not: Hermes has no reasoning control on the surface
Multica drives it over, so no spelling of the value can ever work.

Add ThinkingControlSupported as the capability predicate behind the
existing token gate, and use it so the API answers with the capability
gap instead of blaming the value. The Hermes evidence (ACP session/new
advertises no configOptions, set_config_option is inert, _make_agent
never sets reasoning_config) is recorded next to the predicate so the
next reader does not re-derive it, with a pointer from hermes.go.

No behaviour change to which values are accepted.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 21:13:52 +08:00
beast 31cd51ae2f feat(daemon): converge on out-of-band multica and agent CLI version changes (MUL-3269)
A daemon whose `multica` binary or agent CLI was replaced out of band kept
running the old version until someone restarted it by hand, and most people
never knew they had to.

Two separate behaviors, deliberately not one:

- The `multica` binary being replaced (brew upgrade, a manual download, a
  downgrade) is followed by a restart, once the daemon is idle. A running task
  is never interrupted; a busy daemon defers to the next check and reports the
  reason through `daemon status` / `reload_pending_reason`. This is independent
  of the GitHub auto-update poller and has its own switch
  (`--no-auto-reload` / `MULTICA_DAEMON_AUTO_RELOAD` / `disable_auto_reload`),
  because "don't pull new versions" and "follow the binary I replaced myself"
  are different intents. Desktop-managed daemons stay excluded.
- An agent CLI upgrading in place is a hot refresh: re-probe, refresh the
  cached version and the server-side registration, and let subsequent tasks run
  under the new version's policy. Multica's availability does not track a third
  party's release cadence.

Failure semantics are explicit. An unreadable, blank, or unparseable version is
"no evidence", not a version change: the runtime and last trusted version are
kept and the next round retries. A version confirmed below the minimum takes
that provider's runtimes offline once the daemon is idle, and recovers
automatically on upgrade. A late register response, a newly synced workspace,
or an older cleanup request can neither revive a runtime already judged too old
nor knock out one that has legitimately recovered.

No migrations, no server endpoints, no frontend changes.
2026-08-05 19:54:22 +08:00
Bohan Jiang c896f677d5 fix(agent): recover from a codex resume that overflows the reader (MUL-5722) (#6420)
Layer 1 (#6383) raised the shared scanner cap to 32 MiB, which moved the cliff
without removing it — a codex rollout is append-only, so a thread that outgrows
any fixed cap still fails its resume forever. This adds the recovery path.

Layer 2: an oversized thread/resume response is reported as
Result.ResumeRejected rather than a crash, which is the positive evidence
shouldRetryWithFreshSession needs and reconnects the recovery #5715 had
unintentionally cut off. Reaching it required one real fix — the reader wrapped
the scanner error with %v, so bufio.ErrTooLong never survived to a caller.

Layer 3: a new codex_resume_oversized reason marks the session resume-unsafe,
and both resume lookups block by TIME rather than by matching the failed row.
That shape is required, not stylistic: the failure happens before the turn
starts, so the row lands with session_id NULL and is dropped by
latest_per_session before any error-text filter runs. The block expires once a
thread terminates after the overflow, so an issue recovers instead of starting
cold forever.

Also splits the MUL-4424 continuity notice by what each surface can still read
— issue comments and Slack channel history can be re-read, web chat and Feishu
cannot — so only the last group tells the user a loss happened. The backend no
longer holds any of that wording; it receives ExecOptions.ResumeContinuityNotice
from the caller and stays silent when the prompt already carries it, which makes
the duplicate injection on the retry path structurally impossible.

Known gap, tracked not claimed: for chat, the claim handler reads
chat_session.session_id before the fallback query, so a daemon predating this
PR leaves that pointer naming the oversized thread. Clearing it needs the
attempted session recorded at claim time, which is a schema change.
2026-08-05 16:55:29 +08:00
Bohan Jiang c1bd4f995c fix(agent): fail a run whose provider session ran out of context (MUL-5739) (#6422)
Detect provider context exhaustion from Claude Code's structured
terminal_reason rather than from its error prose, so a saturated session is
retired and the next task cold-starts instead of resuming a conversation
that can no longer answer.

Refs #6402 — does not close it: the captured 2.1.220/2.1.221 frames report
is_error alongside terminal_reason, so the specific `completed` escape the
issue reports is not reproduced. The structured check is the load-bearing
fix and is proven present on the reported CLI version; the composite text
predicate is a bounded backstop for uncaptured shapes.
2026-08-05 15:41:15 +08:00
b98f760f1f MUL-5604: 支持 Reasonix runtime (#6370)
* feat(agent): add Reasonix runtime

Co-authored-by: multica-agent <[email protected]>

* fix(agent): make Reasonix sandbox host-adaptive

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 14:19:28 +08:00