Commit Graph
317 Commits
Author SHA1 Message Date
d276991c60 MUL-6743 refactor(issues): remove the assignment handoff note (#7626)
* refactor(issues): remove the assignment handoff note (MUL-6743)

Usage data puts the handoff note at 0.2% of assignments, so the box
occupied the confirm dialog's centre for the other 99.8% and bought
them nothing. Removing it takes the whole feature with it: the note
input and its runtime version gate, the `handoff_note` write field on
PATCH/batch issue updates, the task column write, and the daemon's
opening-prompt + issue_context.md handoff sections.

The confirm dialog itself stays — it still names who is about to run
and still offers "Don't start yet" (suppress_run).

The `*WithHandoff` enqueue methods only ever varied by that note, so
they become `EnqueueTaskForIssueByActor` / `EnqueueTaskForSquadLeaderByActor`
— the actor-carrying variants autopilot already called with an empty
note. `preview-trigger` drops `handoff_supported`, which nothing read
after the gate moved client-side.

`agent_task_queue.handoff_note` is deliberately left in place: it holds
historical rows and this is the expand half of an expand-contract pair.
Nothing reads or writes it now, so a follow-up migration can drop it
once no deployed binary selects it.

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

* docs(issues): reshoot the assignment confirm dialog (MUL-6743)

The asset still showed the handoff note box the surrounding prose no
longer describes. Recaptured from the app at the same 600x450 frame;
the confirm button now carries the send-chord keycaps it has shipped
with since MUL-5694.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 16:13:33 +08:00
3df7c38ed1 fix(agent): normalise the provider prefix in the Hermes set_model skip-gate (MUL-6958) (#7908)
* fix(agent): normalise the provider prefix in the Hermes set_model skip-gate

The gate added in #5690 (MUL-5029) compares the configured model against the
model the session reports as current with a raw `==`:

    if opts.Model != "" && effectiveModel == sessionCurrentModel {

Those two values come from different id namespaces, so for an agent whose
`model` is stored bare the comparison can never be true. The gate is dead code
for those agents and `session/set_model` is replayed on every turn — which is
the exact situation #5690 was opened to prevent.

Why the two sides cannot match:

  - Hermes always reports a provider-encoded id. `_encode_model_choice` in its
    ACP adapter returns `f"{provider}:{model}"` whenever a provider resolves,
    and the provider falls back to `detect_provider()` or `"openrouter"`, so it
    is effectively never absent. Confirmed against a live `hermes acp` session:
    session/new populates `models.currentModelId` from that encoder, so a bare
    configured `claude-sonnet-4-5` on a `custom` provider is reported back as
    `custom:claude-sonnet-4-5`.

  - `agent.model` is persisted verbatim. `handler/agent.go` writes `req.Model`
    straight into the column on create and update; nothing normalises or
    validates a `provider:` prefix on that path.

  - The bare spelling is a documented, first-class input. `cmd_agent.go`'s own
    flag help is `--model  Model identifier (e.g. claude-sonnet-4-6,
    openai/gpt-4o)`. Only the ACP-catalog dropdown produces the prefixed form,
    because `acpModelEntry` uses the advertised id as `Model.ID`.

So dropdown-configured agents match and skip; every agent configured through
the CLI, the API, a seed, or a row predating the catalog does not, and never
will.

What the replay costs, per turn, in the Hermes runtime: `set_session_model`
replaces the session's agent with `_make_agent`, which re-runs `load_config` and
`resolve_runtime_provider`, joins background MCP discovery for up to
`mcp_discovery_timeout` (~1.5s), and constructs a fresh agent whose system
prompt is then re-derived from disk. It also re-runs `_resolve_model_selection`,
i.e. the provider auto-detection this gate exists to avoid — so the
`custom:deepseek-v4-pro` mis-route from #5690 is still live for precisely the
agents that hit it. Running Hermes' own resolver over the ids this daemon sends
reproduces it: `custom` + `deepseek-v4-pro` resolves to `openrouter`, and
`anthropic` + a bare Claude id resolves away from `anthropic`, both of which
make `_make_agent` drop base_url and api_mode.

The fix replaces the raw comparison with `acpModelIDsEquivalent`, which
normalises the optional prefix before comparing:

  - both sides carry an explicit provider → compare the whole id, so a genuine
    provider switch (openrouter:X → custom:X) still sends set_model;
  - exactly one side carries a provider → compare model names only; a bare
    configured id expresses no provider preference, so the session's own
    provider is authoritative;
  - either side empty → not equivalent, preserving the existing fall-through
    for runtimes that report no current model.

Only the first colon separates provider from model, so slash-bearing
OpenRouter-style names (`nous:moonshotai/kimi-k2.6`) split correctly, and the
comparison is case-insensitive because Hermes lowercases the provider when it
encodes the id.

No migration and no operator action. Rows already written in the prefixed form
keep working — both spellings now resolve to the same selection.

Deliberately not argued on cost. A cache-hit-rate difference first pointed here,
but that measurement was grouped by "does the agent have a configured model",
which on the measured deployment was confounded with the transport wire, so this
change does not claim it. It stands on the comparison being unsatisfiable and on
the guard it restores.

Tests: 12 `acpModelIDsEquivalent` cases, including the provider-switch and
different-model negatives, the case-folding and whitespace cases, and the
slash-bearing model id; plus 6 `splitACPModelID` cases covering the
leading-colon and multi-colon shapes.

* fix(agent): only skip set_model when an explicit provider is confirmed

Review caught a regression introduced by the previous commit: the third
equivalence rule was symmetric, but the risk is not.

    acpModelIDsEquivalent("openrouter:hermes-4", "hermes-4") // => true

Comparing model names whenever *either* side lacked a provider meant a caller
who named a provider explicitly got the switch swallowed, silently, and the
session kept whatever provider it had defaulted to. `origin/main` sends
set_model for that input, so the previous commit changed behaviour for it.

The premise that justified the symmetry — "Hermes always reports a
provider-encoded id" — is false, and this repository already contained the
counterexamples: `acp_effort_test.go` captures `currentModelId: "hermes-4"`
from `hermes acp` v0.20.0 and `currentModelId: "gpt-5.6-sol"` from jcode, both
unprefixed, and jcode routes through this same backend (hermes.go states it
covers both binaries). `_encode_model_choice` returning a prefix is what
happens when a provider resolves; it is not a property of every input.

The rule is now asymmetric:

  - bare configured id  -> equivalent on the model name alone. It expresses no
    provider preference, so the session's provider is authoritative. This is
    the case the gate exists for, and it still skips.
  - explicit configured provider -> equivalent only when the session reports
    that same provider. A bare current id cannot confirm the match, so we fall
    through and send set_model, as before this change.

Tests:

  - three `acpModelIDsEquivalent` table entries, including the missing
    `provider:model` vs bare-id row that let the regression through;
  - `TestHermesSkipsRedundantSetModelForBareConfiguredModel`, an end-to-end
    frame-recording test that asserts the gate actually fires for the bare
    spelling — the unit tests never checked that set_model stopped going out;
  - `TestHermesSendsSetModelWhenExplicitProviderAndBareCurrent`, which fails
    without the asymmetric rule (verified: the recorded frame is absent).

Also addressed from the same review:

  - dropped the stale cache-hit-rate paragraph from the `acpModelIDsEquivalent`
    comment. The PR description claimed it had been removed; it had not.
  - dropped the paragraph arguing the benefit via MCP refresh and tool-cache
    prefix invalidation. It described Hermes' Python internals this repository
    cannot verify or track, and it read as arguing against the sentence right
    after it.
  - softened "the comparison can never be true": it holds when the runtime
    reports a prefixed id, which is not always.
  - noted that `splitACPModelID` cannot distinguish a provider prefix from a
    colon inside a bare model name (`llama3:8b`), and that the degradation is
    safe in the only direction that matters — it falls back to sending
    set_model, never to a false skip.

* docs(agent): correct the bare-current-id evidence in the set_model gate

The justification for the asymmetric equivalence rule cited acp_effort_test.go
as capturing unprefixed model ids from both `hermes acp` and jcode. Only the
jcode half holds.

Verified against a live `hermes acp` v0.20.0 — the same version the fixture
names — driving `initialize` + `session/new`, the two calls this backend makes:
`models.currentModelId` came back provider-encoded in all three cases (no model
param, a bare model param, and a provider-encoded one), and all nine
availableModels ids were prefixed too. Sending a bare `gpt-5.6-terra` returned
`openai-codex:gpt-5.6-terra`, which is the defect this gate fixes and is now
first-hand rather than inferred.

Hermes Agent's encoder does have a bare branch — acp_adapter/server.py
`_encode_model_choice` returns the model alone when the provider is empty,
confirmed by calling it directly — but `_build_model_state` defaults the
provider (`state.agent.provider or detect_provider() or "openrouter"`) and
`normalize_provider("")` falls back to `openrouter`, so a normal install never
reaches it. The `hermes-4` in that fixture is better read as a simplification
made while writing a test about configOptions, not a verbatim capture.

The rule itself is unchanged and still correct: this backend serves Hermes
Agent and jcode, jcode really does report unprefixed ids, and refusing to
confirm an explicit provider against a bare current id costs nothing.

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

---------

Co-authored-by: Garfield <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 13:53:56 +08:00
603519d158 MUL-6977: test: remove implementation-only coverage (#7932)
* test: remove implementation-only coverage

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

* test: remove remaining implementation-only coverage

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

* test: restore compatibility and fragment navigation coverage

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

* test: preserve behavioral cleanup contracts

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 13:23:25 +08:00
a075e58b80 MUL-6961 feat(agent): discover Claude Code's model catalog from the CLI (#7917)
* feat(agent): discover Claude Code's model catalog from the CLI

Claude was the last first-party runtime answering "what models do you
have?" from a hand-maintained list. A static catalog cannot know what the
CLI in front of it can run, so every new Anthropic model opened a window
where Multica offered a model that 400s:

    API Error: 400 Claude Code 2.1.246 does not support this model;
    version 2.1.251 or newer is required.

Claude Code does have a discovery hook, just not on the command line: the
stream-json control protocol answers a `list_models` control request with
the rows its own /model picker renders. Ask the installed binary and the
window closes at the source — a model it cannot run is either absent or
flagged disabled, computed against the real CLI version and the logged-in
account.

Verified against 2.1.223/228/246/258. The three older builds return Fable
5.1 as a disabled row carrying "Update to 2.1.255+ to use Fable 5.1";
2.1.258 returns it as a normal pick. Those rows are the reason no
per-model minimum-version table is needed here: upstream already computes
one, including the remedy copy, which we forward verbatim and grey out.

No version gate guards the request. An unsupported subtype is answered,
not ignored — every build tested replies "Unsupported control request
subtype" in ~2s and exits 0 — so an old CLI costs one cheap round trip and
falls back. Gating would mean hand-maintaining exactly the kind of number
this change removes.

Notes:

- The static catalog stays, now as the fallback and as the server-side
  accept list. It is flagged Catalog.Fallback so a discovery failure is
  not pinned in the server's day-long cache (MUL-5549).
- Discovery replaces rather than merges, as with every other dynamic
  provider. The picker narrows to the CLI's own list; agents already
  pinned to an older model keep working, and manual entry still reaches
  them.
- Effort levels now come from each row instead of scraping `claude
  --help` and narrowing through the hand-kept claudeModelEffortAllow.
- Catalog ids keep the context-window tag, because `claude-opus-5[1m]` is
  what the CLI would really run for that row. That exposed a latent bug
  in two capability lookups, which normalized the query side but not the
  catalog side; both now normalize both, or a model would fail to find
  its own effort picker.

MUL-6961

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

* fix(agent): keep unrunnable Claude models out of the selectable catalog

Review found the first cut carried "this CLI cannot run it" as a flag on
the model itself. That was wrong in a way a flag cannot fix.

Only the create-agent dropdown learned the flag. The inspector picker and
the agent builder read the same list and kept offering the row, so
editing an existing agent still reached the 400 this change exists to
prevent. And the part no amount of teaching fixes: an already installed
desktop client has never heard of the field, so it renders the row as an
ordinary model and persists a model id the CLI rejects — exactly what the
repo's mixed-version API rule forbids.

Unavailable models now travel in their own list, end to end: daemon
report, store, catalog cache, and the client result. `models` contains
only selectable entries. The inspector and the builder needed no changes
to become correct — they read `models`, and the rows are no longer in it
— and an old client is correct for the same reason. Both pickers render
the advisory rows through one shared component that takes no click
handler, so nothing can turn them back into controls.

Two more review findings:

- The default row was dropped when no sibling row named the same model,
  which an org-restricted install can produce. That is not one missing
  picker entry: ValidateThinkingLevelWith resolves an empty model — the
  common "follow the CLI default" configuration — through the entry
  flagged Default, and fails closed without one, silently discarding the
  user's effort on every such task. It is now materialised, keeping the
  row's own capabilities. The test that claimed to cover this asserted
  the opposite of its own comment; rewritten, plus a case proving an
  unrunnable default is never materialised.

- "One cheap round trip" for a CLI without list_models was one round trip
  PER TASK: every Claude task carrying a thinking_level loads the catalog
  before it starts, and cachedDiscovery refuses to memoise a fallback.
  The structured unsupported-subtype answer is now remembered per
  (command, CLI version) — a property of the binary, not a bad moment —
  so an old CLI is probed once. Transient failures stay uncached and
  retryable, and the version in the key makes an upgrade take effect at
  once rather than after a TTL. Detecting it costs ~0.01s against the
  ~1.7s it saves, and the fallback path already paid for it.

The discovery timeout keeps its 20s ceiling, now with the measurements
that justify it: 1.57-2.19s over five runs against 2.1.258, 1.64-1.79s
against 2.1.246. The ceiling is for a CLI that never answers, not a slow
one.

Verified against real binaries: 2.1.246 offers four selectable models and
one unavailable row carrying upstream's own upgrade hint, 2.1.258 offers
Fable 5.1 normally, a missing binary falls back to the flagged static
catalog.

MUL-6961

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

* docs(agent): record the downgrade case as an accepted boundary

Dynamic discovery governs selection, and only selection. An agent already
configured with a model whose CLI is later downgraded still starts and
still gets the upstream 400 — discovery cannot reach a value already
persisted.

That is deliberate, not an oversight, so it is written down next to the
code that invites the wrong fix. Clearing the configured model turns a
legible error into a silent config change the user did not make;
substituting a runnable one is worse, because it succeeds using a model
nobody chose. The downgrade is rare and the upstream error is accurate,
so failing loudly at task start is a fine way to report it.

MUL-6961

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 15:48:38 +08:00
Rusty Raven 2e424e06a1 MUL-5154: fix(agent): surface Kimi provider API errors (#5785)
Surface Kimi provider failures that ACP reports as successful empty turns, preserve terminal error details across retries and EOF, and connect poisoned-history failures to the existing guarded fresh-session recovery path.
2026-09-02 14:45:39 +08:00
yarrumretep 9d09902dfe MUL-6943 feat(agent): add Claude Fable 5.1 model and pricing (#7892)
Adds `claude-fable-5-1` to the static Claude Code model catalog and to the
backend and frontend pricing tables, so the model is selectable with its
effort picker enabled and its usage prices at the published rates
($10 input / $50 output / $12.50 5m cache write / $0.25 cache read — 5.1
prices cache hits at 0.025x input rather than the standard 0.1x).

Maintainer follow-ups on top of the contribution tighten backend alias
matching: both Fable rules now end at a shared `claudeVersionEnd`
terminator anchored to the end of the id, and the tag-stripping retry in
`PriceForModelAlias` peels exactly one context tag. Together these stop
Fable 5 and 5.1 from inheriting each other's tier, keep an unknown variant
(`claude-fable-5-2`, `-10`, `-1-preview`, `[1m][2m]`) unmapped instead of
silently borrowing a rate, and keep the backend and the dashboard resolving
the same suffix forms. Regression coverage added for the dated, `-latest`,
context-tagged and doubly-tagged spellings.

Sonnet 4.6 remains the sole default. Known follow-up: Claude Code rejects
this model below 2.1.251 while the global Claude runtime floor is 2.0.0, so
members on an older daemon see it in the picker and fail at run time; the
fix is a per-model minimum-runtime-version signal, tracked separately.
2026-09-02 13:38:15 +08:00
438a30ef00 MUL-6902 fix(agent): fix Pi v0.4.37 Windows session regression (#7843)
* fix(agent): avoid locking Pi transcript on Windows

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

* test(pi): cover sidecar lock contention and release

* test(ci): run the Windows Pi session-lock regression tests

The windows-execenv job runs ./pkg/agent through an explicit -run
allowlist, and the ubuntu backend job cannot compile a windows-tagged
file. Without this step the two tests added for #7840 would never
execute in CI, leaving the same coverage gap that let the v0.4.37
regression ship.

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

---------

Co-authored-by: worker-opencode <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-09-01 13:12:38 +08:00
mrlonelyandL 108407088c MUL-6836: fix(codex): allow explicit standard speed (#7756)
* fix(codex): allow explicit standard speed

* fix(codex): negotiate explicit standard support

---------

Co-authored-by: L <[email protected]>
2026-09-01 13:04:32 +08:00
mrlonely 2ebe0ae327 MUL-6862: fix(codex): widen thread setup handshake budget (#7790)
* fix(codex): widen thread setup handshake budget

* fix(codex): preserve handshake timeout overrides
2026-08-31 17:02:24 +08:00
mrlonelyandL 6231962e98 MUL-6840 fix(pi): preserve sessions across workdir changes (#7760)
* fix(pi): resume session across workdir changes

* fix(pi): serialize session transcript writers

* fix(pi): keep busy sessions resumable

---------

Co-authored-by: L <[email protected]>
2026-08-31 13:49:38 +08:00
88e9170dc1 refactor(redact): drop home-directory masking from Text (#7772)
redact.Text rewrote the local home directory to /Users/****/ on its way
to the database and the WebSocket broadcast. Removing it.

It was never an access-control boundary. Anyone who can read a task
transcript already sees repository paths, file contents, commands, diffs
and error output; masking one path segment out of all that protected
nothing, while it did make paths non-copy-pasteable and harder to line up
when debugging a run.

It was also incoherent about whose home directory it hid. homeDir and
username were resolved from the process running Text, and the main
transcript fields (Content / Output) are only redacted in the server's
ingest handler -- so a hosted deployment matched them against the
*server's* home directory, never the machine actually running the agent.
Only tool inputs and stderr diagnostics went through the daemon-side call
where the resolved home was the right one.

Transcript visibility is an authorization concern and belongs at that
layer. Secret redaction (API keys, tokens, PEM blocks, connection
strings) is a real boundary and is untouched.

Two tests asserted the removed behaviour and now assert its absence, so
nobody reintroduces host-path masking as a side effect of another fix:
redact.TestTextLeavesHomePathIntact (replacing TestRedactHomeDirectory
and TestInputMapRedactsNestedHomePath) and the home-path arm of
agent.TestCursorExecuteReportsSanitizedStderrOnProcessFailure, which
keeps its bearer-token assertion.

Dropping the branch also resolves the Windows failure in
TestRedactHomeDirectory reported in #7744: Windows can return a
DOMAIN\user account name that is not a substring of the home path, so the
strings.Replace never fired. CI only runs Go tests on ubuntu, which is
why main stayed green.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 13:39:39 +08:00
mikyan fe59262188 feat(agent): add Huawei Cloud CodeArts support (#6985)
* feat(agent): add Huawei Cloud CodeArts support

* fix(migrations): resolve CodeArts prefix collision

* fix(agent): address CodeArts review feedback

* fix(agent): tighten CodeArts integration scope

* fix(agent): resolve CodeArts follow-up review blockers

* fix(agent): redact CodeArts launch arguments

* docs: sync supported runtime counts

* fix(agent): own CodeArts model probe process tree
2026-08-31 12:30:59 +08:00
Bohan Jiang dcc2d58251 MUL-6737: fix(daemon): raise the agent inactivity budget to 2h and derive the tool budget (#7685)
The idle watchdog was sized to notice a hang quickly (30m), but the two costs it trades between are not symmetric: force-stopping a healthy run throws away the work and flips the issue to blocked, while noticing a hang late only holds a concurrency slot.

- DefaultAgentIdleWatchdog 30m -> 2h, sized by the longest legitimate silent step.
- AgentToolWatchdog now derives from the idle budget in both directions; MULTICA_AGENT_TOOL_WATCHDOG still overrides and 0 keeps its 'never force-stop during a tool call' meaning.
- Watchdog tick capped at 5m, so worst-case detection is budget + 5m instead of budget * 1.5.
- Dropped the unreachable 30s tick floor (window >= 1m implies window/2 >= 30s) and asserted the property it was written for directly.
- Removed the dead MULTICA_CODEX_TIMEOUT from .env.example; documented the generic watchdog and Codex timeout rows in CLI_AND_DAEMON.md; synced four doc locales.
2026-08-28 16:08:08 +08:00
VvVandweiweiwei 7d128b5a45 fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467, MUL-5630, MUL-6736) (#6275)
* fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467)

openclawCLITimeout has carried a "Known gap (deliberately not fixed here)"
note since #6084: the 5s deadline could not actually bound an
`openclaw config ...` invocation. This closes it, and fixes a second
OpenClaw misbehaviour found alongside it.

## Bug 1 — the deadline was decorative

exec.CommandContext kills only the direct child. cmd.Output() blocks in
Wait() until the output pipes os/exec manages reach EOF, and cancelling the
context does not unblock that io.Copy. So when openclaw leaves a descendant
holding stdout — its `openclaw-config` helper, or cmd.exe → node on Windows
— the call runs for the *descendant's* lifetime.

#6084 measured this on linux/dash (a shim whose backgrounded child slept 6s
took 6.01s against a 150ms deadline), tried a cmd.WaitDelay backstop, and
reverted it on review because it bounds the call by leaving the descendant
running — trading a hang for a process leak that nothing on Unix reaps. The
note left behind names the real fix: process-tree ownership.

Three call sites had the shape:

  - execenv.execOpenclawCLI       on the task's critical path
                                  (Prepare → prepareOpenclawConfig)
  - agent.detectCLIVersion        runs for every provider inside the
                                  daemon's blocking registration preflight
  - agent.discoverOpenclawAgents  four invocations per call

## Bug 2 — the answer was already on stdout

Measured on an OpenClaw host (openclaw 2026.5.27):

  openclaw --version    258ms  exits cleanly
  openclaw config file    60s  correct path printed, then never exits
  openclaw agents list    60s  correct list printed, then never exits

Waiting for exit turns two working commands into a task-fatal error:

  agent_error.process_failure (prepare execution environment: execenv:
  prepare openclaw config: locate openclaw active config:
  openclaw config file: context deadline exceeded (process: signal: killed))

The contract of these commands is "print a value". Once the value has
arrived and nothing more is coming, whether the process tidies itself up is
not the caller's business, and it should not fail a chat task.

## The fix

Two helpers in server/pkg/agent, both built on the process-group primitives
codex/claude/opencode already use — no new platform-specific functions:

  - RunCollect: hands os/exec an *os.File instead of a buffer, so os/exec
    starts no copy goroutine and Wait returns the instant the direct child
    exits. Then signals the child's process group, which reaps the helper
    and releases the last write end, so our own readers see EOF. Unlike
    WaitDelay this reports the CLI's real exit status, which matters because
    a failed `--version` probe skips runtime registration entirely, and
    because openclawShimDiagnostic type-switches on *exec.ExitError.
  - RunCollectQuiet: returns when the process exits, or when stdout has
    produced output that then stayed idle for 400ms — whichever comes first.
    Reports quiet=true on the idle path so callers can log the CLI's failure
    to exit without failing on it.

The idle shortcut deliberately cannot mask a silent CLI: with no output
there is nothing to salvage, so the deadline still governs and the call
still fails. A non-zero exit still fails too, with stderr intact.

execOpenclawCLI keeps its shim diagnostics and its context-attribution
order byte-for-byte; only the exec mechanism changes.
discoverOpenclawAgents keeps its existing 30s function-level budget rather
than gaining a per-attempt one.

Windows: there is no process group to signal, so calls are bounded (by the
drain grace) but a descendant can still outlive one. Owning descendants
there needs a Job Object and is not attempted here.

## Verification

  - Both packages green with -race; 6 consecutive runs, no flakes.
  - All 51 existing Openclaw* tests in execenv pass, including
    TestExecOpenclawCLITimeoutIsNotMisdiagnosedAsMissingInterpreter and
    TestExecOpenclawCLICancellationIsWrapped — their shims produce no
    stdout, so the salvage path is not reachable and the
    errors.Is(err, context.DeadlineExceeded) contract is preserved.
  - 13 new tests. Both mechanisms are mutation-verified:
      * RunCollect reverted to buffers with no group reap → the guard test
        hangs and the dump is the production stack: cmd.Wait →
        awaitGoroutines blocked, writerDescriptor parked in io.Copy.
      * RunCollectQuiet's idle path disabled → both the unit test and the
        execenv end-to-end test fail after a full 60s.

* fix(daemon): address review on the openclaw CLI collector (MUL-5467)

Three blocking items from review of #6275. All three were reproduced before
being fixed, and the first one was a genuine correctness hole rather than a
style objection.

## 1. The deadline branch salvaged partial answers

RunCollectQuiet returned success from the ctx.Done() branch whenever stdout was
non-empty, without requiring the idle grace or any check that the answer was
finished. A CLI still streaming when the deadline arrived therefore had its
truncated output reported as a completed response. Reproduced with a stub
emitting a JSON document forever against a 250ms deadline: 9 runs in 10 came
back as success carrying an unparseable fragment.

The early return now needs two independent conditions, neither sufficient
alone:

  - an OutputComplete rule supplied by the caller accepts the buffer, and
  - the buffer has since been idle for idleGrace.

Reaching the deadline is never success. The captured bytes are still returned
alongside the error so a caller with its own rule can inspect them, but the
runner no longer decides on the caller's behalf. A nil rule disables the early
return entirely, which is the conservative default for a command whose output
shape has no completeness rule.

The rule cannot be inferred inside the runner, which is the point: only the
caller knows whether `{"agents":[{"id":"a"},` is truncated or legitimate.

  - `--json` subcommands -> agent.JSONOutputComplete (whole buffer parses).
  - `openclaw config file` -> openclawConfigPathComplete (last non-empty line
    looks like a path). Deliberately stricter than
    openclawParseActiveConfigPath, which resolves a relative line through
    filepath.Abs and would therefore accept a Doctor warning border as an
    answer. That matters because OpenClaw prints Doctor UI *before* the path
    (MUL-3136), so an early return on idle output alone hands back the banner
    as the config path — and nothing downstream catches it.
  - `agents list` text fallback -> RunCollect (wait for exit). Decorated text
    has no completeness rule and a silently short agent catalog is worse than a
    slow one; this path still gains the reaping and the bounded return.

## 2. quietWriter published bytes before their timestamp

The buffer write and the last-write timestamp were updated in separate critical
sections, so a reader could observe new bytes carrying a stale timestamp and
conclude the stream had gone quiet at the moment it was producing — truncating
an answer mid-write. Both now move in one critical section (outputBuffer).

## 3. The drain-timeout path raced the readers, and could park a goroutine

RunCollect read its buffers after the drain grace expired while io.Copy could
still be appending to them; the deferred Close on the read ends ran only after
the return values had been evaluated. Separately, RunCollectQuiet handed os/exec
io.Writers, so its cmd.Wait goroutine could sit forever on a descendant holding
the pipe — on Windows nothing released it, since there is no process group to
signal.

Both are now structural rather than patched. A single collector owns the pipes
for both helpers (the *os.File ownership RunCollect already had), and finish()
guarantees that when it returns, every goroutine startCollector spawned has been
joined and nothing can still append to the buffers: it reaps the tree, waits
bounded for Wait, and if the readers still have not seen EOF it closes the read
ends outright — which makes the in-flight Read return — and only then joins them.

## Verification

  - Both packages pass with -race under CI's flags (-p 2 -parallel 2).
  - All pre-existing Openclaw* tests still pass, including the timeout
    attribution and shim diagnostic ones.
  - New regression tests, all three named in the review: continuous output until
    the deadline (must not be success), a prompt followed by a delayed body (must
    not return the prompt), and a late non-zero exit within the grace (must be
    reported as the failure it is). Plus the completeness rules per subcommand,
    the banner-then-path case end to end through execOpenclawCLI, goroutine
    collection on both helpers, and the two outputBuffer contracts.
  - Windows contract coverage added (run_collect_windows_test.go) with a scoped
    step in the existing windows-execenv CI job, since the close-the-read-ends
    path is unreachable on Unix where the group kill releases the pipe.
  - Mutation-verified: restoring the unconditional salvage fails both the unit
    and the execenv regression; dropping the completeness rule from
    execOpenclawCLI makes the Doctor banner come back as the config path.

* test(daemon): make the openclaw config-path fixtures hermetic

The two execenv fixtures printed the literal path the host had printed,
`/root/.openclaw/openclaw.json`. openclawParseActiveConfigPath stats what it
parses and tolerates only os.ErrNotExist, so that path resolves differently by
environment: ENOENT on a developer machine, where /root does not exist, but
EACCES on a Linux CI runner, where it exists and is unreadable. The second case
is a hard error, so TestExecOpenclawCLIWaitsForThePathAfterDoctorBanner failed
in CI for a reason unrelated to the boundary it covers.

Both fixtures now print a real file inside t.TempDir(), which stats cleanly
everywhere. The second test only inspects raw stdout today, but a hard-coded
path there would be a trap for whoever next adds a parse to it.

The literal path stays in the completeness-rule table, which calls the
predicate directly and never stats, so it keeps documenting the shape the CLI
actually emits.

* fix(agent): own one-shot CLI process trees on Windows

* fix(daemon): support current OpenClaw config discovery

* Address review, and narrow this branch to the process-tree fix

Blocking items from the review:

1. The deadline branch no longer salvages. It returns an error unconditionally,
   and callers supply a per-subcommand completeness rule (openclawOutputComplete)
   that decides whether the captured bytes are a finished answer. Regression
   coverage for the three shapes named in the review: output continuing right up
   to the deadline, a banner ahead of the real answer, and a late non-zero exit.

   finish() now reports, rather than being wired to a field that was never
   assigned. Two cases do not converge and both cost the caller something it
   would otherwise never learn: a process still outstanding in Wait after the
   tree kill, and a descendant still holding the pipe when the read ends had to
   be force-closed, which can truncate trailing output. collectorError joins that
   with the command's own error, so nothing is displaced.

2. outputBuffer publishes bytes and the write timestamp inside one critical
   section, so a poller cannot see new bytes with a stale timestamp and conclude
   the stream went quiet while it was in fact producing.

3. The drain-timeout path closes the read ends and waits for both absorb loops
   before any snapshot is taken, so no io.Copy can be appending to a buffer the
   caller is about to read. Windows contract coverage runs as its own CI job.

   The goroutine guarantee is stated at its real strength instead of "no
   goroutine outlives the call, on any platform". Convergence holds whenever the
   tree kill takes. When it does not, one goroutine can stay parked in cmd.Wait
   on a process nothing can terminate; that residue is not removable here, so it
   is reported instead of asserted away. On Windows the same case extends the
   call rather than leaking, because an anonymous pipe is not pollable and
   closing the read end waits for an in-flight Read to release it.

Also from review:

- A JSON error envelope is valid JSON, so JSONOutputComplete accepts one and a
  CLI that prints `{"error": ...}` and then lingers past the idle grace hands
  back err == nil with the envelope in stdout — where the exit-status path never
  sees it. All three JSON readers now check for that explicitly. It matters most
  for `config get agents.list --json`, where the envelope is how a 2026.6+ host
  says the key moved to the registry: decoding it as data turned a graceful
  fallback into a failed preparation. For the object-target read the failure is
  silent, since the envelope would be sanitized and written into the task's
  snapshot as if it were the user's config.

- RunCollectCmd selects on ctx.Done(). It takes a command the caller built, so
  enforcement must not depend on that caller having used exec.CommandContext;
  the ordering matches RunCollectQuiet so the two entry points cannot report
  different error shapes for the same situation. startCollector drops its unused
  ctx parameter rather than implying it enforces one.

- checkOpenclawVersion keeps stderr in the parse. It replaced CombinedOutput(),
  and a build that prints its version there would otherwise fail the gate on
  empty output, which skips runtime registration entirely. extractVersionLine is
  bypassed on purpose: parseOpenclawVersion already scans the whole text, while
  picking the first line containing a semver lets unrelated stderr noise answer
  for it. The probe is bounded because the caller's ctx is the task's, and
  ExplainExecError is applied here too so an ENOEXEC shim is still named
  (MUL-6164).

- TestRunCollectQuietWithoutCompletenessRuleWaitsForExit synchronizes on a
  marker the CLI writes after its answer instead of a deadline. Its two
  assertions want opposite budgets, and the reviewer reproduced the resulting
  flake 3/3 under load. Waiting for the marker also proves more than a timeout
  could: that the runner was still blocked at a moment when the answer was
  already fully available.

Scope: the MCP work that shared this branch is reverted to main's behaviour and
will come back on its own, because it needs a discussion this one does not. It
changed which command supplies the resolved MCP settings, restructured the
include chain, and moved where the user's config bytes live — none of which the
deadline fix depends on. Preparation for a managed-MCP agent behaves exactly as
it does on main here.

$OPENCLAW_HOME path expansion stays: openclawConfigPathComplete calls
openclawHomeRest directly, so the completeness rule for `config file` cannot
tell whether that shape is a finished answer without it.

* fix(agent): converge the collector's tree kill and stop failing calls on cleanup (MUL-5467)

Review follow-up on #6275. All three findings share one root cause: whether the
caller got its answer depended on whether cleanup converged, and cleanup
convergence depended on a single kill(-pgid) landing.

- Retry the tree kill across collectReapWindow. One pass loses a descendant
  whose fork completes between the kill's enumeration of the process group and
  the signal's delivery — measured 3 misses in 10 runs of the forking stub, each
  leaving a `sleep 300` reparented to init and stalling the call until the drain
  budget expired. The window is deliberately short so it does not widen the
  pid-reuse window reapProcessTree already documents.

- Replace the 5s EOF budget with a 400ms settle. Once the direct child has
  exited, at most one pipe buffer of its output can still be unread — a child
  that had written more would have blocked in write() and could not have exited
  — so a short cap cannot truncate a well-behaved CLI's answer. What it cuts
  short is a surviving descendant's trailing output, which is not the answer.

- Stop reporting cleanup residue as a call error. finish() now logs and returns
  nothing: a probe whose output arrived and whose helper the kernel would not
  kill is exactly the case #6084's cmd.WaitDelay got wrong, and a failed
  version probe skips runtime registration entirely. This drops finishErr,
  collectorError and the drainForced/waitPending split, which the four call
  sites were interpreting two different ways by accident.

- Fix the reaping tests. They asserted on `$$` captured inside `( )`, which
  POSIX keeps at the invoking shell's pid, so they named the direct child that
  cmd.Wait reaps regardless — all of them passed with reapProcessTree removed,
  while the run leaked 7 orphans. Record `$!` instead, and add
  TestRunCollectRetriesTheTreeKill, which injects a missed pass because the race
  lives inside one syscall and cannot be forced from a stub.

Verified: collector group 0/10 failures (was 3/10) with 0 orphans, 0/5 under
-race, full ./pkg/agent -race green, execenv Openclaw -race green, and both
mutations — retry removed, reaping disabled — fail the tests that now cover
them, on their own assertions rather than on a cleanup error.

* refactor: move the `$OPENCLAW_HOME` expansion out of this branch

It is not process-tree work. `openclaw config file` printing
`$OPENCLAW_HOME\...` instead of the expanded path is the same class of bug as
#6630 — a path the daemon cannot stat, reported as a fresh install, wrapper
without the user's `$include` — and #6643 shipped the tilde half of it as its
own change. This half belongs there too, not folded into a branch about
owning the CLI's process tree.

Removed here: openclawHomeRest, its branch in expandOpenclawPath, the unit
test, and the completeness-rule row that depended on it. Neither PR now
depends on the other.

openclawConfigPathComplete therefore does not recognize the variable shape,
which is correct in isolation: `main` cannot expand it either, so a rule that
accepted the line would hand the caller a path the parser then fails on.
Such a host keeps `main`'s behaviour — no rule match means RunCollectQuiet
waits for exit — so it loses the hang tolerance and nothing else. The comment
on that function says so and names the change that should close it, because a
gap nobody wrote down is one a rebase re-opens silently.

* fix(daemon): teach the config-path rule the `$OPENCLAW_HOME` shape (#7310 hand-off)

#7310 landed the parser side of this, so the deferred half is now due — and it
was recorded in the code precisely so a rebase could not drop it silently.

While expandOpenclawPath could not resolve `$OPENCLAW_HOME\...`, this rule had
to refuse the shape: accepting it would have reported a finished answer and then
handed the caller a path the parser failed on. The cost was that a host with that
variable set lost the early return entirely and went back to waiting for the CLI
to exit — which is the hang this branch exists to survive. With the parser
fixed, the refusal was the only thing left standing between those hosts and the
tolerance.

Resolved through openclawHomeFromEnv rather than read raw from the environment.
The value may itself be a tilde path that upstream expands before printing, so
`filepath.IsAbs` on the raw string answers false for `OPENCLAW_HOME=~/svc` and
silently withholds the early return on exactly the configuration #7310's review
was about. Mutation-checked: the raw-env version fails the new
`tilde-valued OPENCLAW_HOME` row with `rule(...) = false, want true`.

TestOpenclawOutputCompleteRules becomes subtests so a row can carry the
environment it is about, and gains three: an absolute OPENCLAW_HOME, a
tilde-valued one, and an unset one — the last staying false, so a line with
nothing to resolve it against still waits for exit and lets the parser report
the real error instead of this rule guessing.

* docs(agent): site the collector against outputOwned, not cmd.Output()

#7531 moved every one-shot probe in this package onto launch.go's
runOwned / outputOwned / combinedOutputOwned, which makes three claims in
this branch stale rather than wrong-in-detail:

- RunCollect called itself "the safe replacement for cmd.Output()". There
  are no cmd.Output() call sites left in pkg/agent. The comparison a
  reader needs is against outputOwned, which owns the same process tree
  and differs only in who owns the pipes — so that is what the doc
  compares, including why the probeWaitDelay bound is the right default
  for the probes it serves and the wrong one here.
- RunCollect's path-taking form advertised itself as the migration entry
  point for "the remaining cmd.Output() call sites (models.go's catalog
  probes, codex.go, deveco_models.go, thinking.go)". #7531 migrated all
  four to the owned helpers. It stays as the form for a caller holding
  only a path, and for a probe that turns out to need pipe ownership.
- collector's doc now says outright which of the two properties launch.go
  cannot provide, since "os/exec owns the draining" now describes
  in-package code rather than a stdlib call nobody here makes.

Also adds the `if: needs.changes.outputs.backend == 'true'` guard the new
Windows CI step was missing. Every other step in that job has it; without
it the step ran on frontend-only changes.

* docs(agent): cite upstream for the hang tolerance, not just the timings

The measured "prints its answer and then never exits" for `config file` and
`agents list` came from one host on openclaw 2026.5.27, which invites the
obvious objection: this is an upstream bug, so why compensate for it here
rather than wait for a fix.

Read at openclaw v2026.7.1, it is not a bug in a build — it is the exit
model. `src/cli/one-shot-exit.ts` provides an explicit opt-in,
`requestExitAfterOneShotOutput`, which `runCli` flushes after both streams
drain. In the shipped 2026.7.1 tarball exactly three modules take it:
`models list`, `models status`, and the hooks CLI. The commands this
package invokes — `config file`, `config get`, `agents list` — do not:
their success paths only print, and `runtime.exit` appears solely on their
failure paths, so termination is whatever the event loop happens to do.
A version that exits promptly is not promising the next one will.

Also pins openclawHomeRest's citation forward. `runConfigFile` still
prints shortenHomePath at v2026.7.1, and resolveHomeDisplayPrefix there
still selects `$OPENCLAW_HOME` on a non-empty trimmed variable and `~`
otherwise. Worth recording that the separator comes from the original path
rather than being inserted by the prefix, since that is what the rest-slice
in this function relies on.

* fix(daemon): size the config-file idle grace to the real warning gap

E2E on a real OpenClaw host (2026.7.1-2, plus 2026.6.8 and 2026.7.2-beta.7)
falsified two claims this branch was making. Both are corrected here; one
needed a code change.

**The 400ms idle grace was sized against the wrong gap.** The claim was
that a one-shot response arrives in back-to-back writes, so 400ms is
"orders of magnitude longer than the gap between those writes". The
first half is confirmed — 0.012-0.033ms between writes. The gap that
matters is a different one: with a config that produces Doctor and plugin
warnings, `openclaw config file` spread its stdout over 3289ms, of which a
single 3285ms silence sat between the end of the warning block and the
path. The default grace is eligible to fire inside that silence, on
whatever the warnings left in the buffer.

Why it lands on `config file` alone: upstream routes incidental console
output to stderr when argv carries `--json`
(withConsoleLogsRoutedToStderrForJson, src/cli/json-output-mode.ts at
v2026.7.1), so every `--json` caller here gets a stdout that is the JSON
document and nothing else, and a partial document cannot parse however long
the writer pauses. `config file` has no `--json`, so the warnings share its
stdout with the answer.

openclawConfigPathComplete is still the guarantee — a box-drawing border is
not an absolute path — and a grace can only be defence in depth, since the
gap is a property of the host's plugin and Doctor work. But it should not be
defence that loses by three seconds, so openclawQuietIdleGrace gives
`config file` 5s and leaves `--json` on the default. The cost is paid only
by a CLI that hangs: `config file` then returns in path + 5s rather than
path + 400ms, against a 30s deadline.

**RunCollect guarantee 2 overstated what a group kill can reach.** The
helper was measured holding the same stdout pipe as its parent (both
`pipe:[135202]`) while running with its own PGID and SID. `kill(-pgid)`
never reaches that, and neither does #7531's ownership, which uses the same
group. "Invoking a CLI on a timer cannot accumulate orphans" was therefore
wrong on Unix, and the three comments that repeated it are corrected. What
holds regardless is guarantee 1: it is pipe ownership, not the kill, that
makes Wait return and the deadline enforceable. Windows is unaffected — a
Job Object owns the tree irrespective of process groups.

Tests: TestOpenclawQuietIdleGraceCoversTheWarningGap pins the per-command
grace and asserts the `config file` value exceeds the measured gap;
TestExecOpenclawCLISurvivesTheWarningGapBeforeThePath drives a stub with a
warning block, a gap past the default grace, then the path, and requires
the path back.

* fix(agent): make pipe EOF the completion signal, and bound what is kept

Second review round found three blockers on b9b39eaea. All three reproduce
exactly as reported, and all three are cases where this branch was *worse*
than the launch.go helpers it replaced — which is the bar a replacement has
to clear, so the fix narrows the branch rather than patching around them.

**1. Leader exit was treated as answer completion (worst of the three).**
RunCollect*Cmd reaped the process group as soon as the direct child exited.
A wrapper can exit 0 while the real CLI — its descendant, holding the
inherited stdout — has not printed yet, which is the normal shape for an npm
or PowerShell shim. Measured here: the wrapper exits 0, its child prints the
version 500ms later, and this head returned an empty version with a *nil
error* in 427ms where outputOwned returns the version in 517ms. Empty-and-nil
is the worst available shape, and detectCLIVersion routes all 23 providers
through it.

Pipe EOF is the only signal that means "no more output is coming", so:

- detectCLIVersion, checkOpenclawVersion and discoverOpenclawAgents go back
  to launch.go's outputOwned / combinedOutputOwned, i.e. back to os/exec's
  own EOF wait. What this branch still fixes there is the *reporting*: a
  lingering descendant makes Wait report exec.ErrWaitDelay over an answer
  that arrived, and a failed --version probe skips runtime registration
  entirely (#6084 reverted a WaitDelay backstop over exactly that). So
  salvageProbeAnswer drops the error when, and only when, the caller's own
  parse found the answer — never on a non-zero exit, never on a deadline.
- The collector keeps its reason to exist (execenv's task-critical path) and
  gains awaitOutputAfterExit: after the direct child exits it waits for pipe
  EOF, bounded by collectDrainGrace (2s, matching probeWaitDelay), unless the
  caller's completeness rule says the answer is already in — so a CLI that
  prints and exits with a helper on the pipe still pays nothing.
- RunCollect / RunCollectCmd are deleted. With the probes back on
  outputOwned they had no production caller, and an exported wait-for-exit
  collector is exactly the API that invited this bug.

**2. A path-shaped warning could be returned as the config path.**
openclawConfigPathComplete accepted any absolute-looking last line, and the
previous commit's answer — a 5s grace — was the wrong dimension, as the
review pointed out by quoting this branch's own comment that no fixed grace
can bound the host's plugin work. Reproduced: a CLI printing an existing
plugin-cache.json path, pausing 6s, then printing the real path returned the
warning path after 5.51s.

Fixed by not asking a question content cannot answer. `openclaw config
validate --json` reports the path in a named `path` field, on every branch
(valid / invalid / file-not-found / error), in every version from v2026.5.5
— which is minOpenclawVersion — through v2026.8.1-beta.3, and `--json` makes
upstream route incidental output to stderr so stdout is the document alone.
openclawActiveConfigPath asks that first and keeps `config file` as a
fallback with no completeness rule at all: it waits for exit and fails closed
on the deadline. Upstream runs the same readConfigFileSnapshot() for both
commands, so preferring the JSON form costs no extra work; it costs one extra
invocation only on a CLI too old for either, which is pinned in
TestPrepareOpenclawConfigFallsBackWhenConfigFileUnsupported.

openclawConfigPathComplete, openclawQuietIdleGrace and
openclawConfigFileIdleGrace are gone. `--json` is now the only shape with a
rule, and the doc says why that is the design and not a gap.

**3. Retention was unbounded.** absorb appended without limit, where
outputOwned keeps the last probeStderrSampleBytes. Measured: 13,107,400 bytes
of stderr retained from a CLI writing continuously, ~400x the bound it
replaced. stderr now keeps its tail at collectStderrTail (= 32 KiB, so moving
a call site between mechanisms cannot change what a failed probe reports) and
stdout is capped at collectStdoutLimit and reports overflow as an error —
deliberately asymmetric, because a head-truncated answer is how a partial
catalog becomes a confident empty one.

Every new guard was mutation-verified by restoring the mechanism it covers:

| Mutation | Test | Failure |
|---|---|---|
| collector + reap on leader exit | TestDetectCLIVersionWaitsForAWrapperDescendant | `version = ""` in 0.42s |
| awaitOutputAfterExit returns immediately | TestRunCollectQuietWaitsForAWrapperDescendant | `stdout = ""` |
| drop the rule short-circuit | TestRunCollectQuietDoesNotWaitWhenTheAnswerIsIn | pays the full 2s |
| unbounded buffers | TestCollectedStderrKeepsOnlyItsTail | retained 13,107,417 bytes |
| unbounded buffers | TestCollectedStdoutOverflowIsReportedNotTruncated | 524,296 bytes, nil error |
| shape rule for `config file` | TestOpenclawActiveConfigPathIgnoresAPathShapedWarning | returns plugin-cache.json |

The new fallback test also caught a bug in this commit before it shipped:
openclawValidatedConfigPath accepted upstream's `CONFIG_PATH ?? "openclaw.json"`
literal, because expandOpenclawPath turned that relative name into a confident
path under the daemon's cwd. It now requires the reported value to be absolute
before expansion, allowing only the `~` and `$OPENCLAW_HOME` forms.

* fix(agent): repair the Windows-only collector test call sites

The mechanical conversion of the deleted RunCollect call sites over-widened
three tuples in run_collect_windows_test.go, which already used
RunCollectQuiet's four results. Host `go build` and `go vet` never compiled
them — the file is `//go:build windows` — so the break surfaced on the
windows-execenv job as `assignment mismatch: 5 variables but RunCollectQuiet
returns 4 values`.

Verified this time the way it should have been the first time:
`GOOS=windows go build ./...` and `GOOS=windows go vet ./...`, plus darwin and
linux. That is the check to run whenever this package's exported signatures
move, since a third of its process-lifecycle tests only compile on Windows.

* test(execenv): pin the config-path payload shapes E2E measured

E2E on OpenClaw 2026.7.1-2 confirmed `config validate --json` as the config-path
source and corrected two things this branch had inferred from source.

**The exit status is load-bearing in the wrong direction, and this was untested.**
Two of the three real states exit *non-zero* while carrying the answer: a missing
config file gives `{"valid":false,"path":"…","error":"file not found"}` and an
invalid one `{"valid":false,"path":"…","issues":[…]}`, both exit 1 with stderr
empty. A fresh install is the missing-file case, so reading a non-zero exit as
"no answer" would break first run. openclawValidatedConfigPath already ignored
the status, but nothing pinned it — writeOpenclawConfigStub could only exit 0, so
the tests covered the rarest branch. It now takes an exit code, and
TestOpenclawActiveConfigPathReadsThePathFromANonZeroExit drives all three
measured payloads.

**Two source-based claims corrected.**

- "Preferring the JSON form costs nothing extra" understated it. Measured over
  three runs each on the same host and config: 1.65/1.66/1.65s for
  `config validate --json` against 6.36/4.16/4.06s for `config file`. The JSON
  form is ~2.5-4x faster, which tracks the output it does not render — 715 bytes
  of JSON against 2663 bytes and 28 lines of warning UI.
- The reason a `--json` stdout is clean was attributed to upstream's
  console-log reroute (`withConsoleLogsRoutedToStderrForJson`). That function
  exists, but it is not what was observed doing the work: the Doctor and plugin
  warnings arrive as a `warnings` array *inside* the payload. The distinction
  matters for why JSONOutputComplete is a real signal — the stream is
  structurally one document, not merely quieter — so the comments now describe
  the measurement rather than the mechanism inferred from it.

Unrelated and pre-existing: `./pkg/agent`'s ACP timing tests (1-6s wall-clock
budgets) flake under load, and which ones fail varies per run. Control run of
`origin/main`'s whole package on this machine, same conditions: fails
TestCodexExecuteSemanticInactivityAllowsContinuousMessages and
TestCodexExecuteLegacyFirstTurnMessageSatisfiesProgress. Not introduced here.

* fix(agent): require a recognised version before salvaging a probe answer

The ErrWaitDelay salvage in detectCLIVersion was gated on `version != ""`,
and extractVersionLine falls back to the trimmed raw output when no line
carries a semver token — so any non-empty text satisfied it, including
whatever a wrapper printed before the real version existed.

Review reproduced it with a stub that prints a banner, exits 0, and has a
descendant print the version past the 2s WaitDelay: detectCLIVersion
returned "initializing plugins" with a nil error, and logged "CLI answered
but left its output pipes open" — the opposite of what happened. That value
is then persisted as the runtime version, for every provider, since
DetectVersion routes all 23 through here.

extractVersionLine now reports whether the semver scan actually matched, and
only that answers the salvage question. The trimmed-raw fallback still
stands on the success path: a CLI that exits 0 with an unusual version
format is reporting its version (#2516), and it is only unusable as evidence
that a bounded, incomplete read already holds the answer.

Mutation-checked: with the old gate,
TestDetectCLIVersionDoesNotSalvageABannerAsTheVersion fails with exactly the
reviewed shape — version="initializing plugins", nil error, misleading warn.

* test(agent): derive the collected-stdout cap from the largest answer

collectStdoutLimit argued for why a fail-closed cap is needed but not for
its value, and where it falls is a product compatibility boundary rather
than only a memory bound: a legal config past the cap stops a task from
starting at all.

The derivation is now recorded and executable. Measured answers on 2026.7.1-2
are 715 B (`config validate --json`) and 2663 B (`config file`); the largest
answer any call site asks for is the resolved config from `config get --json`,
which upstream puts no ceiling on. So the worst case is constructed — 1000
agents and 250 MCP servers with the fields OpenClaw carries, 547,291 bytes,
already orders of magnitude past a real deployment — and 8 MiB leaves 15x on
top of it while matching what the daemon already holds for a comparable
per-task payload (maxLocalSkillBundleSize).

Two tests pin it: the constructed high-water mark must round-trip through the
collector byte-identically with the stated headroom, and the boundary itself
is exact — at the limit the answer is returned whole, one byte past it fails
closed with errCollectStdoutTooLarge.

* fix(execenv): share one CLI deadline across both path-resolution attempts

openclawActiveConfigPath asks `config validate --json` first and falls back
to `config file`, and the fallback carried a fresh full deadline. That made
the worst case five budgets against a ceiling derived from four: at the 60s
override ceiling, 5m of CLI time alone, landing exactly on
daemon.defaultTaskPrepareTimeout — so the specific, non-retryable
ErrOpenclawCLITimeout collapses back into the generic retryable prepare
timeout, which is the outcome openclawCLIMaxTimeout exists to prevent.

Both invocations answer the same question, so they now share one context.
The multiplier is renamed to openclawMaxCLIDeadlinesPerPreparation because
that is what it always measured — the budget is per step, not per invocation.

The consequence is deliberate and asserted: a validate that burns the whole
budget leaves the fallback none, os/exec reports the expired context on Start,
and the failure still carries ErrOpenclawCLITimeout. A CLI that cannot say
where its config is within the entire deadline will not answer the same
question on a fresh one.

TestPrepareOpenclawConfigWorstCaseCLIBudgets now drives the real worst case
(validate fails, config file answers, registry fallback, managed MCP) and
counts distinct ctx deadlines rather than calls — the old test synthesized a
validate success from the `config file` response, so the two never fired in
the same run. Mutation-checked: with separate deadlines it reports 5 budgets
against a constant of 4.

---------

Co-authored-by: weiweiwei <[email protected]>
2026-08-28 14:04:10 +08:00
Roman Yufimov 620f99d8ce fix(codex): wait for authoritative turn completion (#7554) 2026-08-26 14:33:08 +08:00
46b5d9e6d1 MUL-6658 fix(agent): own the runtime process tree on every backend (#7522) (#7531)
* fix(agent): own the runtime process tree on every backend

Cancelling a task on Windows left the agent running. The daemon marked
the task cancelled, revoked its mat_ token, and killed what it thought
was the CLI — but on Windows the direct child is usually a cmd.exe shim
and the real CLI is already a grandchild, so it survived. One reporter's
orphan kept working for 40 minutes, read the daemon owner's profile PAT
after its own token started returning 401, and posted comments as the
member (GH #7522).

Whole-tree ownership already existed on both platforms. It was opt-in,
and opt-in rotted: 8 of the 27 places this package starts a process asked
for a Unix process group, and 3 of the 23 backends took Windows Job
Object ownership. Adding claude to that list would have left 19 backends
with the same hole, so the fix is structural instead.

- newRuntimeCmd, at the single point where a runtime process is built,
  now puts every command in its own process group and replaces os/exec's
  leader-only Cancel with a group-wide kill. A backend that wants a
  graceful shutdown still assigns its own cmd.Cancel and wins. This also
  covers the --version and model-discovery probes, which cmd.Output()
  runs without ever reaching a start helper.
- startOwnedProcessTree is now the only way this package starts a
  process, so Windows Job Object ownership is not something a backend
  can forget. Each start is paired with releaseProcessGroup after the
  reap, which drops the job handle and kills whatever outlived it.
- Two structural tests enforce both halves, the way
  TestOnlyLaunchGoSpawnsRuntimeProcesses already enforces the argv
  boundary: a new backend calling cmd.Start fails, and one that takes
  ownership without releasing it fails.

The Unix half is #5918 for the 20 backends the original fix did not
reach: their CLI was never in its own group at all, so no group-wide
signal could have reached it.

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

* fix(agent): own the process tree for synchronous probes too

Review found the ownership boundary had a hole the PR claimed to cover.
os/exec's Run, Output and CombinedOutput call Start themselves, so a
probe written with cmd.Output() never reaches startOwnedProcessTree and
owns nothing on Windows — where the direct child of a `--version` probe
is typically the npm shim and the real CLI is already a grandchild. That
is the same escape #7522 was reported for, in 15 call sites nobody was
looking at; detectCLIVersion's own comment already describes a broken
CLI leaving grandchildren that hold the stdout pipe.

- runOwned / outputOwned / combinedOutputOwned give a synchronous probe
  the same start, reap and release a task launch gets. They keep the
  stdlib contract: stdout returned, stderr attached to the ExitError,
  combined output through a single writer so os/exec gives it one pipe.
  The stderr sample is bounded, as os/exec bounds its own.
- All 15 probe sites route through them, and the structural guard now
  rejects Run/Output/CombinedOutput alongside Start.

The release guard did not hold its invariant either: it only asked
whether a file contained any releaseProcessGroup, so a second unreleased
start in a file that already had a pair would pass — and models.go is
exactly that shape. It now works per function: one owned start each,
paired with at least one release. Equal counts would be the wrong rule,
since dsh's Execute legitimately releases on two exit paths.

Both guards are now pure functions over an AST, with tests that feed
them synthetic sources — including the second-unreleased-start case that
the file-level version missed — so the guards fail closed without
needing a real regression committed to find out.

Adds the Windows probe regression (descendant must be in the job and die
with a cancelled probe) plus unix equivalents, and names the consequence
in the degraded-ownership warning, which is the only signal an operator
gets that cancellation on that host is back to killing the leader alone.

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

* fix(agent): bound a finished probe's wait on pipes its descendants hold

Review caught that runOwned could wait for its own cleanup and never
return, and it reproduces: a descendant that inherited the output pipes
holds them open after the leader exits, cmd.Wait blocks until the copy
goroutines see EOF, and what would close those pipes is the release that
runs after Wait.

Cancellation breaks the cycle, which is why every test in the last round
missed this — they all cancelled first, and they all set WaitDelay by
hand. Normal exit has nothing to break it, and checkOpenclawVersion runs
on the caller's context before runContext establishes any task timeout,
so a version wrapper that exits cleanly while leaving a pipe-holding
descendant would hang the task until a human cancelled it.

runOwned now fills in a bounded WaitDelay when the caller left it zero —
Go defines WaitDelay for precisely "a child process that exits but
leaves its I/O pipes unclosed" — and signals the group before dropping
ownership, so the descendant is reaped on Unix too, where releasing a
process group is a no-op. A caller with its own bound keeps it;
detectCLIVersion has set one by hand since MUL-3812 for this same shape.

Adds the normal-exit regression on both platforms: the leader exits
holding nothing, the descendant keeps the pipes, and outputOwned has to
return in bounded time with the descendant gone. The Windows descendant
spawner gains a spawn-and-exit mode and now hands its stdio to the
descendant, which is what makes it hold the pipes at all.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-25 16:27:46 +08:00
750fc0cc5a MUL-6655 Fix MCode ACP session startup race (ZIC-199) (#7516)
* Fix MCode ACP session startup race

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

* fix(agent): only trust transport signals for MCode startup exit

mcodeSessionStartupExited also matched "process exited" / "broken pipe"
anywhere in the error text. A still-running MCode reports session/new
failures as a JSON-RPC *acpRPCError, and session/new is the call that
launches mcpServers, so a real error such as `failed to start MCP
server "filesystem": process exited with code 1` was rewritten into
"MiniMax Code ACP exited before starting a session; ... upgrade
MiniMax Code". That branch returns a fixed string with no %v, so the
original error was dropped, not just relabelled, pointing the member
at the wrong subsystem.

Drop the text matching and keep the transport-level signals. Real
provider exits already arrive as errMcodeProcessExited via
closeAllPending, and a stdin write racing the exit arrives wrapped as
syscall.EPIPE, so no covered case is lost.

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

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-08-25 12:30:52 +08:00
bdcb231ba0 MUL-5466 fix(agent): deliver the qwen prompt on stdin, not argv (#7343)
* fix(agent): route qwen launch through the Windows invocation chooser

On Windows npm installs qwen as qwen.cmd, a batch shim. Spawning a .cmd
goes through cmd.exe, whose %* forwarding re-tokenises the raw command
line, so the `-p <prompt>` value and the `--output-format stream-json`
flags buildQwenArgs puts behind it are mangled whenever the daemon's
structured chat prompt carries newlines, quotes or `&`. Qwen then runs
without a usable prompt or without stream-json, exits 0 after emitting a
single unparseable stdout line, and finalizeStreamResult reports "qwen
stream ended without terminal result". A short manual `qwen -p "hallo"`
never hits the mangling, which is why the CLI looked healthy in isolation.

Mirror the existing cursor/pi/copilot fix instead of inventing a new
mechanism: resolve qwen.ps1 next to the .cmd and invoke PowerShell with
`-File <ps1>` directly so Go hands each argv token whole, staying on the
official launch path. qwenBackend.Execute now builds its process through
Command.execVia(chooseQwenInvocation, ...) rather than Command.exec; on
macOS/Linux the chooser is a passthrough and behaviour is unchanged.

Fixes #6082

* test(agent): cover qwen's stdin prompt delivery and update signatures

buildQwenArgs dropped its prompt parameter in the prior commit (the
prompt now goes on stdin, not argv), so the existing unit tests needed
their call sites and assertions updated to match — including an
explicit check that "-p" never appears in argv again.

Adds TestQwenBackendDeliversPromptOnStdin: a round-trip regression
guard proving a prompt containing embedded double quotes, an em dash,
and shell metacharacters reaches the child process byte-for-byte via
stdin, the way cursor-agent and Pi already do (#5649's fix). The fake
qwen script now optionally captures stdin to a file the same way it
already captures argv.

* test(agent): cover qwen Windows PowerShell launch

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-24 15:08:14 +08:00
1cff79d256 fix(agent): discover Grok reasoning effort catalog (#7252)
Co-authored-by: Bowser Bot <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-24 15:03:47 +08:00
Danila Katalshov 6e70b1dc4b fix(agent): preserve newlines in Antigravity streamed text (#6717)
The Antigravity adapter scanned agent stdout line by line and emitted each
streamed MessageText as the bare line without its "\n", skipping blank lines.
The daemon concatenates streamed text with no separator
(pendingText.WriteString), so the persisted task_message type=text rows lost
every newline. The chat renders those rows, so block markdown (headings,
lists) collapsed onto one line and rendered as literal "###" / "-" text.

Mirror the output builder in the streamed chunks — prefix the newline on every
line after the first and stream blank lines too — so the reconstructed
task_message text matches output exactly. output itself is unchanged, so
chat_message.content stays identical. Other backends stream whole blocks that
already carry their newlines, so only this adapter was affected; the daemon
concatenation, the frontend join, and the markdown renderer are all correct.

Fixes #6149
2026-08-24 14:43:46 +08:00
8a5a6adbf7 feat(agent): add ZeroClaw as a native ACP runtime (MUL-6511) (#7351)
* feat(agent): add ZeroClaw as a generic ACP agent runtime

Add ZeroClaw (a Rust-based, single-binary generic agent CLI) as a
first-party runtime, driven over the standard ACP JSON-RPC transport via
`zeroclaw acp`, mirroring the Dim/QwenPaw/Grok ACP backend shape. Only the
binary, launch args, and blocked-flags policy are ZeroClaw-specific; the
backend reuses the shared hermesClient ACP transport and deliverable
tracker.

No provider-specific quirks (permission-preset injection, minimum-version
gate, or a separate authenticate step) are assumed without evidence — those
were verified against real Dim/Grok binaries and would need the same
hands-on verification against a real ZeroClaw binary before being added
here.

Backend: SupportedTypes/New/launchHeaders wiring, model discovery via
session/new's advertised catalog, daemon probe with
MULTICA_ZEROCLAW_PATH/MODEL overrides, AGENTS.md context delivery,
default-command-name registration, metrics label, and a migration widening
the runtime_profile.protocol_family whitelist.

Frontend: provider union, MCP-config support flag, and a placeholder
provider-logo mark (no official ZeroClaw brand asset exists yet).

Tests mirror the existing ACP backend conventions: fresh-session,
resume-via-session/load, resume-not-found, transient-load-error, blocked
launch args, timeout, and the shared ACP deliverable-boundary regression
suite.

* fix(agent): renumber ZeroClaw's migration to 378 to avoid a same-day collision

Both this branch and a concurrently-implemented Junie runtime branch
independently picked migration 377 off the same origin/main tip.
Renumbering ZeroClaw to 378 up front avoids opening two PRs from the
same author that claim the same migration number.

* fix(agent): align the ZeroClaw ACP backend with the real 0.8.4 runtime

The backend was written against an assumed vanilla ACP handshake and never
checked against a ZeroClaw binary. Driving a real zeroclaw 0.8.4 over stdio
shows five of those assumptions are wrong, and the test fake encoded the same
assumptions, so the suite stayed green against a contract the runtime does not
have.

- Drop the session/set_model request. It is not in ZeroClaw's dispatch table
  (-32601) and no ACP handler reads a model param at all, so it could never
  succeed; because its failure was fatal, any agent with a model configured
  failed before session/prompt was reached. The model comes from the ZeroClaw
  agent profile, so opt zeroclaw out of ModelSelectionSupported and stop
  reading MULTICA_ZEROCLAW_MODEL rather than leaving the UI offering an
  override that is silently ignored.

- Label token usage from initialize's _meta.zeroclaw.defaultModel, the only
  model identity the ACP surface exposes. session/new returns just
  {sessionId, workspaceDir}, so the two extractACPCurrentModelID reads were
  dead code and the model-catalog discovery they fed could only ever return an
  empty fallback while paying an ACP subprocess per catalog read. Return an
  empty catalog directly, as qwenpaw/mcode do.

- Resume through session/resume instead of session/load. Both restore the
  transcript into the agent, but load also replays every retained message back
  as session/update notifications, which fed the previous answer into this
  turn's deliverable. Add the acceptNotification turn gate every other ACP
  backend carries, so out-of-turn chunks are dropped even if a runtime flushes
  them early (#1997).

- Send agentAlias on session/new when the operator names one. ZeroClaw
  auto-selects an agent only when its config holds exactly one entry, so
  multi-agent installs failed with -32602 and had no way out: `zeroclaw acp`
  has no --agent flag, and passing one kills the process at argument parsing.
  The alias is consumed from custom_args and travels as a param; it is omitted
  when unset, because a hardcoded guess breaks the single-agent case that
  works today. The refusal now names both remedies.

- Stop advertising MCP support. No ZeroClaw handler reads params.mcpServers;
  acp_enable_mcp gates that agent's own mcp_bundles, not the client list, so
  MCP is operator-side only. Hide the MCP tab and warn on a stale mcp_config
  instead of failing the task.

Also teach isACPSessionNotFound about ZeroClaw's custom -32000
SESSION_NOT_FOUND, without which a stale session id was retried forever
instead of restarting fresh. The wording check still decides, so a transient
error under the same code stays non-rejecting.

Tests now encode the observed handshake — _meta default model, session/new
without a catalog, -32000 for an unknown session, -32601 for set_model — and
cover the replayed-history, alias and MCP paths.

* fix(agent): make ZeroClaw integration merge-safe

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

* fix(agent): tighten ZeroClaw ACP fallbacks

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

* fix(agent): preserve ZeroClaw retry semantics

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-24 14:39:08 +08:00
Sanjay Ramadugu f48f24c405 fix(agent): route all Windows Pi runs through powershell -Command to preserve stdin (#7392)
Task and chat runs pass --mode json, so isPiRPCInvocation sent every
real task down the PowerShell -File route. In -File mode PowerShell 5.1
reads stdin through the console ANSI codepage, so the prompt that has
travelled on stdin since #6457 lost every non-ASCII byte (#7355).

Route every Pi invocation through rewriteCmdToPS1Command (-Command with
@args), which forwards stdin untouched; delete the now-dead
isPiRPCInvocation predicate. Extract the argv construction into an
untagged piPowerShellCommandArgs helper so the shape is testable off
Windows, and update choosePiInvocation's doc comment: the #3306 -File
reasoning is stale for Pi now that the prompt is on stdin, and the
residual exposure is a multi-line custom option value.
2026-08-24 14:37:26 +08:00
acecb7dd60 fix(pi): keep turn error when process exits 1 [MUL-6585] (#7451)
* fix(pi): keep turn error when process exits 1

Pi (and pi-print-clean-exit) exits 1 after stopReason=error. The backend
then overwrote lastTurnError with "pi exited with error: exit status 1",
so Classify filed a LiteLLM Connection error / Request timed out as
non-retryable process_failure.

Preserve the provider message and classify those strings as
provider_network so auto-retry can resume the session.

* fix(taskfailure): scope Pi network error matching

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

---------

Co-authored-by: buihongduc132 <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-24 14:02:33 +08:00
25afaaf153 fix(agent): MUL-6606 surface hermes model-discovery failures instead of a silent empty catalog (#7464)
* fix(agent): surface hermes model-discovery failures instead of an empty catalog

Hermes model discovery swallowed every failure into an empty list with no
error, so the daemon reported "completed with 0 models" and the picker rendered
an authoritative empty dropdown — no reason, no hint, no prompt to type a model
in by hand. Hermes' own message already explains the common case ("No LLM
provider configured. Run `hermes model` ..."), and it was being discarded on
the discovery path.

- discoverHermesModels sets strictErrors. Unlike grok/codebuddy/copilot, hermes
  has no static catalog to degrade to, so the failure must reach the UI rather
  than be replaced by a stand-in list.
- Add a discovery-path annotation for "no LLM provider configured". It points
  at the daemon's own environment, which is the actual cause when a GUI-launched
  daemon cannot see credentials the user's shell exports. Deliberately not the
  task path's HERMES_HOME/custom_env advice: discovery is per-runtime and reads
  neither, so that remedy cannot populate the picker.
- Give acpDiscoveryProvider a per-provider timeout and set hermes to 40s.
  Measured against hermes 0.20.0: a healthy session/new answers in ~2s, but a
  hermes that cannot resolve its provider takes ~25s to say so. Under the shared
  15s cap that diagnosis was replaced by "context deadline exceeded" — the one
  failure text that tells the user nothing.
- Raise the client poll budget to the server's own 60s modelListRunningTimeout.
  Below that the client abandoned requests the server would still have answered.
  Manual entry stays live throughout the poll, so waiting costs the user nothing.
- Render the reason in the picker, next to the manual-entry input.

Also reconciles three comments that contradicted each other about which
providers report supported=false. ModelSelectionSupported names qwenpaw and
mcode; runtime_models.go claimed hermes, which if "aligned" to would have cost
hermes its model picker entirely.

Verified against the local hermes 0.20.0 CLI. Before: models=0, err=nil at the
15s cap. After: the full "No LLM provider configured" message plus the hint.

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

* fix(agent): address review — poll budget window and Windows test portability

Two blockers from review, both confirmed against the code:

1. The client poll budget did not cover the server's full request lifetime.
   The pending and running windows are sequential, not overlapping: pending is
   measured from CreatedAt (30s to claim), running from RunStartedAt, which
   PopPending sets at claim time. Heartbeat pickup therefore does NOT consume
   the 60s running window, as the previous comment claimed. A request claimed
   at ~29s that then runs hermes' ~40s discovery reports at ~69s — past the 60s
   client budget, while the server still holds the record open. The user got a
   generic client-side "timed out" instead of the daemon's reason, which is the
   exact failure mode this PR exists to remove.

   POLL_TIMEOUT_MS is now derived from the two server constants plus slack, so
   the invariant is visible rather than asserted in prose. Letting the server's
   own timeout win is also strictly better copy ("daemon did not respond within
   30 seconds" beats "model discovery timed out"). Added two fake-timer tests:
   one for a late claim plus a full discovery, one proving the poll still
   terminates once both server windows have closed.

2. The new backend regression tests broke `go test ./pkg/agent` on Windows.
   They write and execute `#!/bin/sh` fixtures, but the file carried no build
   tag, and the Windows writeTestExecutable can only drop bytes on disk. Split
   along the same line as codebuddy_discovery_fallback_test.go: process-based
   cases move to hermes_model_discovery_unix_test.go (//go:build !windows), and
   the assertions needing no subprocess — hint wording, classification
   inertness, the timeout invariant — stay cross-platform against a literal of
   the ACP error string.

Also corrects the Go timeout invariant itself, which had encoded the same wrong
model of the running window, and refreshes two daemon.go comments that still
described discovery as capped at ~15s and as uniformly returning empty lists on
failure.

Verified: GOOS=windows go test -c builds, with the shell-fixture tests excluded
from the Windows binary and all four portable tests present. Both new core tests
fail against the previous 60s budget.

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

* test(agent): make ACP timeout fixture deadline-safe

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-24 13:42:39 +08:00
zyzyhbz 179761a325 fix(agent): advertise grok-4.6 thinking levels and published rates [MUL-6549] (#7406)
* fix(agent): advertise grok-4.6 thinking levels and published rates

Grok Build CLI 1.0.5 lists grok-4.6 as the default model, but
annotateGrokThinking only tagged grok-4.5. A grok-4.6 agent therefore
got Thinking == nil, so the inspector hid the effort picker as soon as
thinking_level was cleared ("Follow CLI config"), and
ValidateThinkingLevel dropped --effort before launch.

Attach the documented low/medium/high catalog to grok-4.6 as well as
grok-4.5, default the offline fallback to grok-4.6, and add grok-4.6 to
the pricing tables at the published $2 / $6 short-context tier.

xhigh stays off the grok enum: Multica injects grok agent --effort, which
is still the grok-4.5 vocabulary.

Related to #7184.

* fix(agent): price grok-4.6 cached input at $0.50 and advertise xhigh

Review on #7406:

Grok 4.6's published short-context cached-input rate is $0.50/1M, not
the $0.30 copied from grok-4.5. Update both pricing tables and the
frontend 1M+1M+1M estimate from $8.3 to $8.5.

Grok 4.6 documents and accepts --effort xhigh (docs.x.ai and grok
1.0.5). Attach xhigh only to grok-4.6, let the grok server enum accept
the token, and keep ValidateThinkingLevel failing it closed for grok-4.5.
2026-08-24 13:42:30 +08:00
iiwish 668a4e06c3 MUL-6464 fix(agent): support ACP terminals for Kimi runtime (#7323)
Closes #7289
2026-08-24 13:37:18 +08:00
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