Files
bcf40040e0 MUL-8233: fix Pi cancellation and guard unconfirmed cleanup (#9557)
* fix(agent): join Pi cancellation cleanup and guard continuation

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

* fix(agent): preserve Pi completion and negotiate cleanup

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

* perf(agent): avoid Pi exit delay and index cleanup guards

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

---------

Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-27 12:37:13 +08:00

586 lines
28 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, Meta Muse 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 {
// EnableTaskSupplement installs provider hooks only for runs whose daemon/server
// capability handshake enabled additional messages.
EnableTaskSupplement bool
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, including Codex app-server and Claude SDK hooks;
// zero uses the provider default rather than disabling the bound.
HandshakeTimeout time.Duration
// TurnInterruptTimeout bounds how long the Codex backend waits for the
// app-server to acknowledge turn/interrupt and emit turn/completed after a
// task is cancelled. Zero uses the provider default. A positive override is
// useful on slower hosts without coupling cancellation cleanup to the much
// longer execution or handshake budgets.
TurnInterruptTimeout time.Duration
// ThreadHandshakeTimeout optionally gives Codex's heavier thread/start and
// thread/resume RPCs a wider budget than initialize and turn/start. Zero
// preserves the legacy behavior for callers that explicitly set
// HandshakeTimeout; when both are zero Codex uses separate built-in defaults.
ThreadHandshakeTimeout 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). "default" explicitly selects standard
// routing; 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
// OpenclawModelOverride is the claim-time snapshot of the agent's persistent
// underlying-model override. The setting applies to all newly claimed runs
// until changed or cleared. OpenClaw receives it as `--model <id>` alongside
// `--agent <id>`, preserving the identity, workspace, sessions, and memory
// selected by Model. Other backends ignore this field.
OpenclawModelOverride 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
// CursorHookCommand is the argv of the multica hook relay (the daemon's own
// binary followed by CursorHookHelperArg). The cursor backend needs it, with
// EnableTaskSupplement, to accept additional messages; other providers
// ignore it.
CursorHookCommand []string
// piMcpExtensionPath is set by piBackend.Execute after it writes the
// temporary MCP extension. The daemon does not fill it.
piMcpExtensionPath 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 {
// CleanupRequired means Result is also the acknowledgement that the
// backend's bounded process cleanup finished. A missing acknowledgement
// must prevent automatic continuation, even after upstream cancellation.
CleanupRequired bool
// Supplement delivers an additional human instruction to the currently
// active provider turn. Nil means the backend cannot safely do so.
// Adapters bound their own transport calls; a hook-based adapter may wait
// for the next provider boundary until ctx or the execution is cancelled.
Supplement func(context.Context, string) error
// SupplementReady reports whether Supplement currently targets a live,
// provider-confirmed turn. The daemon must not claim durable input before
// this becomes true: claiming during provider initialization would convert
// a transient startup window into a user-visible delivery failure. Nil is
// fail-closed and means no run-scoped input may be claimed.
SupplementReady func() bool
// ToolActivity optionally reports backend-owned tool accounting and its last
// transition time, independent of the best-effort transcript. Nil uses the
// daemon's message-based accounting. The timestamp gives completed tools a
// fresh idle budget even when their transcript message has not drained yet.
// The timestamp is an awakeclock reading (see package awakeclock), the same
// clock the idle watchdog measures, so host sleep never counts as silence.
ToolActivity func() (int32, time.Duration)
// InterruptBackgroundTools stops owned background tools at the daemon's
// tool watchdog boundary without cancelling the agent. True means at least
// one tool completed/was stopped and released from accounting; the watchdog
// gives the agent a fresh budget to report its authoritative result.
// Implementations must be concurrency-safe and return false after cleanup.
//
// This is reached only at the tool budget, so a zero tool budget never calls
// it: work that is genuinely in flight is what that setting declines to
// force-stop. The daemon's AgentToolWatchdog documentation states the
// operator-facing consequence.
InterruptBackgroundTools func() bool
// TerminalObserved reports whether the backend has already read its
// authoritative terminal result. Once true the run's outcome is decided and
// no liveness policy may reclassify it, however long the backend then takes
// to finish cleaning up. Backends must publish this before any cleanup that
// can block or fail, otherwise a completed run can still be re-tagged as a
// hang. Nil means the backend offers no such boundary.
//
// It must also be published before the backend sends on Result. The daemon
// reads it only after a result is in hand, so that ordering is what makes
// the read reliable instead of a race: delivering the result establishes
// the happens-before, and no flag read has to win a timing window.
TerminalObserved func() bool
// 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.
// Its four token counts are mutually exclusive: InputTokens excludes cache
// reads and writes, and OutputTokens includes reasoning/thinking.
// Breakdowns already included in a total must not be added to that total again.
// Zero counters alone do not establish that the provider reported complete usage.
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 permanently 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
// ResumeRejectedTransient is positive evidence that this run's requested
// resume cannot proceed right now, but the session itself remains healthy.
// The daemon may use a fresh session for this turn, but must not retire the
// requested session from future lookups. Backends must not set this together
// with ResumeRejected; the latter means the session is permanently unusable.
ResumeRejectedTransient 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, codearts, openclaw, hermes, pi, cursor, kimi, reasonix, dsh, kiro-cli, agy, qodercli, qoderclicn, traecli, grok, qwen, qwenpaw, mcode, muse, dim, zeroclaw)
CLIVersion string // detected version paired with ExecutablePath; vendor-specific usage semantics also require BuiltinRuntime
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", "codearts", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "reasonix", "dsh", "kiro", "antigravity", "qoder", "qoderclicn", "traecli", "grok", "qwen", "qwenpaw", "mcode", "muse".
//
// 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 342 to
// add mcode, migration 370 to add dim, migration 403 to add zeroclaw, and
// migration 441 to add codearts): 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",
"codearts",
"deveco",
"openclaw",
"hermes",
"pi",
"cursor",
"kimi",
"reasonix",
"dsh",
"kiro",
"antigravity",
"qoder",
"qoderclicn",
"traecli",
"grok",
"qwen",
"qwenpaw",
"mcode",
"muse",
"dim",
"devin",
"zeroclaw",
}
// 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,
"codearts": 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 "codearts":
return newCodeArtsBackend(cfg)
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
case "devin":
return &devinBackend{cfg: cfg}, nil
case "muse":
return &museBackend{cfg: cfg}, nil
case "zeroclaw":
return &zeroclawBackend{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)",
"codearts": "codearts run (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",
"devin": "devin acp",
"mcode": "mcode acp",
"muse": "muse serve (msp)",
"zeroclaw": "zeroclaw 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 ""
}