mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* fix(agent): attribute Grok usage from the turn's own model id A resumed Grok session with no configured model recorded its entire spend under the model id "unknown", which matches no pricing row — so the task reported $0 cost instead of its real spend. grok.go only learned the model from the session handshake, and ACP's `session/load` carries no model id (only `session/new` does). When neither the agent nor MULTICA_GROK_MODEL pins a model, `daemon.go` legitimately passes an empty model, leaving nothing to attribute the usage to. Every Grok turn stamps `result._meta.modelId` with what it actually billed against. Parse it in the shared ACP result parser and use it as the fallback in grok.go. Other ACP backends are untouched — they keep whatever the handshake gave them. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(metrics): price the Grok catalog in server-side cost metrics server/internal/metrics/pricing.go carried no Grok rows at all, so RecordLLMUsage took the unpriced branch for every Grok turn: llm_cost_usd reported zero Grok spend while the tokens accumulated in llm_unpriced_tokens. Internal cost monitoring simply could not see Grok. Add the six SKUs xAI publishes rates for, mirroring the frontend table in packages/views/runtimes/utils.ts. Aliases are anchored exact matches like the gpt-5.6 rows, so `grok-composer-*` (in the catalog, absent from the price sheet) stays unmapped instead of inheriting a guessed rate. Short-context tier on purpose: xAI bills a request at 2x once its prompt reaches 200K tokens, but a usage record aggregates every model call in a turn and cannot say which tier an individual request hit. A regression test re-derives the cost of a real grok 0.2.106 turn from the table and checks it against the costUsdTicks xAI returned for that turn. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): scope the Grok cost claim to what was actually fixed The v0.4.9 entry promised "accurate cost" in all four languages, but the fix corrected catalog pricing and cached-input double-counting — it did not implement xAI's 2x long-context tier, so a turn whose requests reach 200K prompt tokens still under-reports by up to 50%. Say what was fixed instead. Also correct two stale claims in the pricing comment: the daemon tags usage rows with the runtime provider `grok`, not `xai` (the bare `grok-*` keys are what make them resolve), and record why thresholding the long-context tier on an aggregated row would be worse than not pricing it at all. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * feat(usage): carry the provider's own cost through to the usage record Cost has always been derived client-side as tokens x a static rate, which cannot express request-level pricing rules. xAI bills a Grok request at 2x once its prompt reaches 200K tokens, and a task_usage row aggregates every model call in a turn — so the stored token counts genuinely cannot say which tier any individual request hit. Thresholding on the aggregate would be worse than the status quo: it turns a bounded 50% under-estimate into an unbounded over-estimate for turns made of many short requests. Grok already reports what it charged, per turn, in `_meta.usage.costUsdTicks`. Parse it, carry it through agent -> daemon -> API, and store it on task_usage as a nullable BIGINT of 1e-10 USD ticks (integer, so sub-cent turns stay exact end to end). NULL means the provider reported no cost — every pre-existing row and every provider that doesn't return one. No backfill: there is no authoritative figure to recover for those, and inventing one is the guess this removes. A single hourly bucket can mix rows that carry a cost with rows that don't, so task_usage_hourly gains both halves: `cost_usd_ticks` sums the authoritative side, and `uncosted_*_tokens` carry exactly the tokens that still need a rate-table estimate. Consumers report authoritative + estimate(uncosted), which degrades to today's behaviour when nothing in the bucket is authoritative. The existing token columns keep covering every row, so token displays are untouched. The new columns are additive with defaults, so the unique key, the dirty-queue shape, and migration 102's triggers are unaffected. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * feat(usage): prefer the provider's own cost over the rate table With the authoritative figure now stored, both cost consumers use it: the usage dashboard (estimateCost / estimateCostBreakdown) and the server-side llm_cost_usd metric. Each reports `authoritative + estimate(uncosted tokens)`, so a row or bucket that mixes priced and unpriced sources stays whole. The static rate tables remain, but for Grok they are now a fallback — they still price usage recorded by a daemon too old to report cost, and every provider that reports none. Custom pricing overrides likewise apply only to the estimated half: they are a user's guess at a rate, and the authoritative half is not a guess. A model with no rate-table row but a provider-reported cost now also drops out of the "unmapped models" banner, since asking the user to supply a rate for it would invite overriding a real bill. llm_cost_usd is labelled by token_type and the provider reports one number per turn, so the charge is distributed across the buckets in the rate table's own proportions. Only the total is authoritative; the split stays an estimate, which is why this scales the existing buckets rather than inventing a label. estimateCostBreakdown does the same, keeping the stacked chart summing to the headline figure instead of silently under-drawing every Grok row. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): say Grok cost now follows xAI's actual charge The earlier wording scoped the claim down to catalog pricing and cached input because the long-context tier was still unhandled. It is handled now — the cost comes from what xAI charged for the turn — so the entry can say so. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(usage): keep the provider's cost when the model has no rate row Both cost consumers bailed out before reading the authoritative figure when the rate table had no row for the model. A `grok-composer-*` turn — in the Grok Build catalog, absent from xAI's price sheet — was therefore reported as $0 spend even though xAI told us exactly what it charged. Worse on the client: estimateCost returned the real cost while estimateCostBreakdown returned zeros, so the headline and the stacked chart disagreed on precisely the rows whose cost is exact — and the unmapped-models banner was (correctly) hidden, so nothing explained the discrepancy. Handle the charge before the rate lookup in both places. Without rates there is nothing to split a total by, so it lands whole in the `input` bucket, the same fallback distributeAuthoritativeCost already uses when it has no shape to scale. Tokens with no rate keep going to llm_unpriced_tokens: "unpriced" describes the rate table, not the money. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * perf(usage): drop the historical rewrite from the cost-split migration Migration 213 rewrote every existing task_usage_hourly row to seed the uncosted counters. That is a full-table UPDATE inside a schema migration — lock time, WAL and bloat all scaling with table size — for rows this issue explicitly does not care about. Deleting the UPDATE alone would have zeroed historical cost: with `NOT NULL DEFAULT 0`, an untouched row asserts "nothing here needs estimating", so every pre-split bucket would report $0 until the rollup happened to touch it. Make the uncosted columns nullable with no default instead. NULL means "never recomputed since the split existed", readers COALESCE it to the row's own token total ("estimate all of it"), and the pre-split behaviour is preserved exactly — with nothing to seed, so no rewrite. A bare ADD COLUMN is metadata-only, so this is now fast DDL. Rows heal into the split naturally as the rollup recomputes their buckets. Verified on a fresh database: a legacy-shaped row reads back as its full tokens to estimate, and a group mixing legacy and post-split buckets sums to the authoritative cost plus both rows' estimable tokens. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
366 lines
16 KiB
Go
366 lines
16 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, Cursor, Kimi, Kiro, Antigravity, Qoder, Trae, Grok,
|
|
// Qwen Code). It
|
|
// mirrors the happy-cli AgentBackend pattern, translated to idiomatic Go.
|
|
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"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 is consumed only by providers that can pass or safely inline
|
|
// developer/system instructions. Hermes ACP intentionally ignores it and
|
|
// relies on cwd-scoped context files such as AGENTS.md instead.
|
|
SystemPrompt string
|
|
ThreadName string
|
|
MaxTurns int
|
|
Timeout time.Duration
|
|
SemanticInactivityTimeout 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 to the user instead of silently
|
|
// restarting. Currently honoured by the codex backend (MUL-4424).
|
|
ResumeExpected bool
|
|
ExtraArgs []string // daemon-wide default CLI arguments appended before CustomArgs; currently read by claude and codex backends only
|
|
CustomArgs []string // per-agent CLI arguments appended after ExtraArgs
|
|
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, 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, or the session belongs to
|
|
// another provider account. Only a refused resume can be cured by starting
|
|
// over, so it is what the daemon's fresh-session fallback looks for first.
|
|
//
|
|
// 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).
|
|
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, kiro-cli, agy, qodercli, traecli, grok, qwen)
|
|
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
|
|
}
|
|
|
|
// New creates a Backend for the given agent type.
|
|
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli", "grok", "qwen".
|
|
//
|
|
// 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, and migration 202 to add qwen): a
|
|
// custom runtime profile may only
|
|
// be based on a backend Multica officially supports.
|
|
// qoder is exposed here so Qoder CN (`qoderclicn`) users can point the Qoder
|
|
// backend at a non-default binary instead of misrouting through Kiro/ACP with
|
|
// incompatible arguments (#4883). 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",
|
|
"kiro",
|
|
"antigravity",
|
|
"qoder",
|
|
"traecli",
|
|
"grok",
|
|
"qwen",
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
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 "kiro":
|
|
return &kiroBackend{cfg: cfg}, nil
|
|
case "antigravity":
|
|
return &antigravityBackend{cfg: cfg}, nil
|
|
case "qoder":
|
|
return &qoderBackend{cfg: cfg}, nil
|
|
case "traecli":
|
|
return &traecliBackend{cfg: cfg}, nil
|
|
case "grok":
|
|
return &grokBackend{cfg: cfg}, nil
|
|
case "qwen":
|
|
return &qwenBackend{cfg: cfg}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok, qwen)", agentType)
|
|
}
|
|
}
|
|
|
|
// DetectVersion runs the agent CLI with --version and returns the output.
|
|
func DetectVersion(ctx context.Context, executablePath string) (string, error) {
|
|
return detectCLIVersion(ctx, executablePath)
|
|
}
|
|
|
|
// 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",
|
|
"kiro": "kiro-cli acp",
|
|
"openclaw": "openclaw agent (json)",
|
|
"opencode": "opencode run (json)",
|
|
"pi": "pi (json mode)",
|
|
"qoder": "qodercli --acp",
|
|
"traecli": "traecli acp serve",
|
|
"grok": "grok agent stdio",
|
|
"qwen": "qwen -p (stream-json)",
|
|
}
|
|
|
|
// 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 {
|
|
return launchHeaders[agentType]
|
|
}
|