Files
multica/server/pkg/agent/agent.go
T
e1be1625cb MUL-5959: feat(agent): add Dim (DimCode) ACP runtime (#6675)
* feat(agent): add Dim (DimCode) ACP runtime

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

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

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

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

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

Two gated tests behind MULTICA_RUN_REAL_AGENT_SMOKE=1:

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

Verified against dimcode 0.3.2 and 0.3.8.

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

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

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

Address review feedback on the Dim ACP runtime:

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

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

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

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

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

Address all remaining blockers from the second review:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Address all 5 remaining blockers from review round 4:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Address all 4 findings from review round 5:

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

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

* chore: trigger CI

* chore: refresh PR

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

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

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

* fix: restore all remaining mcode references lost during merge

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

Verified: no file has fewer mcode references than upstream.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(migrations): renumber dim migration to 370

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 12:54:46 +08:00

485 lines
22 KiB
Go

// Package agent provides a unified interface for executing prompts via
// coding agents (Claude Code, CodeBuddy, Codex, Copilot, OpenCode, DevEco Code,
// OpenClaw, Hermes, Pi, Oh-My-Pi, Cursor, Kimi, Reasonix, Kiro, Antigravity, Qoder,
// Trae, Grok, Qwen Code, QwenPaw, MiniMax Code). It
// mirrors the happy-cli AgentBackend pattern, translated to idiomatic Go.
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
)
// Backend is the unified interface for executing prompts via coding agents.
type Backend interface {
// Execute runs a prompt and returns a Session for streaming results.
// The caller should read from Session.Messages (optional) and wait on
// Session.Result for the final outcome.
Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error)
}
// ExecOptions configures a single execution.
type ExecOptions struct {
Cwd string
Model string
// SystemPrompt carries the Multica runtime brief for the few providers
// that cannot pick it up from disk. The daemon leaves it empty for every
// other provider (see daemon.providerNeedsInlineSystemPrompt), because the
// brief is already delivered as a per-task context file in the workdir —
// CLAUDE.md, AGENTS.md, CODEBUDDY.md or QWEN.md depending on the runtime.
//
// A backend must therefore NOT assume this is populated, and adding a new
// backend that only reads SystemPrompt will silently receive nothing.
SystemPrompt string
ThreadName string
MaxTurns int
Timeout time.Duration
SemanticInactivityTimeout time.Duration
// FirstTurnNoProgressTimeout optionally overrides the Codex first-turn
// no-progress ceiling — the window a turn may stay completely silent after
// the app-server reports turn/started before the watchdog fails it. Zero
// keeps the provider default and the existing behaviour where
// SemanticInactivityTimeout can only shrink that ceiling; a positive value
// sets it explicitly, upward included. This answers a different question than
// SemanticInactivityTimeout ("did the process ever start producing?" vs "has
// a running turn gone quiet?"), so the two move independently. Currently
// honoured by the codex backend (GH #3262).
FirstTurnNoProgressTimeout time.Duration
// IdleWatchdogTimeout optionally narrows the daemon's generic no-message
// watchdog for this execution. Zero keeps the daemon-wide window, and a
// value above that window cannot extend the global safety bound. The
// daemon-wide zero still disables the watchdog entirely, and an in-flight
// tool continues to use the separate tool watchdog budget.
IdleWatchdogTimeout time.Duration
// HandshakeTimeout bounds startup RPCs for providers with a long-lived
// protocol transport. It is currently consumed by Codex app-server;
// zero uses the provider default rather than disabling the bound.
HandshakeTimeout time.Duration
ResumeSessionID string // if non-empty, resume a previous agent session
// ResumeExpected records that this task intended to continue a prior
// conversation, independent of ResumeSessionID (which a fallback retry may
// clear). When it is true but the backend ends up on a fresh thread — the
// live resume RPC was rejected, or a transport failure forced a fresh retry —
// the backend surfaces a continuity notice instead of silently
// restarting. Currently honoured by the codex backend (MUL-4424).
ResumeExpected bool
// ResumeContinuityNotice is the text to prepend to the first turn when
// ResumeExpected holds but the backend lands on a fresh thread anyway. The
// caller owns the wording because only it knows what the surface lost — an
// issue's comments and a Slack channel's history survive and can be re-read,
// a web chat's and a Feishu channel's cannot — and that difference decides
// whether the agent should tell the user anything at all (MUL-5722).
//
// Empty means say nothing, and the caller MUST leave it empty when its own
// prompt already carries the notice. That is what keeps a turn from paying
// for the same paragraph twice: the daemon injects it whenever it already
// knows the resume is gone, and the backend covers only the case the daemon
// cannot see — a live resume RPC rejected mid-run.
ResumeContinuityNotice string
// ExtraArgs is honoured only by backends that opt in by reading it; the
// rest ignore it. Deliberately not enumerated here — the previous list
// went stale as backends were added, which is how MULTICA_QWENPAW_ARGS
// shipped plumbed but dropped. Grep for ExtraArgs to see today's set.
ExtraArgs []string // daemon-wide default CLI arguments appended before CustomArgs
CustomArgs []string // per-agent CLI arguments appended after ExtraArgs
QwenpawWorkspace string // per-task QwenPaw workspace directory (passed as --workspace to qwenpaw acp); empty when not applicable
McpConfig json.RawMessage // if non-nil, MCP server config to pass via --mcp-config
// ThinkingLevel is the runtime-native reasoning/effort value (e.g.
// Claude's "low|medium|high|xhigh|max", Codex's "none|minimal|low|
// medium|high|xhigh", OpenCode's model variant names). Empty means
// "use the runtime/model default" —
// every backend that consumes this skips its --effort / reasoning_effort
// injection so the upstream CLI's own default applies. Currently honoured
// by the claude, codex, opencode, codebuddy, dsh, and grok (ACP
// `--effort` on `grok agent`) backends; other backends ignore
// the field rather than fail (so MUL-2339 can grow runtime support
// incrementally without breaking unrelated agents).
ThinkingLevel string
// ServiceTier is a runtime-native Codex execution tier (for example
// "priority", displayed as Fast). Empty means inherit local Codex config.
// Other providers ignore this field.
ServiceTier string
// OpenclawMode chooses between local (embedded) and gateway routing for
// the openclaw backend. "" or "local" keeps the historical behaviour —
// the daemon spawns `openclaw agent --local …` and the agent loop runs
// in-process on the daemon host. "gateway" instructs the daemon to drop
// the --local flag and let openclaw route the turn through a Gateway (the
// user's globally-configured one, or an endpoint pinned in the per-task
// config wrapper that the daemon writes from execenv.OpenclawGatewayPin —
// see server/internal/daemon/execenv/openclaw_config.go). Other backends
// ignore this field, mirroring ThinkingLevel's renderer-side fall-through
// pattern. See issue #3260.
OpenclawMode string
// ClaudeSettingsPath is a daemon-owned, task-local settings file passed
// through Claude Code's --settings flag. It currently carries restrictive
// runtime-skill overrides only; other providers ignore it.
ClaudeSettingsPath string
}
// runContext derives the execution context for an agent subprocess from the
// configured per-run timeout. A positive timeout imposes a hard wall-clock
// deadline; a zero (or negative) timeout imposes NO deadline, leaving liveness
// entirely to the daemon's inactivity watchdog so a session that keeps emitting
// events is never killed merely for running long (MUL-3064). The caller owns
// the returned CancelFunc and must call it to release resources.
func runContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout > 0 {
return context.WithTimeout(ctx, timeout)
}
return context.WithCancel(ctx)
}
// Session represents a running agent execution.
type Session struct {
// Messages streams events as the agent works. The channel is closed
// when the agent finishes (before Result is sent).
Messages <-chan Message
// Result receives exactly one value — the final outcome — then closes.
Result <-chan Result
}
// MessageType identifies the kind of Message.
type MessageType string
const (
MessageText MessageType = "text"
MessageThinking MessageType = "thinking"
MessageToolUse MessageType = "tool-use"
MessageToolResult MessageType = "tool-result"
MessageStatus MessageType = "status"
MessageError MessageType = "error"
MessageLog MessageType = "log"
)
// Message is a unified event emitted by an agent during execution.
type Message struct {
Type MessageType
Content string // text content (Text, Error, Log)
Tool string // tool name (ToolUse, ToolResult)
CallID string // tool call ID (ToolUse, ToolResult)
Input map[string]any // tool input (ToolUse)
Output string // tool output (ToolResult)
Status string // agent status string (Status)
Level string // log level (Log)
SessionID string // backend session id (Status), for early resume-pointer pinning
}
// TokenUsage tracks token consumption for a single model.
type TokenUsage struct {
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheWriteTokens int64
// CostUSDTicks is the provider's own statement of what this usage cost,
// in ticks of 1e-10 USD. Zero means "not reported" — only a few agents
// return it (xAI Grok Build does, via `_meta.usage.costUsdTicks`).
//
// It matters because a token-times-rate estimate cannot reproduce
// request-level pricing rules. xAI bills a request at 2x once its prompt
// reaches 200K tokens, and a usage record aggregates every model call in
// a turn — so the stored token counts cannot say which tier any single
// request hit. The provider's own figure already has that priced in.
CostUSDTicks int64
}
// CostUSDTicksPerUSD is the scale of the provider-reported cost unit: xAI
// reports whole ticks of 1e-10 USD, which keeps sub-cent turn costs exact in
// int64 all the way to the database instead of drifting through float64.
const CostUSDTicksPerUSD = 10_000_000_000
// Result is the final outcome after an agent session completes.
type Result struct {
Status string // "completed", "failed", "aborted", "timeout", "cancelled"
Output string // final user-facing output selected by the backend
Error string // error message if failed
DurationMs int64
SessionID string
Usage map[string]TokenUsage // keyed by model name
// ResumeRejected is positive evidence that this run's requested resume
// was itself refused — the transcript is gone, the session belongs to
// another provider account, OR the session still exists but its history
// can no longer be replayed to the provider (e.g. GH #5975: a stored
// image now exceeds the provider's max dimensions, so every resumed
// session/prompt is rejected before the turn runs). What unites these is
// that the resume CANNOT continue and only starting over can cure it, so
// it is what the daemon's fresh-session fallback looks for first. Note the
// last case keeps a non-empty SessionID (the id is real, only its history
// is unusable) — the daemon gates on this boolean, not an empty id.
//
// false is NOT evidence of the opposite. For a backend listed in
// ResumeRejectionUndetectable it means "could not tell"; for every other
// backend it means "checked, and this was not a rejection". The daemon
// needs the provider name to tell those apart — see
// shouldRetryWithFreshSession in internal/daemon.
//
// Backends must NOT set it for failures a new session cannot cure:
// network drops, rate limits, quota, provider 5xx, or auth errors. Those
// keep the session pointer so the platform's own retry can resume the
// truncated conversation (see retryableReasons in internal/service/task.go).
//
// The auth exclusion above stands even for the one auth error a fresh
// session DOES cure — a resumed session whose persisted provider identity
// can no longer resolve its credentials (GH #6777). An adapter cannot tell
// that apart from a genuinely bad credential by looking at the error, so
// the judgement is made once, provider-agnostically, in
// shouldRetryWithFreshSession, where "was this run a resume?" is already
// known. Do not encode it here.
ResumeRejected bool
// codexInitializeRetrySafe is provider-internal evidence that an
// initialize timeout happened before semantic activity and after the
// process tree was reaped. It is intentionally not part of the public
// result contract.
codexInitializeRetrySafe bool
// codexStartupRefreshRetrySafe is provider-internal evidence that the
// first turn produced no semantic progress because Codex could not load
// its model catalog, and that the process tree was reaped afterwards.
// Like codexInitializeRetrySafe it is not part of the public contract.
codexStartupRefreshRetrySafe bool
}
// Config configures a Backend instance.
type Config struct {
ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, reasonix, dsh, kiro-cli, agy, qodercli, qoderclicn, traecli, grok, qwen, qwenpaw, mcode, dim)
CLIVersion string // detected version paired with ExecutablePath; observation only, never used to choose behavior
Env map[string]string // extra environment variables
Logger *slog.Logger
TaskID string
RuntimeID string
DaemonVersion string
CodexVersion string
// BuiltinRuntime reports that ExecutablePath is the provider's own
// discovered binary rather than a custom runtime profile's command. A
// custom profile keeps its protocol family as the provider, so the
// provider name cannot distinguish the two: `protocol_family: hermes`
// with `command_name: jcode` arrives as "hermes" while being an
// unrelated implementation. Backends use this to scope
// compatibility exceptions that were verified against a specific
// vendor's binary; it defaults to false so an unset caller fails
// closed onto standard behavior.
BuiltinRuntime bool
// provider is the runtime/provider identity used in safe launch logs. New
// fills it from the protocol family; NewRuntime preserves the concrete
// built-in runtime identity instead (for example omp rather than pi).
provider string
// LaunchPrefix is the argv prefix that belongs to ExecutablePath itself —
// a custom runtime profile's fixed_args. It is spliced in directly after
// the executable, ahead of every argument a backend builds, because a
// wrapper's subcommand has to be consumed before the wrapped CLI's own
// flags mean anything (`ccms start q36` then `-p …`, GH #7046).
//
// Unlike ExtraArgs this is not opt-in: New filters it once and the
// Command boundary applies it to every process the package spawns, task
// launches and CLI probes alike. Backends never read it directly.
LaunchPrefix []string
}
// New creates a Backend for the given agent type.
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "reasonix", "dsh", "kiro", "antigravity", "qoder", "qoderclicn", "traecli", "grok", "qwen", "qwenpaw", "mcode".
//
// SupportedTypes is the canonical whitelist of agent types eligible to back a
// custom runtime profile. It MUST stay in lockstep with the
// runtime_profile.protocol_family CHECK constraint (migration 120, widened by
// migration 134 to add qoder, migration 136 to add traecli, migration 175 to
// add deveco, migration 179 to add grok, migration 202 to add qwen,
// migration 242 to add qoderclicn, migration 253 to add qwenpaw,
// migration 254 to add reasonix, migration 313 to add dsh, migration 327 to
// add mcode): a
// custom runtime profile may only
// be based on a backend Multica officially supports.
// qoder and qoderclicn share the same ACP backend; keeping both provider keys
// lets the daemon auto-detect and register the international and China-region
// binaries independently. traecli (Trae) has a New backend, launch
// header and provider branding but was previously missing from this whitelist,
// so the family picker rejected it (#4945). grok is the xAI Grok Build CLI
// ACP backend (`grok agent --always-approve stdio`). qwen is Qwen Code's
// native `qwen -p <prompt> --output-format stream-json` backend.
var SupportedTypes = []string{
"claude",
"codebuddy",
"codex",
"copilot",
"opencode",
"deveco",
"openclaw",
"hermes",
"pi",
"cursor",
"kimi",
"reasonix",
"dsh",
"kiro",
"antigravity",
"qoder",
"qoderclicn",
"traecli",
"grok",
"qwen",
"qwenpaw",
"mcode",
"dim",
}
// IsSupportedType reports whether agentType is in the SupportedTypes whitelist.
// Used to validate a custom runtime profile's protocol_family before it is
// persisted or registered.
func IsSupportedType(agentType string) bool {
for _, t := range SupportedTypes {
if t == agentType {
return true
}
}
return false
}
// resumeRejectionUndetectable lists the backends that cannot produce
// Result.ResumeRejected at all. They scrape SessionID out of stream output and
// have no rejection detection: no phrase match, no structured error code, no
// internal restart. copilot's own comment documents the hole (a session.error
// arriving before session.start leaves SessionID empty), and antigravity's
// conversation-id reader returns "" whenever the CLI exits before dispatching.
//
// Membership is deliberately opt-in. A backend absent from this map is treated
// as capable, so a new backend fails closed — it reports no rejection and gets
// no fallback — rather than silently inheriting a guess-based retry. Remove an
// entry as soon as its backend learns to report rejections.
var resumeRejectionUndetectable = map[string]bool{
"antigravity": true,
"copilot": true,
"cursor": true,
"deveco": true,
"opencode": true,
}
// ResumeRejectionUndetectable reports whether agentType is a backend that
// cannot tell a refused resume from any other startup failure. Callers use it
// to read a false Result.ResumeRejected correctly: "could not tell" for these,
// "checked, not a rejection" for everything else.
func ResumeRejectionUndetectable(agentType string) bool {
return resumeRejectionUndetectable[agentType]
}
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
if cfg.provider == "" {
cfg.provider = agentType
}
// Filter the launch prefix here, at the one point that knows both the
// prefix and the protocol family. Doing it per-backend would be the same
// opt-in arrangement that let ExtraArgs rot: a family that forgot the call
// would accept a fixed_args `--output-format text` and break its own
// stream-json channel.
cfg.LaunchPrefix = filterLaunchPrefix(cfg.LaunchPrefix, agentType, cfg.Logger)
switch agentType {
case "claude":
return &claudeBackend{cfg: cfg}, nil
case "codebuddy":
return &codebuddyBackend{cfg: cfg}, nil
case "codex":
return &codexBackend{cfg: cfg}, nil
case "copilot":
return &copilotBackend{cfg: cfg}, nil
case "opencode":
return &opencodeBackend{cfg: cfg}, nil
case "deveco":
return &devecoBackend{cfg: cfg}, nil
case "openclaw":
return &openclawBackend{cfg: cfg}, nil
case "hermes":
return &hermesBackend{cfg: cfg}, nil
case "pi":
return &piBackend{cfg: cfg}, nil
case "cursor":
return &cursorBackend{cfg: cfg}, nil
case "kimi":
return &kimiBackend{cfg: cfg}, nil
case "reasonix":
return &reasonixBackend{cfg: cfg}, nil
case "dsh":
return &dshBackend{cfg: cfg}, nil
case "dim":
return &dimBackend{cfg: cfg}, nil
case "kiro":
return &kiroBackend{cfg: cfg}, nil
case "antigravity":
return &antigravityBackend{cfg: cfg}, nil
case "qoder", "qoderclicn":
return &qoderBackend{cfg: cfg, defaultExecutable: qoderDefaultBinary(agentType)}, nil
case "traecli":
return &traecliBackend{cfg: cfg}, nil
case "grok":
return &grokBackend{cfg: cfg}, nil
case "qwen":
return &qwenBackend{cfg: cfg}, nil
case "qwenpaw":
return &qwenpawBackend{cfg: cfg}, nil
case "mcode":
return &mcodeBackend{cfg: cfg}, nil
default:
return nil, fmt.Errorf("unknown agent type: %q (supported: %s)", agentType, strings.Join(SupportedTypes, ", "))
}
}
// DetectVersion runs the agent CLI with --version and returns the output.
//
// cmd carries the runtime's launch prefix, so a custom profile is probed the
// way it is launched: `ccms start q36 --version` reports the version of the
// CLI the wrapper actually execs, where a bare `ccms --version` would report
// the wrapper's own and pin the runtime to the wrong compatibility policy.
func DetectVersion(ctx context.Context, cmd Command) (string, error) {
return detectCLIVersion(ctx, cmd)
}
// launchHeaders maps each supported agent type to the user-visible skeleton
// that the daemon spawns before any custom_args are appended. This is
// intentionally minimal — only the command + subcommand (or a short mode
// label when there is no subcommand). Internal flags, transport values, and
// environment variables are deliberately omitted so the string is a hint
// about *what* users are extending, not a dump of the full command line.
var launchHeaders = map[string]string{
"antigravity": "agy -p (non-interactive)",
"claude": "claude (stream-json)",
"codebuddy": "codebuddy (stream-json)",
"codex": "codex app-server",
"copilot": "copilot (json)",
"cursor": "cursor-agent (stream-json)",
"deveco": "deveco run (json)",
"hermes": "hermes acp",
"kimi": "kimi acp",
"reasonix": "reasonix acp",
"dsh": "dsh --profile multica (stdio)",
"kiro": "kiro-cli acp",
"openclaw": "openclaw agent (json)",
"opencode": "opencode run (json)",
"pi": "pi (json mode)",
"qoder": "qodercli --acp",
"qoderclicn": "qoderclicn --acp",
"traecli": "traecli acp serve",
"grok": "grok agent stdio",
"qwen": "qwen -p (stream-json)",
"qwenpaw": "qwenpaw acp",
"dim": "dim acp",
"mcode": "mcode acp",
}
// LaunchHeader returns the user-visible launch skeleton for agentType, or an
// empty string if the type is unknown. Callers render this as a preview so
// users understand which command their custom_args get appended to.
func LaunchHeader(agentType string) string {
if h := launchHeaders[agentType]; h != "" {
return h
}
// Built-in runtime identities derive their launch header from the
// descriptor, not the protocol-family map.
if desc, ok := BuiltinRuntimeByID(agentType); ok {
return desc.LaunchHeader
}
return ""
}