Commit Graph
266 Commits
Author SHA1 Message Date
eb2a94e860 MUL-6368: Prevent cross-agent task and workdir contamination (#7205)
* fix(daemon): isolate task identity and workdir reuse

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

* test(llm): use validated retry override in contract

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

* fix(daemon): address task isolation review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:30:19 +08:00
728ba9c58e fix(daemon): stop concurrent tasks evicting each other's OpenClaw discovery cache (#7204)
loadOpenclawDiscoveryCache rejected any entry stamped later than the reader's
clock sample, treating it as a clock jump. Callers sample time.Now() once and
pass it down, so on a daemon starting several tasks at once a peer routinely
commits a valid entry dated microseconds after the reader sampled — and the
reader, whose own store just succeeded, threw it away and re-ran discovery for
nothing. TestOpenclawDiscoveryCacheConcurrentPreparations flakes on CI for
exactly this reason; it reproduces locally in a few hundred runs.

Tolerate a bounded skew (1s) instead of any future dating at all. That is far
above the interleaving window and far below the clock jump the guard exists to
catch, and it bounds the cost of being wrong: an entry dated ahead outlives its
TTL by at most the tolerance.

TestOpenclawDiscoveryCacheFutureDatedEntry pins both edges — a concurrent
writer's entry is a hit, a jumped clock is still a miss — so the tolerance
cannot be widened into 'never expires' or narrowed back into the flake.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 14:59:40 +08:00
Bohan Jiang 4d5a679b9b fix(openclaw): unblock the OpenClaw runtime on slow hosts (MUL-6321) (#7142)
Fixes #7112.

Task preparation ran `openclaw config file` and `config get agents.list --json` with a hardcoded 5s deadline; on a 2020 Intel MacBook Pro those take 8.4-10.9s and 4.3s every time, so the OpenClaw runtime was unusable there with no way to raise the limit.

- Deadline 5s -> 30s, overridable via MULTICA_OPENCLAW_CLI_TIMEOUT / backends.openclaw.cli_timeout, clamped to [1s, 60s] so the worst case (4 serial calls) stays inside the 5-minute preparation budget.
- Config discovery cached per daemon profile (on disk, since preparation runs in a one-shot helper process), invalidated by binary identity, OpenClaw path env, active config stat, and a 60s TTL. Only complete successes are cached; any cache fault degrades to a live CLI run.
- The timeout is classified structurally into a new platform-side, non-retryable reason (runtime_cli_timeout) instead of being text-matched into agent_error.provider_network, which showed "check your network" copy and auto-retried a deterministic local failure. Copy added for web/desktop/mobile; a NormalizeDaemonReason shim upgrades reports from older daemons.
2026-08-18 18:33:23 +08:00
4e10d6805a fix(execenv): handle OpenClaw JSON errors on stdout (#7133)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 16:05:27 +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
212ecfb8dd MUL-6300: fix(daemon): let reply turns own the status arc for their own issue (#7083)
* fix(daemon): let reply turns own the status arc for their own issue (MUL-6300)

A comment-triggered run was forbidden from touching issue status: "Do NOT
change the issue status unless the comment explicitly asks for it", written
in PR #205 (2026-03-31) on the day on_comment became the default trigger. It
was aimed at conversation — questions, thanks, agent-to-agent chatter should
not churn the board — but it also covered the most common real trigger, a
comment asking for rework on a delivered issue. The agent reworked the issue
with its status frozen, and a human had to move the card by hand (MUL-5165).

Reply mode now owns the same arc Ownership mode does — in_progress when the
work starts, the status the work is actually in when the turn completes — but
only for turns that carry work on an issue assigned to this agent. The two
properties the old prohibition really protected are kept and pinned by tests:

- a purely conversational turn never writes status;
- a run that is a guest on someone else's issue (@mention pull-in) never
  writes status, so an agent pulled in to answer a question cannot push
  another assignee's in-flight issue to in_review.

Squad leaders keep routing on the Squad Operating Protocol's "Own the parent
issue status" grant, which the server injects only when the issue is assigned
to that squad — so the guest-leader path (MUL-3724) stays closed, and the
grant no longer has to be phrased as an exception to a prohibition.

Also refreshes the two built-in skills that documented the old rule.

Supersedes #5781, whose one-line exception was written against brief text that
MUL-5377 has since rewritten.

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

* fix(daemon): compress reply-mode status rule, cover unassigned issues (MUL-6300)

Review round on #7083. The ordinary reply-mode status bullet drops from
103 to 70 words and the squad-leader bullet from 110 to 92, with the
conversational-turn sentence now identical in both branches.

The no-write invariant broadens from 'assigned to someone else via
@mention' to 'not assigned to you': an @mention can also land an agent
on an UNASSIGNED issue (triage), which the old blanket prohibition
covered but the first draft of the positive rule left undefined.

Guard-test pins and the fallback pin in reply_instructions_test.go are
synced to the new wording; the arc-trigger substring is unchanged so its
two pins survive as-is. SKILL.md carries the same broadened phrasing.

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

* docs(daemon): purge stale no-status-unless-asked semantics from ledger surfaces (MUL-6300)

Elon's review round on #7083: four surfaces still explained current
behavior with the pre-MUL-6300 rule. The squads source map (agent-
readable) now says the status-ownership grant, not the system comment's
ask, authorizes the leader's wrap-up write; prompt.go's turn-mode
comment, squad_briefing.go's grant rationale (which also still pointed
at the long-gone writeWorkflowComment), and the child-done stage test
comment now describe the conditional arc. Past-tense history quotes
(contract-test design note, MUL-6300 replacement note) stay verbatim.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 02:38:18 +08:00
8b1acfd19e fix(execenv): fold redundant metadata-list read into the issue-get step (#7016) (#7042)
Issue-triggered runs paid two reads for the same data: the runtime brief
mandated 'issue get' (whose JSON always carries the metadata bag) and then
a separate 'issue metadata list'. Fold the read guidance into step 1,
renumber shared steps 1-6 to 1-5, and update the brief tests to pin the
new wording and ban the retired standalone read step.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 00:16:41 +08:00
Xichang(Seacen) Zhao cc2aea3878 MUL-6104: fix(daemon): tell the brief's reader not to merge stderr into parsed output (#6870)
Both brief builders now state that `--output json` writes JSON to stdout while confirmations and warnings go to stderr, and that merging them (`2>&1`) into anything that parses the output makes a successful write look failed and invites a duplicate retry.

Adds a regression test anchored on the rule's wording — premise, prohibition and consequence — asserted exactly once per rendered brief, so a reversed or duplicated rule fails the test.

234 bytes per rendered brief; a run renders one of the two builders.
2026-08-14 18:39:13 +08:00
19155e41f9 refactor(prompts): compress duplicate --no-start guidance from #6956 (MUL-6168) (#6971)
PR #6956 stated the same rule — use --no-start when the write only records
ownership/status of work already underway — five times across the always-
injected brief, the sibling-runs block, and the working-on-issues skill.
State it once per surface and keep the flag in the command signatures.
Prompt-text only; no behavior change.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 17:52:04 +08:00
b5a48eca13 fix(agent tasks): prevent duplicate self-assignment work (MUL-6168) (#6956)
* fix(agent tasks): avoid duplicate self-assignment runs

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

* fix(agent tasks): address duplicate-run review

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

* fix(agent tasks): resolve sibling-run nits

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 13:57:09 +08:00
ceb6c6845c MUL-6132 fix: stop stranding daemon task markers in user directories (#6917)
* fix(daemon): roll back local_directory sidecars when prepare fails (MUL-6132)

Prepare writes the daemon task marker and the rest of the sidecar tree into
the workdir early, but only persists the manifest that records them at the
very end. Every error return in between left that tree on disk with no record
of what to remove, and since the caller gets no Environment on those paths,
none of the teardown defers that normally undo it were ever registered.

For a local_directory task the workdir is the user's own repository, so the
leftover marker disabled every human-local multica command for that whole
directory tree — silently, and until someone deleted the file by hand. No
crash was needed to reach this state: a failed provider setup was enough, and
writeSidecarManifest failing was only a warning, so a disk or permission
hiccup produced the same permanent residue.

Roll back in place from the in-memory manifest whenever Prepare fails after
writing context files, and treat a manifest write failure as fatal in place so
that rollback runs while the record still exists. Worktree and cloud envs are
untouched: the former discards the whole worktree, the latter is wiped by GC.

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

* fix(cli): retire stale daemon task markers instead of refusing forever (MUL-6132)

A daemon task marker left behind in a user's own directory made every
human-local command (daemon start/stop/restart/logs, login, logout, setup,
workspace switch, runtime profile set-path/unset-path) fail anywhere in that
directory tree, permanently and with a message that named neither the file nor
a way out. The reported instance sat undetected for five weeks.

Retire the marker automatically, but only on positive evidence that nothing is
running: every profile's daemon must answer and report zero active tasks.
Silence is not absence — an unreachable daemon is what a crashed one looks
like, and a task orphaned by that crash keeps running with this marker as its
only remaining guard, so any unreachable or unparseable profile keeps the
command refused. A minimum marker age closes the window where a daemon that
reports zero is claiming a task and about to write this very marker.

Two things stay out of scope by construction: markers without an agent and
issue id, which is the permanent node-wide guard at the workspaces root rather
than a task leftover, and any invocation carrying MULTICA_* task identity,
where the daemon really did launch this process and no file may override that.

When staleness cannot be proven the command still fails closed, but the error
now names the marker path, matching what newAPIClient has reported since
MUL-3922 and replacing a message that sent the user to the source.

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

* fix(daemon): arm the in-place rollback before the first sidecar write (MUL-6132)

The rollback was registered after writeContextFiles returned, but that function
writes the daemon task marker as its very first act and can then fail on any
later step — .agent_context, skill files, project resources. Those failures
returned before the defer existed, so the case the fix was written for still
stranded a marker in the user's repository with nothing beside it.

Arm the defer immediately after the manifest is allocated, before any write.
Rolling back an empty manifest is a no-op, which is what makes arming it that
early safe.

Adds a regression test whose induced failure lands inside writeContextFiles
(a plain file where .agent_context must be a directory), one step after the
marker is written. It fails on the previous commit and passes here.

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

* fix(cli): drop CLI-side marker deletion, keep the actionable message (MUL-6132)

The self-heal could delete the fail-closed guard of a running task. The daemon
increments active_task_count only after a claim returns, so a probe can read
zero for a task that is already claimed and about to write the marker at this
very path; the marker's age says nothing about a file written after the probe.
Serial probing across profiles widened the window further. Separately, the
"every profile answered" proof did not hold: profiles were matched by health
port, and that port is a hash that can collide, so one idle daemon could be
counted as two profiles while a crashed one kept an orphan task running.

Both are properties of doing this from outside the daemon. Removing a guard
needs synchronization with the claim path that only the daemon has, so
retirement moves there and the CLI keeps the half it can do soundly.

What remains is the message, now shared by both refusal paths. Previously only
newAPIClient named the marker file, so whether a user could act on the refusal
depended on which command they happened to run first; requireHumanLocalCommand
sent them to the source instead. Both now resolve the leftover through one
helper and report it identically.

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

* fix(cli): report leftover markers on every refusal path, never the root marker (MUL-6132)

Two gaps from the previous round's review.

requireTaskLocalConfigRoot is the third path a leftover marker can refuse
through — config show, config set and auth status all call it — and it still
returned a message that named no file. Whether the error was actionable
depended on which command the user happened to run first, which is exactly what
unifying the other two paths was meant to end. It now appends the same suffix
when a marker is the only daemon signal.

The leftover helper also could not tell the two markers apart, because
daemonTaskContextMarkerPath matches on managed_by alone. Running from the
daemon workspaces root therefore described the permanent node-wide guard as a
leftover to delete — advice that closes the hole it exists to cover, aimed at
whoever reads the error, including an agent subprocess that lost its
environment. The helper now requires task identity, defined as agent_id OR
issue_id: a chat task has no issue, so requiring both would misfile its marker
as the root marker, while the root marker has neither field.

chdirWithDaemonTaskMarker was writing the bare managed_by form, which under
this rule models the root marker rather than the workdir marker its tests are
about; it now writes a task-scoped one.

Also corrects two comments the earlier commits left stale: cmd_agent.go still
described an automatic retirement that no longer exists, and
writeSidecarManifest still claimed every caller treats a write failure as
non-fatal, which stopped being true when the in-place path was made fatal.

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

* docs(cli): correct the refusal-path count in leftover-marker comments (MUL-6132)

Both comments still said "both refusal paths" from when the suffix was shared by
newAPIClient and requireHumanLocalCommand only. requireTaskLocalConfigRoot
joined them in e02a393, so the count is three; list them by name so the next
addition has somewhere obvious to register.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 02:23:44 +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
85045228a1 MUL-6102: fix(daemon): surface which HERMES_HOME a Hermes task actually read (#6892)
* fix(daemon): surface which HERMES_HOME a Hermes task actually read

A Hermes task runs against a per-task HERMES_HOME overlay, seeded from a
source home the daemon resolves from its own process environment. When
that resolution lands on a home holding no config.yaml, the overlay is
built anyway from nothing: the derived config keeps only
skills.external_dirs, so the user's provider and credentials are gone and
Hermes refuses every session/new with "No LLM provider configured".

Nothing on that path said so. The overlay was seeded silently, no line
logged the resolved source home, and the failure surfaced Hermes' own
remedy -- run `hermes model` -- which edits whichever home the user's
shell points at, not the one the task read. GH #6872 is what that costs:
eight documented workarounds, none of which could have worked, and a root
cause filed against an unrelated resume bug.

Three additions, no behaviour change:

- Warn when the source home is missing, or exists without a config.yaml.
  Kept a warning rather than a hard failure because config-less is a
  legitimate setup: an image that ships Hermes and injects OPENAI_API_KEY
  through the environment resolves a provider fine.
- Log the resolved source home once per task, at Info.
- Append the overlay and source homes to a "no LLM provider configured"
  failure, so the task itself names the directory that was read.

The annotation runs after every classifier has read the error text. It
embeds two user-chosen paths and Classify matches substrings anywhere, so
annotating earlier would let a directory name pick the failure_reason --
a home under /srv/token-cache/limit/ satisfies rule 1 (context overflow),
which is evaluated before rule 2 (missing config) and feeds retry
eligibility and resume blacklisting.

The phrase rule 2 already matched is now a shared const so the new
predicate and the classifier cannot drift apart.

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

* fix(daemon): keep the Hermes hint out of resume-guard input

Review caught that the annotation did not stop at the daemon. It travels
to the server as the task's error, is persisted in
agent_task_queue.error, and is re-scanned there for the life of the row
by service.ResumeUnsafeFailure and by the ILIKE/regex guards in
GetLastTaskSession / GetLastChatTaskSession.

Both interpolated paths are user-controlled -- HERMES_HOME comes from the
agent's custom_env, the overlay root from MULTICA_WORKSPACES_ROOT -- so a
legal directory name got a vote on session recovery. Measured, with a
source home under /srv/400-invalid_request_error/:

  raw error                  ResumeUnsafeFailure = false
  annotated with the paths   ResumeUnsafeFailure = true
  annotated with this hint   ResumeUnsafeFailure = false

That true is a healthy chat session pointer being cleared on a failure
that has nothing to do with it, so "no resume semantics change" was not
true as written.

The hint is now fixed prose with nothing interpolated, and names the
daemon log line that does carry the paths. This removes the vector rather
than containing it: no wire or schema change, and it is correct against
old servers too, so there is no mixed-version window where an un-upgraded
server misreads a new daemon's text.

Tests cover the boundary the previous ones missed: classification AND
service.ResumeUnsafeFailure agree with the unannotated text across five
real failure shapes, plus a check against the SQL guard phrases, which no
unit test can execute.

Also softens the missing-config warning, which asserted the task runs
without credentials when the environment may well supply them -- the
config-less-but-working setup this PR deliberately keeps supported.

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:05:06 +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
8d292ef7ce MUL-5963: feat(server): let web chat read back history via multica chat history (#6680)
* feat(server): let web chat read back history via `multica chat history`

When an agent's provider session resume fails on a web chat / Feishu task, the
daemon injected SessionContinuityNoticeUnrecoverable, making the agent reply
"this is a new session, no memory". But the conversation was never lost — it is
persisted in chat_message, and the agent just had no command to read it back
(only Slack had a history reader).

Make `multica chat history` serve the stored chat_message transcript for any
non-Slack session (web chat, Feishu), reusing the existing task-token
authorization and the shared response shape. Route web chat / Feishu continuity
notices to a new variant that tells the agent to read the transcript back
instead of scripting a "new session" disclosure, and teach the Feishu channel
prompt the read-back command.

- handler/chat_history.go: GetChatChannelHistory falls back to chatMessageHistory
  (ListChatMessages, oldest-first, default recent 30) on ErrNoSlackSession.
- execenv: add SessionContinuityNoticeChatTranscript; keep Unrecoverable for
  channels Multica stores no transcript for (e.g. WeCom).
- prompt.go: route web chat / Feishu to the transcript notice; Feishu channel
  awareness teaches `multica chat history`.
- tests: update surface/notice matrix, Feishu prompt policy, add handler
  transcript fallback tests.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test(server): fix transcript read-back test to insert a real assistant row

The insertChatVisibilityMessage helper hardcodes role='user', so the
"assistant reply" row in TestGetChatHistory_NoSlackBindingReadsStoredTranscript
was actually stored as a user message and the handler (correctly) returned
role "user". Add an insertChatMessageRole helper and use it for the assistant
row so the role-mapping assertion is meaningful.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(server): treat WeCom as a readable transcript surface for session continuity

WeCom is fully wired on main: it persists the conversation to chat_message and
stamps ChatChannelType="wecom", so `multica chat history` reads it back just
like Feishu's. Stop routing it to the Unrecoverable fallback; give it the
transcript notice and the `multica chat history` history command, symmetric
with Feishu (no thread reader, no upload).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(server): make transcript read-back reachable and honor the paging contract

Addresses review on the web-chat history read-back feature.

- GetChatChannelHistory no longer dead-ends on "no channel integration" when
  SlackHistory is nil: a no-Slack deployment (the exact target of this feature)
  now serves the stored chat_message transcript directly, and Slack-backed
  sessions still go through the Slack reader.
- chatMessageHistory swaps ListChatMessages for ListChatMessagesPage, honoring
  the ?before cursor (encoded as created_at|id) and clamping ?limit (default 30,
  max 50), and advertises NextCursor on a full page so an agent can page back
  without re-reading the recent window. Author now uses the channel vocabulary
  (Bot / User) instead of the raw role string.
- Adds a single SurfacePersistsTranscript predicate (web chat, Feishu, WeCom,
  DingTalk) as the one source of truth for "which surfaces are readable", shared
  by the continuity-notice router, the chat-prompt history copy, and the surface
  list — closing the DingTalk gap where the notice said "read it back" but the
  prompt said "no history reader for dingtalk".

Tests: nil-reader-with-rows regression, transcript paging without duplicates or
gaps, Author/TS pins, and DingTalk cases in the surface/notice and prompt tables.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test(server): fix transcript paging test assertion

The paging test wrongly asserted messages are strictly oldest-first ACROSS
pages, but paging to older messages means each later page is older than the
previous one — the sequence is not monotonic. The handler was correct; the
assertion was wrong. Now it checks the real invariants: each page is
oldest-first, and the union of all pages is exactly the transcript with no
duplicates or gaps.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(server): stop telling transcript surfaces their history is not in Multica

The channel-awareness block opened with "This conversation and its history
live in <platform>, NOT in Multica" for every channel type, and the very
next line told a Feishu/WeCom/DingTalk agent that Multica stores the
conversation and hands it `multica chat history` to read it back. An agent
that believes the first sentence has no reason to run the command in the
second.

Only the useful half — do not go hunting through issues and comments — is
unconditional now. Where the conversation actually lives moved into each
branch: Slack keeps the original wording (true there), transcript surfaces
say the conversation happens on the platform and Multica stores a
transcript of it, and the defensive fallback keeps both claims for a
surface that really has no reader.

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

* fix(server): name the channel a served transcript came from

HistoryPage.ChannelType is documented as empty ONLY for a session bound to
no channel, but the transcript branch never set it. A WeCom/Feishu/DingTalk
session that HAD messages therefore reported channel_type "" — telling the
agent it was in a web-only chat — while the same session's empty read
already named the platform. One endpoint, two answers, decided by whether
the session happened to have messages.

Reuses the existing sessionChannelType helper, so the transcript path
inherits its refusal to guess: a binding read that fails for any reason
other than "no such row" is reported rather than silently answered as
web-only. That failure now returns the same status as the empty-read path
does for the same cause, instead of the 502 reserved for a failed channel
read.

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

---------

Co-authored-by: qiushihao279-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-12 15:36:39 +08:00
Wu Changming 84dc02cd65 MUL-6034: fix(cursor): normalize MCP approval server shapes (#6776)
Cursor normalizes an MCP server config into a fixed field set and order before
hashing it into an approval key, so equivalent configs written in a different
field order produced keys Multica never matched, leaving the server unapproved.

Serialize stdio servers as {type?, command, args?, env?, cwd?} and remote
servers as {type?, url, headers?}, dropping unknown fields, and marshal without
Go's HTML escaping so the bytes match Cursor's JSON.stringify output.

Approval keys verified against cursor-agent 2026.08.04-aaa8809 and
2026.07.20-8cc9c0b via `mcp enable`; expected hashes in the tests are captured
from a real Cursor install.

Reported and fixed by @wuchangming.
2026-08-12 14:29:12 +08:00
Xichang(Seacen) Zhao b904e6b71c MUL-6029: docs(daemon): correct two comments #6604 made false, and enforce the Slack/Lark brief policy (#6771)
Three follow-ups to #6604: two comments the merged diff made false, and one policy the tests stated but did not enforce.

- `handler.go` said the brief turns `chat_channel_delivers_files` into upload-or-describe copy. After #6604 the per-turn prompt does, and the brief states no verdict at all — it is the prompt cache prefix, and a per-turn verdict there renders two briefs (MUL-5377).
- `outbound_media.go` cited `TestDeliverAttachments_AdmissionBoundsTheLookupStage` for a sentence about turns carrying no file; that test's turns all carry one. The paragraph now states both ways admission is held without a pending slot and cites the one the test drives.
- `TestBriefSurfaceDeliveryPolicy`'s `chat_slack` and `chat_feishu` rows denied two phrases where the WeCom rows deny four, so a denial worded around "does NOT apply" passed. Both rows now carry the same four.

Review follow-up (tests only): `TestBriefChannelDeliveryCopyIsPlatformNeutral` holds the same policy structurally — with the display name substituted out, every channel-backed brief must be byte-identical, so a per-platform position fails whatever wording it uses. Slack/Lark `mustHave` reaches parity with WeCom, `mustNot` failures quote the real match instead of a section it may not be in, and the admission test asserts `pending == 0` rather than implying it.

No behaviour change: two comments and test coverage.
2026-08-12 01:26:03 +08:00
Xichang(Seacen) Zhao 90eadd940a MUL-5905: feat(wecom): send the files an agent produced into the chat (#6604)
* feat(wecom): send the files an agent produced into the chat

An agent that produced a file and answered in WeCom delivered only the
sentence. `multica attachment upload` had already run, the object was in
storage, and CompleteTask had bound the row to the assistant message — but
nothing downstream of that bind knew how to reach a WeCom conversation, so
the file stopped there. The user read "here is the forecast" and got no
forecast, with no error anywhere to explain it.

Worse when the agent said nothing at all: an empty completion with a file
bound to it returned early, so the artifact was thrown away silently.

This adds the last hop. media_upload.go carries a file up the long
connection — WeCom has no REST endpoint for this, so it goes as
aibot_upload_media_init / _chunk / _finish over the same socket, and all
three read their verdict through the request() layer #6590 generalized.
outbound_media.go is the caller: after the answer is out, it reads the bound
attachment out of object storage, decides what to call it, and pushes it in
as its own message.

Three decisions worth naming.

The answer goes first and the file never blocks it. An upload is megabytes
and round trips and it can fail; delivery runs after the words are out, on
its own goroutine and a budget of its own, and its worst outcome is one
extra line saying a file did not make it.

A lost ack and a refusal are opposite failures. A chunk whose verdict never
came is offered once more — the protocol makes re-sending idempotent, and
losing one slice of a hundred must not cost the file. A chunk the server
refused is not, because the refusal is its answer and will be its answer
again. The push of the finished media follows the same rule in reverse: a
timeout is reported as-is rather than retried, because the same media_id
sent twice puts the file on the user's screen twice with nothing to undo.

WeCom validates bytes against the msgtype, so what to call a file is a
decision. A kind past its ceiling is demoted to a plain file rather than
sent and refused — a card the user can open beats a photo the server
rejected.

WithAttachments is what turns this on, and router.go passes it only when the
deployment configured object storage. Without it the adapter behaves exactly
as it did before.

* fix(daemon): stop telling a WeCom agent its conversation is text-only

The per-turn chat prompt and the runtime brief both decided file delivery by
asking "is there a channel at all". Any non-empty ChatChannelType got the
text-only paragraph: `attachment upload` does not apply here, describe the
file in words, never write its local path. That was true of every IM channel
when it was written, and it stopped being true for WeCom, whose adapter now
reads the bound attachment out of object storage and sends it into the room.

So an agent on WeCom was instructed not to use the one command that would
have delivered its file. The capability shipped and the agent was told it
did not exist.

The delivery question is per-adapter, not "is there a channel", and
ChannelCarriesFiles is where that answer now lives. Both call sites branch
three ways instead of two.

The new copy is deliberately built from main's existing web-chat branch
rather than written fresh. Both open on the same clause — "To include a file
or image you produced in your reply, run `multica attachment upload
<local-path>`" — and diverge exactly where the platforms diverge: web chat
says the file "appears as an attachment card below it" and offers a markdown
snippet to place it inline; WeCom says Multica "sends it into the WeCom
conversation as a separate message right after your text" and that "there is
no way to place it inline". That last clause is the one worth having. An
agent told only that files work here writes "see the chart below" and then
nothing appears below, because the long connection has no msg_item and a
picture cannot be embedded in the reply. Slack and Lark keep their existing
text-only wording untouched.

Two comments are rewritten rather than carried over, because they asserted
the binary the same commit falsifies — "keys off 'is there a channel at
all'" in prompt.go, and "any non-empty channel type means the reply leaves
Multica for an external IM platform, where `attachment upload` has nothing
to bind to" in the brief. A comment that contradicts the code beneath it is
worse than none.

ChannelCarriesFiles names every channel type this package knows rather than
leaving Slack and Lark to the default, so a fourth adapter surfaces here as
a decision to make. An unrecognized type answers false: the agent is told to
describe its file in words, and the worst case is a deliverable that could
have been sent was not. False the other way would have it promise a delivery
nothing performs.

* fix(wecom): stop claiming more certainty than the system has

Three review blockers, one shape: a promise made in one place that another
place cannot keep.

Capability came from the channel type. The daemon answered "this conversation
takes files" from `chat_channel_type == "wecom"`, but the last hop only exists
where the server wired object storage — so a WeCom deployment without storage
had its agents told to send files nothing would carry, and a new daemon on an
older server inferred a capability that build has no code for. The server now
answers it per turn on the claim (`chat_channel_delivers_files`), declared by
the same `if store != nil` branch that gives the adapter the storage, and
absent/false everywhere else.

Upload limits came from the framing. 50 MB is what 100 chunks of 512 KB can
describe; WeCom's own OpenClaw plugin puts the hard file ceiling at 20 MiB, so
that is now the cap, enforced against the attachment's recorded size before any
byte is read. Concurrency was a fixed 4 regardless of size; WeCom's own SDK
steps it down — all at once up to 4 chunks, 3 up to 10, 2 past that, because
their backend answers a burst of chunks on a large upload with a system error.

Failure handling had two words for three outcomes. A send whose ack never came
was reported as a definite failure although the file may well be in the chat,
and the local failures — a full pending queue, a slot wait that expired, a
lookup that errored — were reported to nobody at all. There are now three
states: delivered says nothing, definitely_failed keeps the definite wording,
and unknown gets wording that holds either way and is never resent, since a
duplicate file cannot be taken back. The attachment lookup moved ahead of the
rationing so a shed delivery knows there was a file to apologise for, and a turn
with nothing attached still says nothing.

* fix(wecom): bound the attachment lookup, and stop calling an attempted write a failure

Two gates, not one better place for one gate. The pending counter is claimed
after the lookup so a shed can name a real file, which is also why it cannot
bound the lookup: at the point it would have to be claimed, whether the turn
carries a file is exactly what is not yet known.

admitAttachmentDelivery is claimed before the spawn and bounds the goroutines
and the queries they run. Its cap is twice the pending cap, so a backlog meets
the honest post-lookup shed first and only reaches the silent one when the
system is already well past it. A refusal there is logged and not spoken —
telling a user their file was dropped on a turn that may have carried none is
the false alarm the post-lookup gate exists to prevent.

Separately, a WebSocket write error stopped being read as proof of
non-delivery. wsSender.write already distinguished the stages for tracing; it
now carries that out in the error. Past WriteMessage the frame may be at the
peer — a half-closed connection reports "broken pipe" to the writer for bytes
the reader has — so those classify as unknown and are never resent. What fails
before the write is provably local and stays definite.

Tests drive the real path rather than the counters: one blocks the lookup,
submits past the cap, and counts the goroutines and queries that actually
happen; one hands the frame to a connection that records it and then fails.
Both fail without the change. fakeObjectStore takes a lock, because it is no
longer only ever driven inline.

* docs(wecom): cite the size limits to WeCom's docs, not a third-party constant

The upload ceilings were sourced to Tencent's OpenClaw plugin. That file
carries the right numbers but cites nothing itself, so it corroborates rather
than establishes them. WeCom documents all four on aibot_upload_media_init's
body.total_size row, and repeats them on the classic media upload API. Point
at those.

* fix(wecom): drop the two media helpers #6605 brought in ahead of this branch

This branch carried its own cleanShort filename and extension helpers because
nothing else in the package had them when it was written. #6605 has since
merged, and it brings both — so the package now declares each twice and does
not compile against current main. GitHub still reported the branch mergeable
and CI was green, because both were measured against a main that predated that
merge by six hours.

Upstream's copies are the ones to keep: its cleanMediaFilename also strips
control characters out of a name, and its mediaExtension drops a charset
parameter before matching. Neither of those was in the copies here.

* fix(daemon): keep the file-delivery verdict out of the cached brief

chat_channel_delivers_files arrives on every claim and answers a deployment
question: does this server carry a file the last hop into the room. Upgrading a
server so it starts sending the field, or turning object storage on or off,
flips it under a chat session that is already running — and the brief branched
on it, so one resumed session rendered two different briefs. The brief is the
prompt-cache prefix (MUL-5377), which makes that a miss on every turn after the
flip. The two rows in the delivery test asserting different briefs for the two
verdicts were the exposure written down: they are what one session sees in
sequence.

The brief's channel-backed chat copy now names the platform and says the answer
is stated per turn. It holds on either verdict and contradicts neither. The
verdict has a home already — the per-turn chat prompt, whose branch was correct
and is untouched — and that message is emitted on every turn, so nothing is
lost by the brief declining to answer.

Slack and Lark take the same copy. Scoping it to WeCom would put a channel-type
decision back into the brief, which is what ChannelCarriesFiles exists to
prevent, and would hardcode a denial a server-side change can falsify.

Coverage moves rather than goes. TestBriefByteIdenticalAcrossRunsForEveryKind
gains a WeCom kind and a variant that flips the field; both fail without this
change, and the existing Slack kind fails on the variant too. The delivery
test's two WeCom rows assert the inverse property now — one brief for both
verdicts — and the upload/describe split they used to hold is pinned by
TestBuildChatPromptTwoLayerChannelPolicy on the per-turn prompt, which has
covered both branches all along.

* docs(wecom): correct what the admitted cap implies, and note that delivery cannot answer back

The comment on the two counters said reaching the admitted cap means the
pending cap is full and as many turns again are still in their lookup. It does
not. Admission is held for a goroutine's whole life, including turns whose
lookup finds no file and which therefore never claim a pending slot —
TestDeliverAttachments_AdmissionBoundsTheLookupStage fills admission with
pending at zero. The two-counter split and the ordering it buys are unchanged:
a backlog of file-carrying turns still meets the honest post-lookup shed first.

The package doc gains the fact that an agent cannot learn its delivery was
refused. `multica attachment upload` returns once the object is in storage and
bound to the reply, while the send into the room runs on EventChatDone, after
the run has ended, so everything the adapter knows about a failed delivery goes
to the person in the chat and to nobody else.
2026-08-11 19:01:34 +08:00
e0d0b38153 fix(daemon): keep the runtime owner's Reasonix deny rules in a task (MUL-6021) (#6764)
* fix(daemon): keep the runtime owner's Reasonix deny rules in a task

The per-task reasonix.toml denies the `ask` tool, but Reasonix resolves
the project file over the user config, so `deny = ["ask"]` replaced the
runtime owner's own deny list instead of extending it: an owner who had
denied `bash` globally got it back inside every Multica task.

The task config is now rendered from the owner's own [permissions] table
with `ask` merged into deny. The whole table is restated, not just deny,
so the policy survives whether Reasonix layers these files per key or per
table, and the table is read from the same REASONIX_HOME the child gets
(the agent's custom_env override, else the daemon's env, else the
default).

When that config cannot be read or restated faithfully — unreadable,
malformed, or a deny list that is not an array of strings — the daemon
writes no project config at all rather than one that would drop the
owner's rules. The task then runs with `ask` available, exactly as it
already does when a repository ships its own reasonix.toml.

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

* fix(daemon): resolve the Reasonix user config the way Reasonix does

Restating the owner's permissions only helps if the daemon reads the file
the Reasonix child actually loads. The first cut assumed
$REASONIX_HOME/config.toml with a ~/.reasonix default, which misses:

  - Windows, where the home is %AppData%\reasonix (falling back to
    %USERPROFILE%\AppData\Roaming\reasonix), not %USERPROFILE%\.reasonix;
  - the legacy OS-support and XDG locations Reasonix reads when the
    primary config does not exist and REASONIX_HOME is unset;
  - an agent that sets REASONIX_HOME to empty, which sends the child to
    the platform default while the daemon kept reading its own.

Each miss reads "no owner permissions" and writes a task config holding
only deny = ["ask"] — the exact widening this change set exists to stop.

reasonix_user_config.go now ports Reasonix's own resolution (upstream
internal/config/paths.go): REASONIX_HOME with ${VAR} / ${VAR:-default}
expansion, ~ handling and absolute normalization, the per-platform home
defaults, and userConfigLoadPath's legacy fallbacks with the isolation
rule that switches them off once REASONIX_HOME is set. The lookup runs
against the sanitized custom_env layered over the daemon's environment —
the same env the child is launched with — so presence, not just value,
decides where to read.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 18:15:53 +08:00
8e96329196 refactor(daemon): drop the MULTICA_HERMES_TASK_MEMORY rollback switch (#6760)
The switch let an operator restore the pre-MUL-5932 behaviour of a fresh
task-local memories/ per task. Hermes agent memory migrated correctly, so
it no longer earns its keep — and it never covered the failure it looked
like it covered: the overlay already depends on directory links to mirror
the user's ~/.hermes, so a host that cannot create them fails at the
mirror step regardless of this env.

Flipping it was also lossy in both directions: with the switch on the
daemon stopped marking the store in-use and refreshing its mtime, so the
GC could reclaim the agent's memory after GCHermesMemoryTTL; flipping it
back discarded whatever the task-local dir had accumulated, because a
populated store is never migrated into.

The task-local path itself stays — it is still what a task with no agent
to key on, or an unresolvable Multica profile dir, gets.

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

This reverts commit 8eaa0d229f.

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 15:15:06 +08:00
a1da8e536f MUL-6000 fix(daemon): link user Codex skills into codex-home instead of copying them (#6742)
* fix(daemon): link user Codex skills into codex-home instead of copying them

Every Codex task got a full recursive copy of ~/.codex/skills/ inside its
per-task CODEX_HOME. For a user with npm-backed skills that is ~100 MB per
task directory, re-copied on every task start — hydrateCodexSkills wipes and
re-seeds on both the Prepare and the Reuse path — and never reclaimed: the GC
artifact sweep only matches node_modules / .next / .turbo, and whole-directory
reclaim needs the issue to reach a terminal status first. Total cost scales
with the number of live task directories.

Link instead. The same per-task home already links its other two shared
resources, sessions and the plugin cache, and a skill directory is read-only
input to the CLI, so the write isolation a copy incidentally bought has no
requester. EvalSymlinks still runs first, so an installer-shipped symlink
(lark-cli into ~/.agents/skills/) resolves to the real directory rather than
producing a link to a link.

The three paths that could reach through the new link are unchanged in
behavior: os.RemoveAll deletes a link and not its target, writeSkillFiles
probes with os.Lstat so a workspace skill colliding with a linked user skill
lands in a `-multica` sibling, and the GC refuses to walk into links. Tests
pin all three, plus a windows-tagged test for the junction form of the link.

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

* fix(daemon): stop GC walks from descending into Windows directory junctions

The GC's artifact sweep and its size accounting both refuse to follow links
out of a task directory, but they tested for os.ModeSymlink alone. That misses
the link shape Windows actually produces: createDirLink falls back to a
directory junction (mklink /J) whenever os.Symlink is denied, which is the
default without Developer Mode, and since Go 1.23 os.Lstat reports a junction
as ModeDir|ModeIrregular with no ModeSymlink bit while its DirEntry still
answers IsDir() == true. filepath.WalkDir therefore descends straight into the
link target, and the sweep would delete a node_modules / .next / .turbo
directory that lives inside the user's real home.

Match both mode bits in all three walks. The task directory links user content
in three places today — the Codex session store, the plugin cache, and now the
user's skills — so this is reachable on any Windows host running Codex tasks.

Windows-tagged tests create the junction with mklink /J directly, so the
junction shape is covered even on a runner where symlinks are permitted, and
they run in the existing windows-execenv CI job.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 13:51:50 +08:00
Bohan Jiang 673104d26e docs(daemon): correct the Hermes overlay's skill-less claim (#6703)
The Hermes memory docs said an agent with no skills bound gets no overlay and keeps using the host's ~/.hermes. The daemon does gate the overlay on the task carrying skills, but the server appends the platform's built-in skills to every agent (LoadAgentSkillBundles), so a claimed task's skill set is never empty and that branch is unreachable in production.

Two consequences the old wording got wrong: every Hermes agent gets the overlay, and therefore the persistent memory store from #6693, not just ones with user-bound skills; and clearing an agent's skill list is not a way to keep using the host's memories. Reported on #6638, where the same sentence was read as a contradiction.

Docs only, plus a note at the gate so the next reader does not take the skill-less branch for observable behaviour.
2026-08-10 18:49:02 +08:00
Bohan Jiang f26b97e395 MUL-5932: fix(daemon): give Hermes agents memory that survives the task (#6693)
Hermes discovers skills only from its own home, so Multica takes over HERMES_HOME to inject an agent's bound skills. The overlay inherited the decision of where memory lives and made it task-local, so a skill-bound agent forgot everything between tasks while a skill-less one shared the host's memories with every other agent on the runtime (#6638).

Link the overlay's memories/ to a store Multica owns, keyed on (hermes profile, agent), mounted by directory link with its own GC TTL and the shared active-store guard. Memory now survives across tasks and issues for one agent, never touches the user's home, and is invisible to other agents.

Session history (state.db) stays task-local, and skill-less agents are unchanged. MULTICA_HERMES_TASK_MEMORY=1 reverts; MULTICA_GC_HERMES_MEMORY_TTL bounds retention (90d default).

Migration of a pre-store memories dir copies into a staging sibling and publishes with a single atomic rename, fails closed on any I/O error or entry it will not carry, and never infers a lost race from a failure — the source directory is only removed once its contents are confirmed in the store.
2026-08-10 18:26:00 +08:00
feigandBohan-J 3dd128532c feat(agent): add oh-my-pi (omp) as a supported agent runtime (#6514)
* feat(agent): add oh-my-pi (omp) as a supported agent runtime

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

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

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

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

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

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

Closes #3989

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Bohan-J <[email protected]>
2026-08-10 16:34:07 +08:00
rongchenzheng 586a624aa5 fix(cli): isolate task profile state (#6407) 2026-08-10 12:20:17 +08:00
7c7ed8bf55 MUL-5937 fix(daemon): persist direct chat Codex sessions (#6644)
Co-authored-by: 전영진 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-09 16:40:07 +08:00
93fa120c4c MUL-5925 fix(execenv): expand Windows ~\ in the openclaw active config path (#6630) (#6643)
`openclaw config file` shortens the home prefix with its host OS's
separator, so Windows reports `~\.openclaw\openclaw.json`. The tilde
expansion matched only the `~/` form, so the Windows path fell through
unexpanded; not being absolute, it was then joined onto the daemon's
working directory into a path that can never exist.

The resulting stat miss is indistinguishable from a fresh install, so the
per-task wrapper was written without the `$include` of the user's config
and without an include root. Every task then booted OpenClaw with no
model providers and no auth profiles, failing with `Unknown model:
openai/gpt-5.5` or `No credentials found for profile ...`.

Accept both separators regardless of GOOS so the Windows shape stays
testable from the Linux/macOS test job, and log the discovery outcome:
discovery was previously silent, which is why this could only be
diagnosed by reading the generated file. A missing active config now
warns that the task will not see the user's models or auth profiles.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-09 16:00:51 +08:00
545d890dae feat(cli): add --compact to issue comment list, adopt it in every agent read command (MUL-5442) (#6546)
* feat(cli): add --compact to issue comment list, adopt it in every agent read command (MUL-5442)

The #5999 follow-up its closing note asked for, now that the workspace
measurements exist: the two bounded reads the agent workflow prescribes
are dominated by reader-noise metadata, and every byte of it enters the
conversation history at full price once and is replayed at cache-read
price by every later call of the session.

--compact (JSON output only, opt-in) drops per comment: the issue_id
echoed from the request path, source_task_id, updated_at when identical
to created_at, null-valued fields, and empty arrays. Content, identity,
and thread-summary fields pass through untouched; a real edit timestamp
or non-empty array is never dropped.

Measured on MUL-5442 (production data): --roots-only --summary scan
6,117B -> 4,738B (-23%); --thread --tail 30 deep read 47,359B ->
39,349B (-17%).

Adoption: all nine runnable comment-list command templates in the
per-turn channel (prompt.go, reply_instructions.go) now carry --compact,
and the brief's Available Commands line and workflow step 3 document it
(+~40B one-time, byte-stable). Command pins updated accordingly; new
unit test pins the drop/keep contract and the help-contract test pins
the flag's rendered usage.

Known local-env failure: TestResolveWorkspaceID_AgentContextSkipsConfig
fails identically on clean origin/main in this agent runtime (agent
context env interference) — unrelated.

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

* test(cli): wire-level --compact coverage and zero-value keep pins (MUL-5442)

Review nits on #6546: TestRunIssueCommentListCompactWiring drives
runIssueCommentList against an httptest server both ways — default JSON
keeps every field, --compact drops the reader-noise set while zero-value
scalars (reply_count: 0, content_truncated: false, folded_count: 0)
survive, pinning that null pruning never generalizes to zero pruning.
Same zero-value pins added to the pure-function test.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 12:41:21 +08:00
2f5efa34ca test(daemon): add fan-out guard anchors for --content-file and inline --content (MUL-5825) (#6544)
Elon's #6517 review nit: the block-level banned list omitted the old
cookbook's two central mechanism anchors, so a prose-only restatement of
the flag mechanics ("use --content-file / don't use inline --content")
could regrow in the fan-out block under green tests. Add both anchors,
and replace the phrasing-fragile "Do NOT write literal" anchor with the
semantic backtick-\n-escape anchor that survives rewording.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 10:40:18 +08:00
73ab498d1d MUL-5811: fix(squad) derive leader role from wire fields, not instructions text (#6519)
* fix(squad): derive leader role from wire fields, not instructions text (MUL-5811)

The daemon decided whether a task ran in the squad-leader role by grepping
the agent's Instructions for the "## Squad Operating Protocol" heading that
the server-injected briefing starts with. Role detection therefore depended
on user-writable Markdown: any ordinary agent whose own instructions happened
to contain that heading was promoted to leader and handed the leader rules —
mandatory `multica squad activity` calls, a licence to exit silently on
no_action, and the squad-maintenance CLI section in its brief.

The correct signals were already on the wire the whole time. The claim
handler decides injection from explicit fields (`task.is_leader_task` +
`squad_id` for issue-bound runs, the quick-create `squad_id` for the picker
path) and forwards both to the daemon. Read those instead:

- `taskIsSquadLeader` returns `IsLeaderTask || SquadID != ""`; brief assembly
  now shares that helper instead of repeating the substring check.
- The claim handler clears `is_leader_task` on the response whenever its
  defensive gate withholds the briefing (NULL squad_id, squad hard-deleted,
  leader swapped after enqueue). The flag now means "briefing injected", so a
  leader task with no roster and no protocol degrades to an ordinary agent
  turn instead of booting into a leader role it cannot perform.

Every legal path is byte-identical: the server sets these fields on exactly
the claims it injects into, so real leader runs and ordinary runs produce the
same brief and prompt as before. The only behavior change is the misdetection
case, which is the bug.

Also corrects two comments claiming IsSquadLeader is agent configuration
rather than a per-task role, and notes the flag contract in the squad
source map.

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

* fix(squad): gate leader-role field reads behind a server capability (MUL-5811)

Review catch (#6519): reading is_leader_task / squad_id unconditionally
breaks the new-daemon → old-server direction the repo deliberately supports
(claimTasksLegacy, MUL-4257). is_leader_task only reached the claim response
in #4951, while briefing injection predates it — so against an older server a
real issue-bound leader task arrives with the full briefing and both fields at
zero, and field-only detection would silently demote that leader to a plain
worker, dropping the entire operating protocol.

Add `leader_role_resolved`, a claim-only wire capability the server sets on
every claim response (leader or not). Its absence is what identifies a server
too old to have answered the role question:

- capability present → the explicit fields decide, and an ordinary agent whose
  instructions contain the briefing heading is no longer promoted to leader;
- capability absent → keep the legacy briefing-marker inference, which is
  exactly today's behavior against those servers. That branch goes away once a
  minimum server version is enforced.

The field is claim-only and never rendered into a prompt, so it costs no
prompt tokens and changes no brief or prompt bytes on any path.

Tests: the role table now covers both server generations, including legacy
real-leader (heading, no capability), current ordinary-agent-with-heading, and
current withheld-briefing; an end-to-end legacy prompt test pins that an old
server's leader keeps its rules; and every claim test now asserts the server
actually advertises the capability — losing it would send all daemons back to
text inference.

Also rewords two comments per review: the brief byte-stability tradeoff is
owner-accepted and recorded in MUL-5811, not an open tracking item.

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

* docs(squad): state the pre-capability server shapes precisely (MUL-5811)

Review nit: the comments said every server without leader_role_resolved
"never sent is_leader_task", which is only true before #4951. Servers between
#4951 and this change do send the flag — they just never promised it implies
an injected briefing, which is the actual reason the fallback covers them too.

Names both shapes at each site (daemon types, handler response, helper doc,
role-table doc, squad source map) so a future cleanup of the fallback reads
the version window correctly. Comments only; no behavior change.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 21:05:21 +08:00
6ca6b27ce3 refactor(daemon): converge multi-thread fan-out cookbook into Comment Formatting pointer (MUL-5825) (#6517)
The cross-thread reply fan-out block embedded a full file-operations
cookbook (body file -> --content-file -> cleanup, the inline/stdin bans,
the \n-escape rule, plus two example command pairs) that triple-wrote
the mechanism already carried by the brief's ## Comment Formatting and
the single-thread cookbook. The block now keeps only what is
multi-thread-specific — fan-out announcement + override, posting order,
per-thread --parent targets, the DISTINCT-body-file-per-thread delta,
and the squad leader's whole-block no_action scope sentence — and points
at ## Comment Formatting for the mechanism.

Dropping the embedded commands removes the only OS-dependent text, so
the windows/unix cookbook variants collapse and the block no longer
branches on runtimeGOOS; the OS split lives solely in the brief.

Measured (2-thread, 36-char-UUID fixture): ordinary 1,657B (linux) /
1,675B (windows) -> 919B both; leader 1,870B / 1,888B -> 1,132B both.
~-740-760B per coalesced multi-thread turn.

Pins updated per discipline: ordinary unconditional form and the leader
scope-ordering assertions are retained (obligation strings track the new
text, ledger comments record the mapping); retired cookbook strings get
negative guards in the block test and the assembled-prompt test; a new
non-parallel test pins OS-invariance.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 19:35:37 +08:00
d6d1996210 refactor(daemon): retire the generic no-reply rule — ordinary agents always post one comment (MUL-5442) (#6493)
* refactor(daemon): retire the generic no-reply rule — ordinary agents always post one comment (MUL-5442)

Owner decision (MUL-5442, 2026-08-06): the no-reply / silent-exit
behavior is squad-leader-only by design. Mechanically it always was —
computeCommentAgentTriggers routes agent-authored comments only via
explicit @mentions (the sole implicit wake is the squad-leader path), so
loop prevention lives in the Mentions discipline, and the generic rule
only suppressed one terminal noise comment on an already-enqueued run
while giving every ordinary agent a legal silent-exit path that risks
silently dropped results.

- prompt.go: delete the per-turn agent-trigger warning block entirely
  (−728B per agent-triggered turn; supersedes #6484's compression).
- runtime_config_sections.go: delete the Reply-mode reply-warranted
  bullet and step 5's conditional pointer; the reply bullets become
  unconditional (−472B one-time in the brief). Squad-leader no_action
  bullets and the Mentions section are untouched.
- reply_instructions.go: 'If you decide to reply, post it' → 'Post your
  reply' (−16B per Reply turn); the leader's no_action block states its
  own exception.
- Tests: retired pins are converted to negative guards with ledger
  comments (brief and per-turn both must NOT carry the retired
  apparatus); attribution, member-leak, and no-MUST-respond guards stay.

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

* fix(daemon): scope the reply imperative with the leader's no_action carve-out, close the pin-ledger gaps (MUL-5442)

Review catches by Elon on #6493, both accepted:

1. The leader's only silent path was contradicted later in the same
   prompt: the no_action rule said EXIT without commenting, then the
   cookbook and the brief's mandatory bullet said post unconditionally.
   Both now branch on squad leadership — agent configuration, never
   per-run state, so each agent's brief stays byte-identical across
   turns and issues (cache-safe): leaders get 'Unless your outcome is
   no_action, post your reply…' in the cookbook and 'Unless your outcome
   is no_action (Squad leader rule above), posting your reply as a
   comment is mandatory' in the brief; ordinary agents keep the
   unconditional wording byte-for-byte. New tests render the COMPLETE
   leader prompt and leader brief and assert the carve-out is present
   AND the unconditional capital-P imperative is absent — the scope
   relation, not two isolated Contains.

2. The retirement ledger named five brief pins but guarded only two,
   and the per-turn 'If you decide to reply' had no guard. Added the
   four missing negative guards in ordinary-agent scope (the leader's
   'DO NOT post any comment' matches none of them) plus a leader-leak
   guard on the ordinary brief.

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

* fix(daemon): scope the multi-thread fan-out under the leader's no_action carve-out (MUL-5442)

Ports the per-turn half of #6493 review rounds 2-3 into the slimmed PR
(the brief role-independence refactor moves to its own sub-issue/PR per
owner decision): BuildMultiThreadCommentReplyInstructions takes the
leader flag, and the leader lead sentence scopes the ENTIRE fan-out
block — every later obligation sits under the 'Otherwise' — with
index-order test guards. Ordinary fan-out output is byte-unchanged.

Also repairs a truncated TestFreshSessionMayHelp from the earlier
rebase conflict resolution (missing closing braces).

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

* docs: correct the taskIsSquadLeader comment — leadership is a per-task role (MUL-5442)

Review nit on #6493: the helper's comment still claimed leadership is
'agent configuration, never per-run state' — the disproven premise that
drove the earlier review rounds. State the per-task-role fact and point
at MUL-5811 for the role-independence work.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 16:12:22 +08:00
62a2d70afd refactor(daemon): stage-2 — Mentions and Comment Formatting judgment rewrite (MUL-5442) (#6453)
* refactor(daemon): rewrite Mentions and Comment Formatting to their judgment form (MUL-5442)

Stage 2, final two sections, bundled per the small-sections rule.

Mentions: the four-link side-effect table stays verbatim (platform
facts); the two H3 subsections merge into one policy paragraph keeping
every anti-loop anchor — the no-mention default with its cost mechanism,
the no-sign-off-mention ban, the end-with-no-mention rule, the three
mention-warranted cases, and the silence closer. The retired headings'
pins re-anchor to the policy phrases.

Comment Formatting: both variants keep the full operational contract
(file-first sentence verbatim, both bans with incident ids, workdir
scope, --parent continuity, cleanup, newline rule); what goes is the
mechanism narration (what the shell rewrites, how flags get swallowed,
PowerShell version/encoding detail — the consequence stays).

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

* fix(daemon): correct the PowerShell version claim, pin the mention scope qualifiers, section-scope the formatting assertions (MUL-5442)

Review catches by Elon on #6453, all three accepted:

1. The compressed Windows rationale over-generalized a version-specific
   fact: '$OutputEncoding drops non-ASCII' is true of Windows PowerShell
   5.1 (ASCII default), false of PowerShell 6+ (utf8NoBOM). Now reads
   'Windows PowerShell 5.1 ... may replace non-ASCII characters with ?';
   the Go comment documents the version split and why file-first stays
   version-agnostic (agents cannot rely on which shell services the pipe).
2. The merged Mentions paragraph was pinned only at the list head — the
   scope qualifiers ARE the anti-repeat-notify boundary: 'not yet
   involved', 'for the first time', 'explicitly asks to loop someone in',
   and the loop-cost mechanism are each pinned individually now.
3. The Comment Formatting assertions ran against the whole file, where
   '#4182' also appears in Available Commands — the HEREDOC ban could
   vanish with green tests. The assertions now slice the section (matched
   at the line-start heading, since Available Commands references the
   heading inline) and cover all seven contract elements within it.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 12:44:09 +08:00
f44df04f96 refactor(daemon): rewrite the Output section to its judgment form (MUL-5442) (#6425)
Stage 2 section 2. The delivery contract keeps every platform fact and
boundary: comment-add as the only delivery channel, the invisible
terminal, exactly one comment per run before turn exit, --attachment as
the only file path, the runtime-local-path ban with the code-location
form and the say-so-in-words fallback. What goes is derivation: the
invisible-task consequence restatement, the plans-in-your-reasoning
elaboration, the good/bad style examples, the exists-right-now clause,
and two quick-create explanation tails.

One pin re-anchor: 'Do not assume any workspace issue prefix' follows the
rewrite to 'never assume a workspace issue prefix'. Every other Output
and delivery-invariant pin passes unchanged, including the MUL-4899 trio
and the anti-dangling pointer target the Attachments section names.

Issue-kind Output: 1,493 -> 978. Brief (real-UUID fixture):
13,382 -> 12,907 (-475); quick-create variant sheds ~160 more on its own
kind.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 21:15:13 +08:00
Bohan Jiang c896f677d5 fix(agent): recover from a codex resume that overflows the reader (MUL-5722) (#6420)
Layer 1 (#6383) raised the shared scanner cap to 32 MiB, which moved the cliff
without removing it — a codex rollout is append-only, so a thread that outgrows
any fixed cap still fails its resume forever. This adds the recovery path.

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

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

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

Known gap, tracked not claimed: for chat, the claim handler reads
chat_session.session_id before the fallback query, so a daemon predating this
PR leaves that pointer naming the oversized thread. Clearing it needs the
attempted session recorded at claim time, which is a schema change.
2026-08-05 16:55:29 +08:00
86f255658f MUL-5730 feat(server): expose chat audience without bloating prompts (#6428)
Tell a chat agent whether it is answering in a shared room or a 1:1, without
splitting the cached brief prefix: the room shape rides the existing channel
binding row to the claim response, and is stated once per turn in the chat
prompt rather than in the static runtime brief.

Originally contributed as #6390; re-rolled to move the audience fact to the
per-turn prompt and to minimize the copy it renders.

Co-authored-by: Seacen Zhao <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 16:12:17 +08:00
2bb13667c6 refactor(daemon): cross-channel command dedup — brief states the loop, per-turn carries the commands (MUL-5442) (#6421)
* refactor(daemon): move the workflow's command templates to the per-turn channel (MUL-5442)

Cross-channel dedup, brief side. MUL-5721 measured that every issue-turn
variant of the per-turn message already carries the ready-to-run commands
with real ids (issue-get line, reading hints, reply cookbook), fresher
than the brief's static copies. The six workflow steps now state the loop
shape — command names and flag mnemonics only; full templates and every
embedded issue UUID leave the brief. The Ownership status commands and the
squad-activity call switch to <issue-id> placeholder form.

Doctrine pins stay on the brief side (mandatory catch-up, scan-first
order, both motivation anchors); command-template pins re-anchor to names
and mnemonics. Two design-consequence test updates: the static-catch-up
assertion repoints at the doctrine (the full command now lives in every
per-turn variant), and the byte-identity non-vacuity guard varies agent
identity instead of issue id — because the brief is now deliberately
issue-id-independent, which opens the cross-issue prefix-cache door.

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

* refactor(daemon): point the reply cookbook at Comment Formatting instead of restating it (MUL-5442)

Cross-channel dedup, per-turn side. The cookbook's prose restated the
brief's Comment Formatting rules (file-first rationale, inline-content and
HEREDOC hazards, the full Windows $OutputEncoding mechanics) around the
command that already demonstrates the shape. It now keeps the file-first
order, the ready-to-run command, the literal-newline rule, and the
pointer; the hazard mechanics live once, in the brief section the pointer
names. Default cookbook: 639 -> 494 bytes (-145 per reply turn,
uncacheable channel); the Windows variant sheds ~200 more.

Pins re-anchor from the deleted prose to the surviving anchors (file-first
order, the pointer, the command form); the MUL-2904/#4182 banned-shape
negative guards are untouched.

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

* fix(daemon): narrow the per-turn coverage claim, test the cross-issue invariant, strip the command shape from the guest-visible prohibition (MUL-5442)

Review catches by Elon on #6421 plus the CI failure, all three related:

1. The steps header overstated what the per-turn message carries — it
   ships the issue id and ready-to-run context-read commands; other calls
   are assembled from Available Commands. Reworded to say exactly that (a
   factual claim in a prompt cannot rely on the reader discovering it is
   wrong).
2. The cross-issue byte-equality this PR claims as a design benefit is now
   asserted directly per provider (same stable inputs, different 36-char
   issue UUIDs, byte-identical brief) — a Contains-based negative cannot
   catch truncated/transformed/conditional id use.
3. CI: the squad guest-leader contract test bans any runnable
   'issue status <issue-id> in_review' shape in guest-visible text; the
   placeholder rewrite made the leader dispatch rule's NEGATIVE sentence
   match that ban. The prohibition now states itself without a command
   form ('do NOT move it to in_review or done on this turn') — negative
   sentences should not carry copy-pasteable command shapes at all.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 14:29:01 +08:00
b98f760f1f MUL-5604: 支持 Reasonix runtime (#6370)
* feat(agent): add Reasonix runtime

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

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

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 14:19:28 +08:00
c3aab05a38 refactor(daemon): rewrite Background Task Safety to its judgment form (MUL-5442) (#6381)
* refactor(daemon): rewrite Background Task Safety to its judgment form (MUL-5442)

Owner-authorized judgment rewrite per the Claude 5 context-engineering
guidance: state the platform fact and the boundaries an agent cannot
infer, drop the enforcement details a frontier model derives.

The section is now three paragraphs: (1) the fact everything derives from
— turn exit is task-terminal, no wakeup exists, never background-and-yield
— with the foreground-collect, synchronous-fallback and no-standing-by
consequences stated once; (2) the external-systems/CI boundary: not
run-owned, the named --watch ban (kept because MUL-5223 proved the
principle alone did not stop CI-watching), merge-gate is not acceptance
criteria, the hand-off phrasing, and the single explicit-ask exception;
(3) the persistent-service handoff contract, review-locked verbatim.

Deliberately dropped as derivable: the run-owned work enumeration, the
tool-promise enumeration, the wait/collect split rule, the
persistent-service scope bullet, auto-merge and snapshot elaborations.

Both pin lists rewritten to the surviving semantic anchors (21 each);
retired pins are listed in the test comments with the rationale.

2,980 -> 1,931 (-1,049). Incident history stays in the Go comment where
it costs no context.

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

* fix(daemon): delete the duplicated CI exception, pin the full ban clause, sync the comment ledger (MUL-5442)

Review catches by Elon on the judgment rewrite, all three accepted:

1. The old exception bullet survived below the handoff paragraph — the CI
   exception appeared twice, the second copy scope-widened by its
   position, and every substring pin stayed green. Deleted; both BTS
   tests now count-guard exactly one 'The one exception' occurrence.
2. The compound watch/poll ban was pinned only by its first command; the
   full clause is the MUL-5223 boundary, so the pin now covers all three
   members.
3. The Go comment ledger still described the pre-rewrite bullet model
   (retired pins as kept, a forward reference that no longer exists).
   Rewritten to the three-paragraph model so the safety-decision history
   reads true.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 13:30:50 +08:00
shownandmultica-agent 918803f9a4 fix(execenv): preserve colliding QwenPaw skills (#6396)
Co-authored-by: multica-agent <[email protected]>
2026-08-05 13:24:58 +08:00
18f8f78af5 refactor(daemon): per-turn message slimming — OPT-1 UUID dedup + OPT-2 initiator prose (MUL-5721) (#6382)
* refactor(daemon): drop duplicate UUID commands from per-turn comment hints (MUL-5721)

OPT-1 from the MUL-5721 measurement: within one hint, a second full
`multica issue comment list <uuid> ...` command restated the issue UUID
(and, in the resumed hint, both anchors) for no routing value. The warm
hint's issue-wide catch-up and the cold hint's roots scan become flag
swaps on the thread command they follow; the resumed hint loses its
anchor-restating sentence (the read command carries the thread anchor,
the reply cookbook carries the trigger id as --parent).

Fixture-measured: warm hint 555->481, resumed 551->417, cold 429->396;
reply-turn totals -74/-134/-33 B. Ownership prompt and the reply
cookbook (with its concrete --parent command) are untouched.

Every dropped duplicate gets a deny assertion so it cannot silently
return; the demoted fallbacks get semantic anchors in its place.

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

* refactor(daemon): compress Task Initiator attribution prose (MUL-5721)

OPT-2: the block's 400 B attribution paragraph carried two derivable
restatements ("in a workspace many people can reach", "so this
attribution does not widen what you can read or write"). The compressed
paragraph keeps every rule: per-person privacy/access rules apply, the
initiator (not the runtime owner) is who you are answering, credentials
stay scoped to the runtime owner, and the initiator may not see
everything you can. Both MUL-2645 test-pinned phrases survive verbatim;
the fact line and heading are untouched.

Fixture-measured: block 513->370 B (member+email form). The two
previously unpinned surviving rules gain anchors ("is who you are
answering", "do not assume the initiator can see everything you can").

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

* refactor(daemon): restore initiator read/write authorization boundary (MUL-5721)

Review must-fix (#6382): the compression treated "attribution does not
widen what you can read or write" as derivable from credential scoping
and dropped it. It is an independent boundary: credential scoping names
whose credentials run, the visibility sentence constrains disclosure —
neither stops the model from treating an initiator's request as extra
authorization. Restored as a short explicit negation with a semantic
anchor.

Initiator block 370 -> 440 B (still -73 vs the original 513); typical
reply turn 2,191 B (net -147 for the PR).

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 13:02:51 +08:00
6b5d26b235 feat: add QwenPaw ACP backend support (MUL-5355) (#5986)
* feat: add QwenPaw ACP backend support

Add qwenpaw as a supported agent backend. QwenPaw runs via `qwenpaw acp`
over stdio using the ACP (Agent Client Protocol) JSON-RPC 2.0, reusing the
hermesClient transport layer.

Changes:
- New backend: pkg/agent/qwenpaw.go (ACP stdio via hermesClient)
- Register in agent.SupportedTypes, New(), launchHeaders
- Daemon config: probe qwenpaw binary, QWENPAW_ARGS env var
- Display name 'QwenPaw', inline system prompt support
- Runtime config: AGENTS.md injection, .qwenpaw/skills/ discovery
- User-level skills via QWENPAW_HOME env var
- DB migration 224: add qwenpaw to protocol_family whitelist

* fix(ci): add qwenpaw to CLI guard names and rename migration 231

- Add 'qwenpaw' to scripts/agent-cli-command-names.txt (TestAgentCLIGuardCoversDefaultCommands)
- Rename migration to 231 to avoid collision with existing migrations (TestMigrationNumericPrefixesStayUniqueAfterLegacySet)

* fix: address Bohan-J review comments on qwenpaw backend (PR #5986)

Blocking fixes:
1. config.go: Add qwenpaw CLI probe so daemon discovers the binary
2. models.go: Add qwenpaw case returning empty list (same as qwen)
3. qwenpaw.go: Fix resume path — use session/load (QwenPaw implements
   load_session, not session/resume), use resolveResumedSessionID,
   only set resumeRejected on session-not-found errors, fail the run
   on set_model failure instead of silently continuing
4. daemon.go: Add qwenpaw→"QwenPaw" to runtimeDisplayNameOverrides
5. agent.go: Update error message to list qwenpaw

Non-blocking fixes:
6. qwenpaw_test.go: Add 10 tests covering session/new, session/load,
   session/load not-found, set_model failure, ListModels, blocked args,
   protocol verification (session/load not session/resume), timeout,
   usage tracking, and backend construction
7. sidecar_manifest_test.go: Add qwenpaw to allFileBasedProviders

* fix: address Bohan-J second-round review on qwenpaw backend (PR #5986)

Blocking fixes:
1. qwenpaw.go: Send qwenpaw.coding_project_dir inside _meta, not as
   top-level parameter — ACP Pydantic model ignores extra root-level
   keys (model_config has no extra='allow'), only merges field_meta
   into kwargs
2. qwenpaw.go: Handle set_model returning result:null (not RPC error)
   — real QwenPaw server swallows exceptions and returns None, which
   serialises as JSON null; json.RawMessage('null') is non-nil so
   bytes.Equal check is needed
3. context.go: Workspace skills are at <workDir>/skills, not
   <workDir>/.qwenpaw/skills (confirmed against QwenPaw v2.0.1)
4. local_skills.go: QwenPaw resolves global root from
   QWENPAW_WORKING_DIR -> COPAW_WORKING_DIR -> ~/.copaw -> ~/.qwenpaw,
   not from QWENPAW_HOME

Test additions:
5. TestQwenpawSetModelReturnsNull — regression test for result:null
6. TestQwenpawSessionLoadTransientError — transient error does not
   set ResumeRejected=true
7. TestQwenpawSessionNewSendsCodingProjectDir — verifies _meta format
8. TestQwenpawSessionLoadSendsCodingProjectDir — same for session/load
9. TestQwenpawTimeout — deterministic sync via signal file

All 15 Qwenpaw tests pass. Verified against real qwenpaw v2.0.1 binary
(end-to-end: prompt, session ID, resume, system prompt).

* fix: rebase against upstream/main and add per-task qwenpaw workspace/agent isolation

- Rebase add-qwenpaw-backend branch on upstream/main (88 commits ahead)
- Resolve conflict in config.go: keep refactored probe() with shell
  resolution fallback, which already includes qwenpaw probe
- Add --workspace and --agent CLI args to qwenpaw acp for per-task
  skill isolation and agent identity isolation
- Mark --workspace and --agent as blocked in qwenpawBlockedArgs so
  user custom_args cannot override them
- Add deriveQwenpawAgentID() to produce deterministic agent IDs
  from task's issue ID and agent ID
- Wire QwenpawWorkspace and QwenpawAgentID through ExecOptions
- Add prepareQwenpawWorkspace() in execenv to materialize bound
  skills into a per-task workspace directory

* fix: address Bohan-J third-round review on qwenpaw backend (PR #5986)

Three blocking issues resolved:

1. Agent ID registration — remove session/set_model and --agent entirely.
   The simpler route: QwenPaw model override is declared unsupported,
   eliminating the need for agent profile registration in QwenPaw config.

2. Skill revocation — prepareQwenpawWorkspace now does os.RemoveAll
   on the skill_pool dir and manifest before rebuilding, making it
   idempotent. A->empty (revoke all), A->B (replace), and A->A
   (repeated reuse) all work correctly. Added 3 new unit tests.

3. Skill root path — changed 'skills' to 'skill_pool' in both
   prepareQwenpawWorkspace and skillsDirPath to match QwenPaw's
   store.py get_workspace_skills_dir.

Also cleaned up: removed deriveQwenpawAgentID function and its test,
removed QwenpawAgentID from ExecOptions, removed --agent from
qwenpawBlockedArgs, removed session/set_model test cases.

* fix: add missing qwenpaw probe() call in agents_probe.go

The TestDefaultAgentCommandNamesCoversAllProbes test found only 17
probe() calls in agents_probe.go but defaultAgentCommandNames has 18
entries. The probe() call for qwenpaw was missing, causing the
backend CI test failure.

Adding the probe ensures GUI-launched daemons can resolve qwenpaw
via the login shell fallback, matching all other providers.

* fix: sync models.go with upstream/main (remove qwenpaw from ListModels)

* fix: CI failures — migrate prefix 235→236 + update test

- migration prefix 235 was reused by upstream 235_chat_message_quick_actions;
  renamed our qwenpaw migration from 235 to 236
- TestQwenpawListModels called len() on Catalog struct (compile error);
  fixed to expect error for unknown provider type

* fix: bump qwenpaw migration prefix 236->241 (upstream took 236)

* fix: qwenpaw local skill root — 'skills' → 'skill_pool' (MUL-5355)

Bohan-J third-round review blocker #3: local_skills.go still scanned
<QWENPAW_HOME>/skills but the QwenPaw shared skill pool is
<QWENPAW_HOME>/skill_pool (store.py get_workspace_skills_dir).

Verified no other qwenpaw paths in the codebase assume the wrong layout.

* fix: bump qwenpaw migration prefix 241->242 (upstream took 241)

lint test fails with: migration prefix 241 is reused by
[241_comment_parent_lookup_index 241_runtime_profile_add_qwenpaw].
Upstream added 241_comment_parent_lookup_index; bump our migration.

* feat: add QwenPaw integration test and version declaration (MUL-5355)

- New: server/pkg/agent/qwenpaw_integration_test.go with three
  agentintegration build-tagged tests:
    TestQwenpawRealACPSmoke — full end-to-end ACP smoke test with
    session/new → session/prompt and session/load resume validation.
    TestQwenpawRealWorkspaceSmoke — validates skill_pool workspace
    flag handling and per-task skill isolation.
    validateQwenpawVersion — attempt version detection via qwenpaw
    --version, pip show qwenpaw, and python import.

- Document QwenPaw v2.0.1 as the supported baseline version in
  qwenpaw.go package comment, noting the contract details:
    _meta qwenpaw.coding_project_dir for Coding Mode,
    session/set_model NOT supported,
    skill_pool workspace layout.

- This test suite is gated by MULTICA_RUN_REAL_AGENT_SMOKE=1 and
  requires qwenpaw on PATH, matching the pattern used by grok,
  cursor, and traeecli integration tests.

* fix: bump qwenpaw migration prefix 242->243 (upstream took 242 for qoderclicn)

Upstream added 242_runtime_profile_add_qoderclicn in the same rebase
window, colliding with our 242_runtime_profile_add_qwenpaw. The
migration lint test TestMigrationNumericPrefixesStayUniqueAfterLegacySet
catches duplicate prefixes after the legacy range.

Also add 'qoderclicn' to our migration's CHECK constraint so it doesn't
regress the whitelist added by upstream's 242.

* temp: stub out integration test to isolate CI failure

* fix: restore upstream probeAgentCLIs() call in config.go (rebase regression)

Rebase conflict resolution accidentally reverted upstream's MUL-5439
refactor (extracting probe logic to agents_probe.go) back to the old
inline probe block. This also dropped qoderclicn detection and
duplicated the probe logic already in agents_probe.go.

Restore the single 'agents := probeAgentCLIs()' call — qwenpaw is
already probed in agents_probe.go.

* fix: bump qwenpaw migration prefix 243->251 (upstream took 243-250)

Upstream added migrations 243-250 since our last rebase. Bump to 251,
the next available prefix.

* feat: add QwenPaw integration test (agentintegration build tag)

TestQwenpawRealACPSmoke drives the real qwenpaw acp binary end-to-end:
  - session/new + session/prompt produces 'pong'
  - session/load resume with ResumeSessionID works
  - --workspace flag is forwarded correctly

Gated by MULTICA_RUN_REAL_AGENT_SMOKE=1, matching grok/cursor pattern.
Validated against QwenPaw v2.0.1.

* fix: workspace skills dir 'skills' not 'skill_pool' + add skill loading integration test

Two fixes:

1. qwenpaw_workspace.go: write skills to <workspace>/skills/ instead of
   <workspace>/skill_pool/. QwenPaw's get_workspace_skills_dir() looks
   for workspace skills at <workspace>/skills/ (store.py:65-67), not
   skill_pool (which is the shared pool at WORKING_DIR/skill_pool).
   Verified against real qwenpaw acp — skills in skill_pool/ were never
   discovered.

2. Add TestQwenpawRealWorkspaceSkill integration test that proves a
   bound skill is actually loaded and effective: writes a skill that
   overrides the agent's response, sends an unrelated prompt, and
   asserts the skill's marker text appears in the output. This
   addresses R3 review feedback: 'please also add a test that exercises
   an actually-bound skill'.

All three integration tests pass against QwenPaw v2.0.1:
  - TestQwenpawRealACPSmoke (session/new + prompt + session/load resume)
  - TestQwenpawRealWorkspaceSkill (skill discovery + effectiveness)

* feat: add ACP model discovery for qwenpaw via session/new models field

QwenPaw v2.0.1+ now includes a 'models' field (SessionModelState) in
the session/new response, added by agentscope-ai/QwenPaw#6531. This
lets ACP clients discover available models without session/set_model.

- ListModels for qwenpaw now uses discoverACPModels (same pattern as
  traecli/grok/kiro) to spin up 'qwenpaw acp', call session/new, and
  parse the models catalog from the response.
- discoverQwenpawModels mirrors discoverTraecliModels — ACP-native,
  no auth selection needed.
- Model override via session/set_model remains unsupported: it
  persists to agent.json at the agent scope (not session-scoped),
  so calling it would mutate the user's shared agent config. The
  model picker shows available models for display/selection, but
  the daemon does not send set_model.
- Updated TestQwenpawListModels to verify qwenpaw is a recognized
  type (not 'unknown agent type' error).

* fix: address Bohan-J Review 5 — ModelSelectionSupported=false, version bump to v2.1.0-beta.1, remove debug files

- ModelSelectionSupported('qwenpaw') now returns false with rationale
  (session/set_model persists to agent scope, not session scope)
- Add TestQwenpawModelSelectionUnsupported regression test
- Update version references from v2.0.1 to v2.1.0-beta.1 (includes
  agentscope-ai/QwenPaw#6531 — models field in session/new response)
- Remove check_ci.py, jobs.json, runs.json debug artifacts

* fix: bump qwenpaw migration prefix 251->253 (upstream took 251)

Upstream added 251_agent_runtime_unbind. Bump to 253, the next
available prefix after 252_agent_builder_draft.

* fix: address Bohan-J Review 6 — drop unused discovery, always attribute to unknown

- ListModels for qwenpaw returns empty catalog without spawning ACP
  subprocess (model selection is unsupported, so no consumer exists)
- Usage attribution always uses 'unknown' instead of opts.Model
  (the backend never sends opts.Model to QwenPaw)
- Add TestQwenpawUsageModelIgnored regression test
- Fix stale v2.0.1 comment in TestQwenpawListModels

* chore(agent): clean up qwenpaw model-discovery leftovers

Follow-up nits from review 7 on PR #5986:

- Drop discoverQwenpawModels: it lost its only caller when ListModels
  started returning an empty catalog for qwenpaw.
- Correct the version contract in qwenpaw.go. The execution path needs
  only the ACP surface present in v2.0.1 (current stable); the models
  field on session/new landed in v2.1.0-beta.1 but has no consumer now
  that model selection is unsupported.
- Make TestQwenpawListModels actually guard the no-subprocess promise.
  It pointed at a nonexistent path, which the old discovery helper also
  answered with an empty catalog, so it passed either way. It now uses
  an executable fake that records invocation; verified it fails if a
  discovery path is reintroduced.
- gofmt agent.go (ExecOptions alignment broke when QwenpawWorkspace was
  added) and restore the trailing newline in qwenpaw_test.go.

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

---------

Co-authored-by: niudakok <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-04 18:47:10 +08:00
1e661bb04b refactor(daemon): stage-1 judgment rewrite — mode router and small guardrail sections (MUL-5442) (#6365)
* refactor(daemon): compress the mode router and Reply-block preamble to their facts (MUL-5442)

Stage 1 batch A of the judgment rewrite. The mode-router paragraph spent
~800 bytes explaining WHY the file cannot know the turn mode; the fact an
agent needs is only WHERE the mode is named and that exactly one mode block
applies. Compressed to a single Turn-mode lead carrying every routing fact:
both mode lines, the shared-steps note, the one-block rule, and the
no-mode-line default. The Reply block's first bullet drops its derivations
(session history, confusion warnings) and keeps the id-source rule; the
reply-warranted bullet keeps both pinned anchors and every behavioural
outcome, dropping only the worked examples.

Re-pins: the 'Mode router' heading anchor becomes '**Turn mode.**'.
Co-authored-by: multica-agent <[email protected]>

* refactor(daemon): trim the mechanism prose from the small guardrail sections (MUL-5442)

Stage 1 batch B. Comment Formatting (both variants), Issue Body
Formatting, and Always Use the CLI keep every ban, every incident id, and
every pinned phrase verbatim; what goes is the mechanism narration (what
the shell rewrites, why the CLI rejects outside paths, the resource-type
enumeration). Zero test changes — every pin survives as-is.

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

* fix(daemon): restore two platform boundaries and pin the routing rules (MUL-5442)

Stage-1 review catches by Elon on #6365:

1. Two behavioural boundaries had been deleted as explanation. The
   issue-body H1 rule narrowed 'body or description' to 'body' — but
   'description' is the CLI/API field name, so the alias is a
   cross-surface mapping. And step 2 lost 'CLI failures are normal' —
   platform failure semantics (a failed metadata read must not block the
   task), not tool mechanics. The second deletion also went undisclosed
   in the PR description. Both restored; both now pinned.

2. The compressed Turn-mode lead was guarded only by its markers: every
   routing rule (shared steps, apply-exactly-one, status difference,
   no-mode-line fallback, no status change) could have been dropped with
   green tests. All five now pinned as semantic anchors, and both Reply
   outcomes (work-produced arm, silent-exit arm) pinned individually
   alongside the existing heading anchors.

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

* fix(daemon): pin the full no-mode-line mapping and gofmt the touched tests (MUL-5442)

Final-review catches on #6365: runtime_config_kind_test.go:99 was not
gofmt-clean (a scripted insertion), and the no-mode-line fallback was
pinned as two separable halves — 'No mode line' plus a 'Reply mode' that
other file content satisfies — so the text could mutate to route the
fallback at Ownership mode with green tests. The anchor is now the exact
mapping clause 'No mode line → Reply mode'; the status anchor stays.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-04 18:01:46 +08:00
84fe84cd9e fix(daemon): resolve six injected-instruction contradictions (MUL-5696 conflict audit) (#6354)
* fix(daemon): resolve six injected-instruction contradictions found by the MUL-5696 conflict audit

Cross-checking the four injection surfaces (runtime brief, per-turn
prompts, built-in skills, CLI help) against each other:

- brief taught --due-date <RFC3339> while the CLI help, the projects
  skill, and util.ParseCalendarDate all pin YYYY-MM-DD (RFC3339 passes
  only at exact UTC midnight); 3 occurrences aligned to <YYYY-MM-DD>
- autopilot per-turn prompt banned 'multica issue get' unconditionally
  while the brief's autopilot workflow allows issue commands when the
  autopilot instructions direct issue work; prompt now mirrors the brief
- quick-create per-turn prompt said 'do NOT pass --attachment' while the
  quick-create ## Output section (test-pinned) names --attachment on the
  create call as the surface's only file channel; ban is now scoped to
  URLs from user input
- assignment prompt claimed the workflow file documents pagination, but
  MUL-5442 (#6347) retreated those semantics to --help; pointer updated
- 'attachment download' help example recommended -o /tmp/images, the
  exact machine-shared path the --content-file/--attachment workdir
  guards reject (MUL-4252) and the brief's Attachments section assumes
  away; example moved inside the workdir
- multica-squads skill still listed an unbounded 'issue comment list'
  pull, contradicting the never-one-bulk-pull doctrine (same class as
  MUL-5372 / #6347); replaced with the bounded roots scan
- multica-working-on-issues pinned a different metadata write bar
  ('explicit task requirement') than the brief's two-condition bar;
  aligned to the brief

MUL-5696

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

* fix(cli): create the download output directory; pin MUL-5696 alignments with tests

Review follow-ups for PR #6354 (MUL-5696):

- 'attachment download -o' now creates the output directory, so the
  help example ('-o ./attachments') works from a clean workdir;
  regression test added and verified red without the fix
- the run-only autopilot issue-command boundary is now a single shared
  constant (execenv.AutopilotIssueCommandsGuard) emitted by both the
  brief and the per-turn prompt, so the two copies cannot drift apart
  again
- regression pins for the audit alignments: the brief --due-date
  synopsis stays calendar-day (<RFC3339> banned), the quick-create
  prompt keeps the URL-scoped --attachment boundary (blanket ban
  banned), the squads skill carries no unbounded comment read, and
  working-on-issues keeps the brief's metadata write bar

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

* test(cli): let attachment download tests run inside agent workdirs

Second-round review follow-up for PR #6354 (MUL-5696): the download
tests set only setCLITestServerEnv's non-mat_ token, so inside an agent
workdir (daemon task marker present) newAPIClient rejected them before
the download logic ran — including the new directory-creation test,
which made it a net-new in-tree failure. All three download tests now
set a task-scoped mat_ test token, the same pattern the upload tests
already use; in a standard agent workdir the full-suite failure set
drops from 94 (base) to 92, with the two pre-existing download failures
fixed and none added.

Also bans the old 'explicit task requirement' metadata phrase in the
working-on-issues skill test (review nit).

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

* refactor(daemon): single-source the autopilot guard in the brief; trim per-turn prompt additions

Follow-up to keep PR #6354 byte-neutral-or-better per surface:

- the per-turn autopilot prompt no longer restates the issue-command
  boundary; the brief's autopilot workflow section is its single
  emission point (a second hand-maintained copy is exactly how the two
  drifted before), pinned on both sides
- the assignment prompt's read-surface pointer and the quick-create
  attachment bullet are tightened

Measured against origin/main with identical contexts: per-turn prompts
assignment -26 B, autopilot -68 B, quick-create -7 B; briefs +6/+6/+3 B
(YYYY-MM-DD is 3 bytes longer than RFC3339). Net per run: assignment
-20 B, autopilot -62 B, quick-create -4 B, comment-reply +6 B.

Rebase note: the audit's metadata-bar finding is superseded by #6351,
which landed the opposite canonical direction (brief defers to the
skill); the rebase keeps main's owner-ruled skill text and drops the
now-wrong test pins.

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-04 16:33:42 +08:00
dc60429366 refactor(daemon): slim Issue Metadata to its judgment core, defer discipline to the skill (MUL-5442) (#6351)
* refactor(daemon): fold the metadata ban list into the write rule (MUL-5442)

The Issue Metadata section spent 1,024 bytes teaching a KV bag. The
What-NOT-to-pin bullet was a separate heading restating the write rule's
negative space; it folds into Write on exit as one sentence with every ban
kept explicitly (secrets/tokens/API keys, logs or comment summaries,
runtime bookkeeping, single-run details). The parenthetical examples and
connective prose go; every rule stays.

Pins updated in kind: the merged ban list is pinned as one sentence plus
the result-comment redirect, so the next compression pass cannot drop a
ban without failing CI. -202 bytes (16,099 -> 15,897 on the standard
fixture).

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

* fix(daemon): restore the runtime-bookkeeping examples that define the ban (MUL-5442)

Review catch by Elon on #6351: 'runtime bookkeeping' has no other
definition anywhere in the brief or skills, so its examples (attempts, run
timestamps, agent IDs) are the category's boundary, not connective prose —
and all three are high-probability miswrites that can look like re-readable
diagnostics or durable facts. Restored inside the merged bullet.

Also adopts the review's pin advice, which is our own anchor-pin doctrine
applied properly: the whole-sentence pin from the previous commit is
replaced with separate semantic anchors (each ban plus the bookkeeping
examples plus the result-comment redirect), so the sentence can be reworded
later without CI churn while dropping any single ban still fails.

+52 bytes; the section lands at 874 (from 1,024), the PR nets -150.

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

* refactor(daemon): defer the metadata write discipline to the working-on-issues skill (MUL-5442)

Owner decision on MUL-5442: metadata is deliberately free-form custom
key-value state — the recommended-keys block never matched the feature's
intent and is removed outright, not relocated. The full ban list defers to
the multica-working-on-issues skill, which already carried a near-complete
copy; the skill gains the two bans it lacked (secrets/tokens/API keys,
agent ids) so nothing loses its home.

The brief keeps only what the interface cannot express: the read-as-hints
stance, the will-a-future-run-re-read-it bar, and the two write-time
boundaries (never secrets, never long content). Section: 874 -> 505 bytes
(1,024 at the round's start).

Pins move with the content: the brief side slims to the surviving
semantics plus the skill pointer; the skill-side contract test gains the
relocated ban anchors so the pointer cannot dangle.

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

* fix(daemon): remove the curated key list from the skill and restore the ban categories (MUL-5442)

Round-3 review catches by Elon on #6351, both accepted:

1. The recommended-keys concept survived in the skill — which loads exactly
   when an agent is about to write metadata, so it still steered free-form
   KV into a platform vocabulary. The owner's ruling was that the concept
   should not exist, not that it should move. The key list, the
   'high-signal keys only' heading, and the pr_url-specific example are
   gone; the section now describes free-form durable custom state and a
   generic set example. A mustNotContain guard keeps the curation from
   creeping back.

2. The relocated ban list had dropped its two defining categories (runtime
   bookkeeping, other single-run details), leaving unlisted values looking
   writable. Restored with the reviewer's structure: each category names
   its examples, and the test pins every category AND every example as
   separate line-safe anchors — no item can be silently dropped again.

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

* fix(daemon): remove the last key-vocabulary residue from the property-vs-metadata note (MUL-5442)

Round-4 review catch by Elon on #6351: the property-vs-metadata bullet
still read 'free-form scratchpad for run state (pr_url, waiting_on, ...)'
— recommending the ruled-out fields AND calling metadata a home for run
state, in direct conflict with the runtime-bookkeeping ban restored two
sections above. Now reads 'free-form bag for durable custom issue state',
consistent with both the owner ruling and the ban list.

Test side per the review: the broad pr_url anchor narrows to the full
stale-warning phrase (the one sanctioned pr_url reference — a negative
compatibility note, not a write recommendation), and the curation guard
gains the removed residue phrases so neither the vocabulary nor the
run-state framing can creep back.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-04 16:09:05 +08:00
766c7a7fa2 refactor(daemon): retreat deep read semantics to --help, trim BTS prose (MUL-5442) (#6347)
* feat(cli): carry the comment-read contract in comment list --help (MUL-5442)

The --recent flag help gains the MUL-5372 saturation semantics (N caps
threads, not comments; every thread returns uncapped; small issues return
the whole history) and a pointer at the bounded alternative. The --before
help names the stderr cursor labels alongside the response header.

This is the relocation target for the brief's deep read semantics: the
contract follows the flag, and TestIssueCommentListHelpCarriesReadContract
pins it here so the brief-side pointer cannot dangle.

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

* refactor(daemon): retreat deep read semantics to --help and trim BTS prose (MUL-5442)

Unblocked by #6309: the platform no longer recommends --recent anywhere, so
the flag's deep semantics no longer need to ride in every brief.

- Available Commands comment-list entry: keep the signature, the two bounded
  read shapes, and the one-clause saturation warning; per-thread cap detail,
  folding rules, and cursor labels retreat to --help (pinned there by
  TestIssueCommentListHelpCarriesReadContract).
- Workflow step 3: drop the worked examples and the redundant trailing
  pointer; the mandatory two-step read and both motivation pins stay.
- Background Task Safety: tighten connective prose only — every pinned
  phrase and every behavioural constraint is untouched.
- multica-squads SKILL.md quick-start: replace the last remaining --recent
  recommendation on the platform with the bounded scan — same class of fix
  as #6309, found while relocating the warning.

-676 bytes on the standard fixture (16,766 -> 16,090).

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

* fix(cli,daemon): repair the help rendering, restore the BTS orphan scope, complete the squads read (MUL-5442)

Three review catches by Elon on #6347:

1. The --before usage string wrapped the cursor labels in backticks, and
   pflag's UnquoteUsage hijacked the first pair as the flag's value
   placeholder — rendered help showed '--before Next thread cursor' instead
   of '--before string' (same regression class as
   TestLoginTokenHelpOutputRendersCleanly). Labels now use double quotes,
   and the --recent help no longer suggests composing mutually exclusive
   flags ('--roots-only + --thread --tail' -> an explicit two-step read).
   TestIssueCommentListHelpCarriesReadContract now asserts on the RENDERED
   FlagUsages output, pinning '--before string' and the two-step order.

2. The compressed BTS opening said 'anything still running is orphaned',
   which swept externally-owned work (GitHub Actions) into the orphan rule
   the same section later scopes out. Restored: 'any run-owned work still
   active is orphaned', pinned.

3. The squads quick-start's roots-only scan never returns reply bodies,
   where mention triggers and failure reasons usually live. Added the
   bounded drill-down step and the sequence rationale; both reads pinned in
   TestSquadsSkillCoversLeaderRoutingContract, which also guards against a
   regression back to --recent.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-04 14:22:51 +08:00
e92d828f0c refactor(daemon): compress brief prose and demote the sub-issue playbook (MUL-5442) (#6310)
* refactor(daemon): compress brief prose and demote the sub-issue playbook (MUL-5442)

Six static edits to the issue brief, -1,249 bytes on the standard fixture
(17,977 -> 16,728). All edits are identical for every issue run; prompt-cache
byte stability (#6008) is untouched.

- BTS persistent-service bullet: keep the handoff contract (deliverable-only,
  lifecycle detached, readiness verified, best-effort survival), drop the
  operational walkthrough. Pins re-anchored to concepts (durable logs,
  recorded PID, verify readiness).
- BTS CI-ban bullet: keep the command blacklist and the complete-hand-off
  rule, drop the auto-merge/snapshot elaboration.
- Ownership mode: state the identity-forbids clause once on the header
  instead of once per status bullet.
- Delivery invariant: merge the three sub-bullets into the lead paragraph;
  also fixes the stale '(below)' — the per-surface delivery line renders
  above the invariant, not below it.
- Sub-issue Creation: demote the todo/backlog/stage playbook to the
  multica-working-on-issues skill; the brief keeps a one-line flag map plus
  the skill pointer. Skill-side anchors added to
  TestWorkingOnIssuesSkillCoversIssueLoopContracts so the pointer cannot
  dangle.
- Attachments: collapse the two-sentence CLI-fetch intro into one line.

Every pinned behavioral phrase is either carried verbatim or re-pinned to an
equivalent semantic anchor in the same assertion; no assertion is deleted
without a replacement.

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

* fix(daemon): restore the full persistent-service handoff contract (MUL-5442)

Review catch by Elon on #6310: the compressed bullet weakened the contract
two ways — the reply requirement dropped 'logs' from the URL/logs/stop
triple (durable logs alone are unobservable if the user is never told where
they are), and the general ownership/cleanup handle narrowed to a bare PID
(a supervisor/profile-managed service has no single stable PID). Both test
pin sets had been updated to the weakened phrases, which would have made the
regression look legitimate.

Keeps the compressed sentence shape; restores both halves of the contract
and pins them ("cleanup handle such as PID/profile", "URL, logs, and stop
instructions") so they cannot be compressed away again. +38 bytes; the PR
still nets -1,211 against main.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 20:05:07 +08:00
3c6176cbcd refactor(daemon): de-duplicate cross-section rules in the runtime brief (MUL-5442) (#6302)
* refactor(daemon): fold four restatements of the end-of-turn rule into one (MUL-5442)

Background Task Safety opened with four bullets that were four views of the
same rule -- do not end the turn with run-owned work outstanding: the general
ban, the "tool says it will notify you" case, the unobservable-result case,
and the "standing by" sign-off. Separating them cost bytes without adding a
distinct behaviour.

Fold them into the leading bullet. Every phrase the behaviour tests pin is
carried over verbatim, including "Do NOT end your turn while background
tasks", "Never background-and-yield", "wait for a future
notification/reminder", "running in the background so you can keep working",
"run the work synchronously instead" and "standing by".

MUL-5442

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

* refactor(daemon): demote workflow-step restatements of delivery, mention and metadata policy to pointers (MUL-5442)

The six workflow steps and the Reply mode block restated policy that already
has a dedicated section. Each restatement is a second place to edit when the
policy changes, which is how they drifted apart in the first place.

Give each rule one canonical home and leave a pointer at the call site:

- delivery ("only a comment reaches the user") -> ## Output; steps 5 and the
  Reply block point at it.
- mention discipline -> ## Mentions. The reply-time phrasing the loop-hardening
  test pins moves into that section rather than being duplicated in the Reply
  block, so the anti-loop signal is unchanged.
- metadata read/write bar -> ## Issue Metadata; steps 2 and 6 point at it
  instead of paraphrasing the bar.

Tests: the metadata scope test pinned the old pointer wording, and the mention
test's comment claimed the sign-off rule lived in the workflow steps. Both are
updated to the new placement; every behavioural phrase they guard is still
asserted, file-wide.

MUL-5442

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

* refactor(daemon): give the comment-read surface and the file-safety rules one home each (MUL-5442)

Three cross-section duplications, each resolved toward the section that owns
the rule:

- comment reads: the workflow step and the Available Commands entry both
  explained what --roots-only / --summary / --thread --tail do. The step keeps
  the two reads it mandates and its anti-stale motive; flag semantics stay in
  Available Commands, which is the single discovery point. The saturation trap
  ("caps THREADS, not comments") and the pagination cursor labels stay put --
  they are load-bearing after MUL-5372 and remain asserted.
- --content-file: the comment add entry restated the guardrail Comment
  Formatting owns; it now names the rule and points there for the rationale.
- workdir path rule (MUL-4252): issue create carried its own copy of the
  stale-file rationale; it keeps the rule and defers the why.
- inbound attachments: trimmed to the pinned rule plus a pointer.

MUL-5442

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

* refactor(daemon): merge the Agent Identity action list, gate squad maintenance, trim the attachment restatement (MUL-5442)

Three follow-ups from review, each re-examined against what the text guards
today rather than against the fact that a test pinned it.

1. Agent Identity enumeration. Instruction Precedence and workflow step 4 were
   added in the same commit (#3802) and each carried its own list of actions
   Agent Identity can forbid -- and the lists disagreed: one named status
   changes, the other named issue create/update and delegation, neither
   contained the other. Merge them into Instruction Precedence, which owns the
   rule. Step 4 keeps only what that section cannot express: a delegation-only
   role stops once its delegation is delivered.

2. Squad maintenance. `multica squad member set-role` shipped to every run,
   including every agent that leads no squad and therefore has no squad whose
   roles it could change. Gate it on IsSquadLeader -- agent configuration, not
   per-run state, so the brief stays byte-stable across runs of one session
   (MUL-5377), the same predicate the workflow already branches on.

3. Inbound attachments. The section restated Output's no-clickable-local-path
   rule verbatim. Keep the framing Output cannot express -- a downloaded
   attachment feels shared but landed in a private workdir -- and point at
   Output for the rule. The delivery test now also asserts the pointed-at rule
   is present, so the pointer cannot dangle.

Brief size, plain issue task: 19,231 -> 18,009 bytes for an ordinary agent
(-6.4%), 20,718 -> 19,759 for a squad leader (-4.6%).

MUL-5442

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 19:00:34 +08:00