Commit Graph
26 Commits
Author SHA1 Message Date
Bohan Jiang 80aa31c1ab MUL-7520 fix(agent): support OpenCode 2.x runtimes (#8596)
OpenCode 2.0 changed four things the daemon depends on. Two fail loudly —
`--dir` and `--variant` both exit 1 on an unrecognized flag, which is why every
task on a 2.x runtime died in ~130ms with empty output (GH #8586). Two are
silent: MCP config had no working channel left, and `opencode run` became a thin
client in front of a resident service, so signalling the client's process group
no longer stopped the run.

- `--dir` is dropped on 2.x; the workdir comes from cmd.Dir + PWD.
- The thinking level folds into the model string as `provider/model#variant`.
- Cancellation calls POST /api/session/<id>/interrupt in front of the existing
  SIGTERM→SIGKILL, which is the only thing that stops a 2.x run.
- Runs carrying Multica-managed MCP servers are refused with an error naming
  every source, rather than started without them: 2.x accepts MCP only through a
  file in the task workdir, where the agent's own commits would capture the
  credentials. Restoring MCP is tracked separately.

The 1.x path is unchanged, pinned by argv and MCP regression tests. Verified
against @opencode/cli 2.0.10 and opencode-ai 1.18.31 on macOS/arm64; Windows
cancellation is not yet verified end to end.
2026-09-20 17:10:32 +08:00
importcpp c4c8bab56c fix(opencode): include separate reasoning in output usage (#8431) 2026-09-15 13:59:20 +08:00
46b5d9e6d1 MUL-6658 fix(agent): own the runtime process tree on every backend (#7522) (#7531)
* fix(agent): own the runtime process tree on every backend

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-25 16:27:46 +08:00
3c3e77b00c MUL-6362: redact secrets from provider command logs (#7206)
* fix(agent): redact provider command values

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

* fix(agent): preserve safe command diagnostics

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 16:07:53 +08:00
3b08423684 MUL-6260 fix(daemon): treat custom runtime fixed_args as the argv prefix (#7052)
* fix(daemon): treat custom runtime fixed_args as the argv prefix (MUL-6260)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

Refs #6883

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

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

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

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

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

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

Refs #6883

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 16:27:24 +08:00
3c7221492b fix(agent): deliver the OpenCode prompt on stdin, not argv (MUL-5841) (#6552)
* fix(agent): deliver the OpenCode prompt on stdin, not argv (#6538)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 11:46:44 +08:00
96d567d517 fix(agent): pair failed tool results for OpenCode (MUL-5802) (#6497)
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 15:22:32 +08:00
a23b936b7b fix(agent): raise the agent stream scanner cap to 32 MiB and share it (MUL-5722) (#6383)
* fix(agent): raise the agent stream scanner cap to 32 MiB and share it (MUL-5722)

Codex serializes an entire thread into the single `thread/resume` response
line, so a long thread crossed the per-backend 10 MiB bufio.Scanner cap and
failed the resume with "bufio.Scanner: token too long". Because that
classifies as agent_error.process_failure, the session pointer survives and
every later turn resumes the same oversized thread — the task is stuck for
good. This is GH#4520 recurring: #4563 bounded the hang and the orphan
process, but the cap follow-up it promised was never done.

Raise the bound to 32 MiB and route every line-delimited agent transport
through one newAgentStreamScanner constructor. The cap was copy-pasted into
17 sites and had already drifted (pi at 32 MiB, everything else at 10 MiB),
which is how #4563's fix reached only one backend.

The three tests that deliberately overflow the scanner now size their
payloads from the constant, so raising the cap again cannot silently
downgrade them into plain oversized-line passes.

This is the containment layer only. A thread can still outgrow any fixed
cap, and the recovery path is still missing: overflow is reported as a
transport error, which blocks the thread/start fallback, and codex never
sets ResumeRejected so the daemon's fresh-session retry does not fire
either. Those remain open on MUL-5722.

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

* fix(agent): route the QwenPaw ACP stream through the shared scanner (MUL-5722)

QwenPaw (#5986) landed on main after this branch was cut, carrying its own
bufio.Scanner with the old 10 MiB cap. Merging as-is would have left one
backend still on the cliff this PR exists to remove — the same way #4563's
fix originally reached only one backend.

Rebased onto main and routed it through newAgentStreamScanner. No stray
per-backend cap remains: the only bufio.NewScanner calls left in the package
are the model-listing and codex session-file parsers, which are deliberately
out of scope.

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:09:25 +08:00
0066ab259e refactor(agent): drop unreachable inline system-prompt branches (MUL-5392) (#6050)
* refactor(agent): drop unreachable inline system-prompt branches (MUL-5392)

The daemon only populates ExecOptions.SystemPrompt for openclaw, kimi and
traecli (providerNeedsInlineSystemPrompt); every other backend receives the
runtime brief as a per-task context file in the workdir. The inline branches in
claude, codex, opencode and pi were therefore dead, and read as if they were the
live delivery path.

Probed each backend over its real launch path with a canary in the context file
and no inline delivery — claude 2.1.220 (CLAUDE.md), codex 0.144.6 via the
app-server, opencode 1.17.7, pi 0.67.2, hermes 0.18.2 via ACP — and all of them
picked the brief up from disk, so the branches were removable with no behaviour
change. An empty workdir returned no canary, confirming the probe could fail.

opencode's branch was worse than dead: `opencode run` has no --prompt flag, so
enabling inline delivery there would have made every opencode task exit 1 with a
usage dump. The DevEco backend, forked from opencode, already documents this
constraint; opencode itself never got the fix.

Regression tests pin all three arg builders against re-adding the flag, and
providerNeedsInlineSystemPrompt now documents what was verified and what is
still unprobed (grok, qoder, codebuddy).

Hermes and kiro are untouched: their exclusion is deliberate and already tested.

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

* test(agent): pin codex developerInstructions contract, drop stale pi flag doc

Review follow-up on MUL-5392.

buildPiArgs' doc comment still advertised --append-system-prompt after the
branch that emitted it was removed — exactly the stale-signal this PR set out
to delete.

The two codex sites fixed to a literal nil had no regression test, so restoring
nilIfEmpty(opts.SystemPrompt) would still have gone green. Both thread/start and
thread/resume now run with a canary SystemPrompt and assert developerInstructions
comes through as an explicit null. Mutation-checked: reverting either site fails
its test.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-28 22:02:55 +08:00
6455d390e2 fix(agent): fail opencode runs whose stream ends without a terminal signal (#5238)
* fix(agent): fail opencode runs whose stream ends without a terminal signal

* fix(agent): treat step_finish(reason "tool-calls") as non-terminal in the EOF guard

Review follow-up: step bracketing alone left a false-green window. A tool
step closes with step_finish(reason "tool-calls") before its continuation
step_start, so a stream that dies in that gap had no open step and still
reported "completed".

Parse part.reason (live-probed on opencode 1.17.16: tool loop emits
reasons "tool-calls" then "stop") and keep the run non-terminal after a
"tool-calls" finish until the next step_start. Any other reason —
including absent, for older opencode versions that predate the field —
stays terminal so healthy runs on old protocols are not mass-failed.

Tests: tool-calls finish then EOF now fails; the multi-step happy path
uses the real wire shape ending in reason "stop"; a reason-less
step_finish (legacy protocol) still completes.

* fix(agent): keep stop tool steps pending continuation

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-15 16:40:59 +08:00
a03055b07d fix(agent): terminate opencode process group before closing stdout (#4533) (#4541)
On cancellation/timeout the opencode backend closed the stdout read end
immediately, leaving the child writing into a closed pipe. Every write then
returns EPIPE and, per anomalyco/opencode#33653, can spin an orphaned process
at 100% CPU — surfacing as high idle CPU after a cancelled task or daemon
restart (MUL-3655).

Cleanup now runs opencode in its own process group and, on cancel, drives a
graceful group-wide SIGTERM → grace → SIGKILL, closing the stdout pipe only as
a last-resort unblock once the tree has been signalled (SIGKILL is uncatchable,
so no member can write again — no EPIPE window). The group signal also reaps
tool subprocesses opencode spawned instead of orphaning them. WaitDelay remains
the hard backstop.

Adds unix tests covering the graceful path and the SIGTERM-ignored → SIGKILL
escalation, asserting the whole process group is reaped and the run never
deadlocks on the scanner. Windows behaviour is unchanged (no process groups).

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-25 00:41:25 +08:00
Bohan Jiang 3708fb0f07 fix(daemon): inactivity-based agent run timeout, no wall-clock guillotine (MUL-3064)
Active long-running sessions are no longer killed by a fixed wall-clock deadline. Liveness is delegated to the idle watchdog (MULTICA_AGENT_IDLE_WATCHDOG, default 30m) with a larger in-flight-tool budget (MULTICA_AGENT_TOOL_WATCHDOG, default 2h). MULTICA_AGENT_TIMEOUT is an opt-in absolute cap (default 0 = no cap). The server-side 2.5h sweeper is unchanged as a coarse backstop.

Fixes #3745.
2026-06-05 15:06:07 +08:00
Multica Eve e2720f7d33 feat: add opencode thinking variants
Adds OpenCode model variant discovery for thinking controls, passes saved thinking_level through opencode run --variant, and hardens verbose model parsing with fallback coverage.
2026-06-02 13:15:14 +08:00
feifeigoodandClaude Opus 4.7 382cdd6a0b feat(agent): consume OpenCode mcp_config via OPENCODE_CONFIG_CONTENT (#3098)
Closes the runtime-side gap of #2106: previously `agent.mcp_config` was
honored only by Claude Code (via `--mcp-config <file>`); for OpenCode the
field was accepted by the API but silently ignored at execution time.

## Approach

OpenCode has no `--mcp-config` flag. Project the agent's `mcp_config`
into OpenCode via OPENCODE_CONFIG_CONTENT — OpenCode's general
inline-config injection environment variable, which accepts any subset
of OpenCode's config schema (model / agent / mode / plugin / mcp / …)
and merges at "local" scope after the project-config loop. MCP is the
only field this PR projects through that channel; if a future Multica
field needs the same channel it would assemble a combined config slice
before the env append.

The env-var route was deliberate. An earlier draft of this PR wrote
the translated MCP servers into <workdir>/opencode.json and removed
the file on cleanup; review (#3098) flagged that the task workdir is
reused across turns for the same (agent, issue), and any agent- or
user-written model / tools / permission settings in opencode.json
must survive across runs. OPENCODE_CONFIG_CONTENT avoids the workdir
entirely — nothing is written to disk, no cleanup is needed, and the
env entry dies with the spawned process.

OPENCODE_CONFIG_CONTENT was added to OpenCode in v1.4.10 (2025-09); the
official @opencode-ai/sdk uses the same env var to inject runtime
config, so the surface is stable. Verified empirically against
OpenCode 1.15.6 in our K8s runtime: `opencode debug config` returns
the injected mcp slice deep-merged with the user's global config,
and <workdir>/opencode.json is observably untouched.

## Translation surface

`agent.mcp_config` accepts two shapes for portability:

- Claude-style `{"mcpServers": {name: {url|command, ...}}}` is
  translated into OpenCode's native form: `type: "local"|"remote"`,
  `command` coerced to a string array, `env` renamed to `environment`.
- Native OpenCode `{"mcp": {name: ...}}` accepts the three shapes
  OpenCode's schema permits and is strict-decoded against each:
    - McpLocalConfig:  `{type:"local", command:[…], environment?, enabled?, timeout?}`
    - McpRemoteConfig: `{type:"remote", url:"…", headers?, oauth?, enabled?, timeout?}`
    - bare override:   `{enabled: bool}` (toggle a server inherited
                        from global / project config without redefining it)
  Decoding uses `json.DisallowUnknownFields` so any field outside the
  matching schema is rejected — matching OpenCode's
  `additionalProperties: false`. Without this, a malformed payload
  (e.g. `command: "node"` instead of `command: ["node"]`) would reach
  OpenCode verbatim and either silently disable the server or crash
  the CLI at startup.

Field-level checks the strict decoder doesn't catch:
  - `timeout` must be a positive integer (rejects 0, negative, fractional)
  - `oauth` must be either an object (validated against McpOAuthConfig)
    or the literal `false`; primitives and `true` are rejected as ambiguous
  - `oauth.callbackPort` must be in 1..65535 when set

## Precedence

Go's os/exec dedups `cmd.Env` by key keeping the LAST occurrence
(Go 1.9+). Appending OPENCODE_CONFIG_CONTENT after `buildEnv(b.cfg.Env)`
guarantees the daemon's value wins over any value the user happened
to put in `agent.custom_env` — which matches the intended semantics
(`mcp_config` is the authoritative daemon-managed field; `custom_env`
is the escape hatch). When that override happens we surface a warning
log so accidental clobbers are debuggable.

## Limitation (out of scope, accepted in review)

OpenCode also deep-merges its **global** config
(`~/.config/opencode/opencode.json`) into every session and exposes no
flag to disable that. Operators who want strict per-agent isolation
from the global layer can set:

```jsonc
// agent.custom_env on the platform
{ "XDG_CONFIG_HOME": "/tmp/opencode-isolated" }
```

…pointing at any directory without an `opencode/` subdir. OpenCode then
reads no global config and only honors what the daemon injects via
OPENCODE_CONFIG_CONTENT. Verified with `opencode debug config`.

## Changes

server/pkg/agent/opencode_mcp.go (new):
  - buildOpenCodeMCPConfigContent — translates raw mcp_config into the
    JSON string OpenCode accepts via OPENCODE_CONFIG_CONTENT, returns
    "" when there's nothing to inject so the caller can skip the env
    entry (avoids clobbering anything the user put in
    agent.custom_env.OPENCODE_CONFIG_CONTENT)
  - translateMCPConfigForOpenCode + helpers — Claude-style → OpenCode
    native shape
  - validateOpenCodeNativeMCPEntry + opencodeMCPLocal /
    opencodeMCPRemote / opencodeMCPEnabledOnly / opencodeMCPOAuth
    typed structs — strict-decode native-shape entries against the
    schema (DisallowUnknownFields), plus targeted post-decode
    assertions for timeout / oauth / callbackPort

server/pkg/agent/opencode.go:
  - 12 lines of env injection in Execute(), placed AFTER buildEnv so
    the daemon's value wins via os/exec dedup
  - warning log when agent.custom_env duplicates the same key
  - no on-disk state, no rollback closure, no post-run cleanup —
    OPENCODE_CONFIG_CONTENT lives only in the spawned process env

server/pkg/agent/opencode_mcp_test.go (new):
  - TestBuildOpenCodeMCPConfigContent_{Empty,Remote,Local,Native}
  - TestBuildOpenCodeMCPConfigContent_NativeAcceptsAllSchemaFields —
    covers each native variant round-tripping every optional field
    (local with env+timeout+enabled; remote with headers+oauth-object+
    timeout+enabled; remote with oauth: false; bare {enabled} override)
  - TestBuildOpenCodeMCPConfigContent_RejectsMalformedNative — 31-case
    table covering every constraint on Bohan-J's review: command must
    be a string array, environment / headers values must be strings,
    oauth must be an object or false, timeout must be a positive
    integer, additionalProperties: false (per-shape allow-list checked
    via DisallowUnknownFields)
  - TestOpencodeBackendInjectsMCPConfigViaEnv — E2E happy path; fake
    opencode binary captures $OPENCODE_CONFIG_CONTENT, asserts the
    translated mcp slice is present AND <workdir>/opencode.json was
    NOT written
  - TestOpencodeBackendOmitsMCPEnvWhenEmpty — empty mcp_config does
    NOT inject the env, preserving any value the user set in
    agent.custom_env
  - TestOpencodeBackendOverridesUserOpenCodeConfigContent — daemon
    value wins via os/exec dedup keep-last

apps/docs/content/docs/providers.{en,zh}.mdx:
  - flip OpenCode's MCP cell from ❌ to ✅
  - reword the "MCP configuration: only Claude Code actually reads it"
    section so OpenCode is included; describe each tool's mechanism
    (Claude → `--mcp-config`, OpenCode → OPENCODE_CONFIG_CONTENT)

apps/docs/content/docs/install-agent-runtime.{en,zh}.mdx:
  - update the Claude Code blurb (no longer "the only one")
  - expand the OpenCode blurb to mention mcp_config support
  - fix the now-broken /providers anchor

Refs #2106 (TS types and per-agent UI for mcp_config are separate
follow-ups, not in this PR).

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-30 18:08:21 +08:00
YOMXXX 34f16e2c7a fix(opencode): deny interactive questions in daemon mode (#2878)
* fix(opencode): deny interactive questions in daemon mode

* fix(opencode): avoid permission env ordering bypass
2026-05-20 17:17:31 +08:00
Bohan Jiangandmultica-agent 9a577f3e11 fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir (MUL-2416) (#2849)
* fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir

OpenCode resolves its project discovery root from `--dir` and `PWD`
before falling back to `process.cwd()`. The daemon set `cmd.Dir =
workDir` but never overrode the inherited `PWD`, so OpenCode walked
from the daemon's shell directory and silently bypassed the per-task
workdir — agents lost visibility into `.opencode/skills/` and
`AGENTS.md`, falling back to whatever global skills the host had
installed (MUL-2416).

- Pass `opencode run --dir <workDir>` and override `PWD=<workDir>` in
  the child env so AGENTS.md walk-up + `.opencode/skills` project
  config scan both anchor on the task workdir.
- Block `--dir` from custom args so user overrides cannot re-introduce
  the regression.
- Plumb skill `description` from DB through service / daemon /
  execenv. `writeSkillFiles` synthesizes a YAML frontmatter block
  (`name`, optional `description`) when the stored content lacks one,
  since runtimes like OpenCode silently drop SKILL.md files without a
  parseable `name`. Existing frontmatter is preserved unchanged so
  upstream-imported skills (GitHub / ClawHub / Skills.sh) keep their
  hand-shaped metadata.

Tests:
- New fake-CLI test confirms argv carries `--dir <workDir>` and the
  child sees `PWD=<workDir>`.
- New test confirms a user-supplied `--dir` in custom_args is dropped.
- New execenv tests cover synthesized frontmatter and preservation of
  pre-existing frontmatter.

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

* fix(runtimes): inject SKILL.md `name` when upstream frontmatter omits it

Skills imported with frontmatter that sets `description` but leaves `name`
implicit (relying on the directory slug, as common in GitHub/Skills.sh
imports) still hit OpenCode's "no parseable name → drop" path because the
DB Name fallback never made it into the SKILL.md body. ensureSkillFrontmatter
now scans the existing block and, when name is missing or empty, prepends
`name: <slug>` while preserving description, body, and any runtime-specific
keys verbatim.

Also tighten yamlEscapeInline to always double-quote so descriptions that
look like YAML keywords (`null`, `true`, `[foo]`, `{x: y}`, `2024-01-01`)
parse as strings rather than getting reinterpreted and rejected.

Adds regression test for the nameless-frontmatter case and updates the
existing OpenCode skill test for the always-quoted description format.

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-05-19 16:21:02 +08:00
Truffle 8b340fcf21 fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts (#1718)
* fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts

The npm-generated `opencode.cmd` shim forwards argv via Windows batch `%*`,
which silently truncates positional arguments at the first newline. The
daemon spawns OpenCode with a multi-line prompt (system prompt + user
message), so on Windows the agent only ever sees the first line and
responds generically as if it never received the user's message
(reported in #1717 with native-binary repro confirming the same prompt
arrives intact when cmd.exe is skipped).

When `runtime.GOOS == "windows"` and `exec.LookPath` returns a `.cmd`
shim, walk to the native binary that npm bundles next to the shim:

  <prefix>\opencode.cmd
  <prefix>\node_modules\opencode-ai\node_modules\opencode-windows-x64\bin\opencode.exe

If the native binary is missing (unusual install layout), keep the
original shim path so PATH lookup still wins. The resolver is a pure
function with an injectable `statFn`, so layout assertions are testable
on Linux:

- shim resolves to the bundled native binary
- missing native returns "" (caller keeps original path)
- non-cmd paths (Linux/Mac binary, opencode.exe direct, empty) skip resolution
- uppercase `.CMD` is accepted (PATHEXT entries can be either case)

Closes the user-facing failure mode without restructuring exec resolution
across the rest of the agent backends — the other shim-aware fixes can
follow the same shape if/when they land in similar repros.

* fix(agent/opencode): cover x64-baseline and arm64 npm package variants

`npm install -g opencode-ai` ships three Windows platform packages
(opencode-windows-x64, opencode-windows-x64-baseline for older CPUs
without AVX2, opencode-windows-arm64 for Surface / Copilot+ PC) and
installs whichever matches the host. The previous resolver only knew
about opencode-windows-x64, so baseline-x64 and arm64 hosts would fall
back to the .cmd shim and hit the multi-line prompt truncation again.

Iterate the three package candidates in GOARCH-preferred order. ARM64
hosts try arm64 first; everything else tries x64, then baseline, then
arm64 as a last resort. Cost is one extra statFn call per miss when
the GOARCH-preferred package isn't installed.

Surfaced by review on #1718.

* test(agent): add Windows counterpart to writeTestExecutable

writeTestExecutable in exec_fixture_unix_test.go is referenced by
claude_test.go / codex_test.go / kimi_test.go, but the //go:build unix
constraint meant `go test ./pkg/agent` failed to build on Windows.

ETXTBSY is a Linux/Unix fork-exec race; Windows doesn't have that
pathology, so a plain os.WriteFile is sufficient.

Lifted from #1719 (Codex) with attribution. Surfaced by review on #1718.
2026-04-27 12:16:56 +08:00
KaguraandClaude Opus 4.6 6d9ca9de93 fix(daemon): suppress agent terminal windows on Windows (#1474)
* fix(daemon): suppress agent terminal windows on Windows (#1471)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add hideAgentWindow to detectCLIVersion and avoid SysProcAttr overwrite

- Add missing hideAgentWindow(cmd) call in detectCLIVersion (claude.go:554)
  so --version checks don't flash console windows on Windows.
- Refactor hideAgentWindow to preserve existing SysProcAttr fields
  instead of overwriting the entire struct.

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-23 17:23:00 +08:00
Bohan Jiang 4ac43e9e49 feat(daemon): log agent invocation at info level (#1428)
Surface the actual exec path + argv for every agent backend at INFO
so operators can see the exact command without flipping to debug.
Also add the missing log line in pi.go for consistency with the
other nine backends.
2026-04-21 14:30:07 +08:00
Bohan Jiang b6d30c0e00 feat(agent): log full command line at debug level when spawning agents (#1071)
Add a debug-level log line in every agent backend (claude, codex,
opencode, openclaw, gemini, hermes) that prints the executable path
and full argument list when spawning the agent process. Helps diagnose
custom args, model overrides, and other CLI flag issues.
2026-04-15 16:21:55 +08:00
Bohan Jiang ce447c7f06 feat(agent): add custom CLI arguments support (#986)
* feat(agent): add custom CLI arguments support

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

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

Closes MUL-802

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

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

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

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

Includes unit tests for the filtering logic.

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

- Replace module-level `nextArgId` counter with `crypto.randomUUID()`
  in custom-args-tab.tsx to avoid SSR ID conflicts
- Add unit tests for custom args passthrough and blocked-arg filtering
  in both Claude and Gemini arg builders
2026-04-15 14:58:53 +08:00
977dc6479d fix(daemon): prevent task stall when agent process hangs on stdout (#947)
When an agent CLI process hangs (e.g. a tool call blocks on unreachable
I/O), the daemon's scanner blocks indefinitely on stdout, preventing the
Result from ever being sent. This causes tasks to stay in "running"
state permanently with no further events.

Three-layer fix:

1. Agent backends (claude, opencode, openclaw, gemini): add a watchdog
   goroutine that closes the stdout/stderr pipe when the context is
   cancelled, forcing the scanner to unblock. Also set cmd.WaitDelay
   so Go force-closes pipes after 10s if the process doesn't exit.

2. daemon executeAndDrain: add an independent drain timeout (backend
   timeout + 30s buffer) with context-aware select on both the message
   channel and the result channel, so the daemon never blocks forever.

3. daemon ping path: add context-aware select so pings don't deadlock
   if the agent backend stalls.

Closes #925

Co-authored-by: Devv <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 23:00:27 -07:00
Jiang Bohan fa0c0fe747 fix(usage): address review feedback — independent usage reporting + all providers
1. Separate ReportTaskUsage endpoint (POST /api/daemon/tasks/{id}/usage)
   so usage is captured independently of complete/fail — fixes usage loss
   for failed/blocked tasks.

2. Add usage tracking for all four providers:
   - Claude: already done (stream-json message.usage)
   - OpenCode: extract from step_finish.part.tokens
   - OpenClaw: extract from step_end.data token fields
   - Codex: extract from turn/completed and task_complete usage fields

3. Remove usage from CompleteTask payload — all usage goes through the
   dedicated endpoint now.
2026-04-08 13:23:54 +08:00
Quake Wang 36db325d50 feat(daemon): add opencode as supported agent provider (#341)
* feat(daemon): add opencode as supported agent provider

Add opencode backend alongside claude and codex. The backend spawns
`opencode run --format json`, parses streaming JSON events (text,
tool_use, error, step_start/finish), and supports --prompt for system
prompts. Includes CLI detection, AGENTS.md runtime config, native skill
discovery via .config/opencode/skills/, and 21 tests covering handlers,
JSON parsing, and integration-level processEvents scenarios.

* chore: add .tool-versions to gitignore
2026-04-02 17:52:07 +08:00