mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
* 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]>
682 lines
25 KiB
Go
682 lines
25 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// dimBlockedArgs are flags hardcoded by the daemon that must not be
|
|
// overridden by user-configured custom_args. `acp` is the protocol
|
|
// subcommand that drives the ACP JSON-RPC transport; overriding it
|
|
// would break the daemon↔Dim communication contract. `--auth-setup`
|
|
// and `--remote` switch the CLI into modes that never start the ACP
|
|
// server (interactive login / removed remote flow).
|
|
var dimBlockedArgs = map[string]blockedArgMode{
|
|
"acp": blockedStandalone,
|
|
"--auth-setup": blockedStandalone,
|
|
"--remote": blockedStandalone,
|
|
"--help": blockedStandalone,
|
|
"-h": blockedStandalone,
|
|
}
|
|
|
|
// dimBackend implements Backend by spawning `dim acp` and
|
|
// communicating via the standard ACP (Agent Client Protocol) JSON-RPC 2.0
|
|
// over stdin/stdout.
|
|
//
|
|
// Dim (dimcode) exposes its agent loop over ACP through the `dim acp`
|
|
// subcommand. The protocol surface matches the shared hermesClient ACP
|
|
// transport used by Hermes/Kimi/Kiro/Traecli/QwenPaw — only the binary,
|
|
// the session bootstrap, and the tool-name extraction differ.
|
|
//
|
|
// Notable contract with Dim's ACP server:
|
|
// - `initialize` advertises no authMethods, so no `authenticate` step is
|
|
// needed; the CLI uses the user's existing Dim OAuth login.
|
|
// - `session/new` requires an absolute cwd (relative paths are rejected).
|
|
// - The ACP server hardcodes a read-only permission preset at session
|
|
// creation, which would silently deny every file write and process
|
|
// spawn. The backend therefore issues `session/set_config_option`
|
|
// (permission → full-access, mode → agent) right after session/new so
|
|
// Multica agents can do real work. A resumed session retains these
|
|
// settings across `session/load`, so set_config_option is re-applied on
|
|
// both fresh and resumed sessions (idempotent). Model override is handled through the standard
|
|
// `session/set_model` RPC; the model catalog is advertised by
|
|
// `session/new` under models.availableModels / configOptions.
|
|
// - `session/load` resumes a prior session across processes: dim 0.3.10+
|
|
// releases its per-process session lock within ~5s of the owning process
|
|
// exiting, so a follow-up run in a fresh `dim acp` process can continue
|
|
// the conversation. Earlier 0.3.x builds bound sessions to the creating
|
|
// process permanently; on those the load fails and ResumeRejected lets
|
|
// the daemon retry fresh.
|
|
// - Tool names are already lowercase ("write", "read", "exec"); the
|
|
// shared kimiToolNameFromTitle normalisation keeps the UI identifiers
|
|
// stable.
|
|
type dimBackend struct {
|
|
cfg Config
|
|
}
|
|
|
|
var (
|
|
dimReaderDrainGrace = 2 * time.Second
|
|
dimNotificationQuietTime = 250 * time.Millisecond
|
|
// dimProcessWaitTimeout bounds how long the deferred cleanup waits for
|
|
// the child to exit after cancel+stdin-close before force-killing it. A
|
|
// child that ignores both should not be able to hang Result delivery.
|
|
dimProcessWaitTimeout = 5 * time.Second
|
|
// dimSessionCloseTimeout bounds the best-effort session/close sent before
|
|
// tearing down the process. It is deliberately short so a stuck close
|
|
// cannot delay Result delivery; the next run's session/load is still safe
|
|
// because dim releases the lock on its own after ~5s.
|
|
dimSessionCloseTimeout = 2 * time.Second
|
|
// dimSessionLoadRetryAttempts bounds the number of retries when
|
|
// session/load reports "held by another process" — the prior process's
|
|
// lock has not been released yet. With graceful session/close this is
|
|
// rare (the lock releases immediately), but a force-killed process can
|
|
// take up to ~5s. The retry loop covers that window instead of silently
|
|
// starting a fresh session and losing the conversation.
|
|
dimSessionLoadRetryAttempts = 3
|
|
dimSessionLoadRetryDelay = 2 * time.Second
|
|
)
|
|
|
|
// dimMinVersion is the minimum dim version that supports cross-run session
|
|
// resume via session/load. Earlier builds bind sessions to the creating
|
|
// process permanently.
|
|
const dimMinVersion = "0.3.10"
|
|
|
|
// extractACPAgentVersion pulls agentInfo.version out of an initialize
|
|
// response. Returns "" if the field is absent (e.g. an older runtime that
|
|
// does not report it).
|
|
func extractACPAgentVersion(result json.RawMessage) string {
|
|
var resp struct {
|
|
AgentInfo struct {
|
|
Version string `json:"version"`
|
|
} `json:"agentInfo"`
|
|
}
|
|
if err := json.Unmarshal(result, &resp); err != nil {
|
|
return ""
|
|
}
|
|
return resp.AgentInfo.Version
|
|
}
|
|
|
|
// dimVersionSupported reports whether the dim version meets the minimum
|
|
// required for cross-run session resume. Uses semver-ish comparison
|
|
// (major.minor.patch). Fail-closed: an empty or non-parseable version is
|
|
// rejected (returns false) rather than assumed supported, so a runtime that
|
|
// cannot prove it is >= 0.3.10 is blocked from session resume (review #3).
|
|
func dimVersionSupported(version string) bool {
|
|
version = strings.TrimPrefix(version, "v")
|
|
parts := strings.Split(version, ".")
|
|
if len(parts) < 3 {
|
|
return false // unparseable — fail-closed, do not assume supported
|
|
}
|
|
var maj, min, pat int
|
|
if _, err := fmt.Sscanf(parts[0], "%d", &maj); err != nil {
|
|
return false
|
|
}
|
|
if _, err := fmt.Sscanf(parts[1], "%d", &min); err != nil {
|
|
return false
|
|
}
|
|
// patch may have a suffix (e.g. "10-beta"); Sscanf stops at the first
|
|
// non-digit, which is what we want.
|
|
if _, err := fmt.Sscanf(parts[2], "%d", &pat); err != nil {
|
|
return false
|
|
}
|
|
var reqMaj, reqMin, reqPat int
|
|
fmt.Sscanf(dimMinVersion, "%d.%d.%d", &reqMaj, &reqMin, &reqPat)
|
|
if maj != reqMaj {
|
|
return maj > reqMaj
|
|
}
|
|
if min != reqMin {
|
|
return min > reqMin
|
|
}
|
|
return pat >= reqPat
|
|
}
|
|
|
|
// dimMessageStream serializes sends and the final close so a late stdout
|
|
// reader cannot send on a closed channel. Mirrors grok/traecli/qoder.
|
|
type dimMessageStream struct {
|
|
ch chan Message
|
|
mu sync.Mutex
|
|
closed bool
|
|
}
|
|
|
|
func newDimMessageStream(size int) *dimMessageStream {
|
|
return &dimMessageStream{ch: make(chan Message, size)}
|
|
}
|
|
|
|
func (s *dimMessageStream) send(msg Message) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return
|
|
}
|
|
trySend(s.ch, msg)
|
|
}
|
|
|
|
func (s *dimMessageStream) close() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return
|
|
}
|
|
s.closed = true
|
|
close(s.ch)
|
|
}
|
|
|
|
func (b *dimBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
|
execPath := b.cfg.ExecutablePath
|
|
if execPath == "" {
|
|
execPath = "dim"
|
|
}
|
|
if _, err := exec.LookPath(execPath); err != nil {
|
|
return nil, fmt.Errorf("dim executable not found at %q: %w", execPath, err)
|
|
}
|
|
|
|
// Translate the agent's mcp_config (Claude-style object of objects) into
|
|
// the array shape ACP session/new and session/load expect. Fail closed on
|
|
// malformed JSON so the launch surfaces the real error instead of silently
|
|
// dropping every MCP server.
|
|
mcpServers, err := buildACPMcpServers(opts.McpConfig, b.cfg.Logger)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dim: invalid mcp_config: %w", err)
|
|
}
|
|
|
|
timeout := opts.Timeout
|
|
runCtx, cancel := runContext(ctx, timeout)
|
|
|
|
dimArgs := append([]string{"acp"}, filterCustomArgs(opts.CustomArgs, dimBlockedArgs, b.cfg.Logger)...)
|
|
|
|
cmd := b.cfg.commandAt(execPath).exec(runCtx, dimArgs...)
|
|
hideAgentWindow(cmd)
|
|
configureProcessGroup(cmd)
|
|
// Take over context cancellation: the default would SIGKILL only the
|
|
// leader the instant runCtx is done, orphaning descendants. We instead
|
|
// drive a group-wide SIGKILL from the deferred cleanup below. Returning
|
|
// nil keeps os/exec from racing us with its own kill.
|
|
cmd.Cancel = func() error { return nil }
|
|
cmd.WaitDelay = 10 * time.Second
|
|
b.cfg.logAgentCommand(cmd, newAgentCommandLogArgs(dimArgs, trustAgentCommandPositional(0, "acp")))
|
|
if opts.Cwd != "" {
|
|
cmd.Dir = opts.Cwd
|
|
}
|
|
cmd.Env = buildEnv(b.cfg.Env)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("dim stdout pipe: %w", err)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("dim stdin pipe: %w", err)
|
|
}
|
|
// StderrPipe + an explicit copier give us a join point (`stderrDone`) that
|
|
// fires before the failure-promotion decision; see hermes.go for why the
|
|
// io.MultiWriter form races with stopReason=end_turn under load.
|
|
providerErr := newACPProviderErrorSniffer("dim")
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("dim stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("start dim: %w", err)
|
|
}
|
|
|
|
stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[dim:stderr] "), providerErr)
|
|
stderrDone := make(chan struct{})
|
|
go func() {
|
|
defer close(stderrDone)
|
|
_, _ = io.Copy(stderrSink, stderr)
|
|
}()
|
|
|
|
b.cfg.Logger.Info("dim acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
|
|
|
|
msgStream := newDimMessageStream(256)
|
|
resCh := make(chan Result, 1)
|
|
|
|
// Dim streams interim narration and the final answer as the same
|
|
// agent_message_chunk type; the tracker keeps only the post-tool-call
|
|
// block for Result.Output while retaining the full text for error
|
|
// detection.
|
|
var deliverable acpDeliverableTracker
|
|
|
|
promptDone := make(chan hermesPromptResult, 1)
|
|
activity := make(chan struct{}, 1)
|
|
|
|
c := &hermesClient{
|
|
cfg: b.cfg,
|
|
stdin: stdin,
|
|
pending: make(map[int]*pendingRPC),
|
|
pendingTools: make(map[string]*pendingToolCall),
|
|
onActivity: func() {
|
|
select {
|
|
case activity <- struct{}{}:
|
|
default:
|
|
}
|
|
},
|
|
onMessage: func(msg Message) {
|
|
if msg.Type == MessageToolUse {
|
|
// Re-normalise tool titles the same way kimi/traecli do so the
|
|
// UI sees consistent snake_case names ("write" → "write_file").
|
|
msg.Tool = kimiToolNameFromTitle(msg.Tool)
|
|
}
|
|
deliverable.observe(msg)
|
|
msgStream.send(msg)
|
|
},
|
|
onPromptDone: func(result hermesPromptResult) {
|
|
select {
|
|
case promptDone <- result:
|
|
default:
|
|
}
|
|
},
|
|
}
|
|
|
|
readerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(readerDone)
|
|
scanner := newAgentStreamScanner(stdout)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
c.handleLine(line)
|
|
}
|
|
c.closeAllPending(fmt.Errorf("dim process exited"))
|
|
}()
|
|
|
|
go func() {
|
|
defer cancel()
|
|
defer msgStream.close()
|
|
defer close(resCh)
|
|
// Cleanup ordering matters: cancel the run context first so
|
|
// exec.CommandContext kills the child if it is still alive, THEN
|
|
// close stdin and wait. The previous order (stdin.Close → cmd.Wait →
|
|
// cancel) left cancel() unreachable when a child ignored stdin EOF
|
|
// and never exited, hanging cmd.Wait() forever and never closing
|
|
// resCh. cmd.Wait is bounded so a still-stuck child cannot block the
|
|
// final Result either.
|
|
defer func() {
|
|
// Signal the entire process group (the agent CLI plus any tool
|
|
// subprocesses it spawned) BEFORE closing stdin. exec.
|
|
// CommandContext's cancel is disabled (cmd.Cancel returns nil)
|
|
// so we own all signalling. If we close stdin first, the shell
|
|
// exits on EOF and its process group dissolves before we can
|
|
// SIGKILL the descendants, orphaning them to PPID=1.
|
|
signalProcessGroup(cmd, syscall.SIGKILL)
|
|
cancel()
|
|
stdin.Close()
|
|
waitDone := make(chan struct{})
|
|
go func() { _ = cmd.Wait(); close(waitDone) }()
|
|
select {
|
|
case <-waitDone:
|
|
case <-time.After(dimProcessWaitTimeout):
|
|
}
|
|
waitProcessGroupGone(cmd, dimProcessWaitTimeout)
|
|
// Release the Windows Job Object ownership handle (no-op on Unix)
|
|
// so the OS can finalize any processes that were attached to it.
|
|
releaseProcessGroup(cmd)
|
|
}()
|
|
|
|
startTime := time.Now()
|
|
finalStatus := "completed"
|
|
var finalError string
|
|
var sessionID string
|
|
// Set when the ACP runtime refuses the session we asked to
|
|
// resume. Only that is curable by starting a fresh session, so
|
|
// handshake/network failures below must leave it false.
|
|
var resumeRejected bool
|
|
effectiveModel := strings.TrimSpace(opts.Model)
|
|
|
|
initResult, err := c.request(runCtx, "initialize", map[string]any{
|
|
"protocolVersion": 1,
|
|
"clientInfo": map[string]any{
|
|
"name": "multica-agent-sdk",
|
|
"version": "0.2.0",
|
|
},
|
|
"clientCapabilities": map[string]any{},
|
|
})
|
|
if err != nil {
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("dim initialize failed: %v", err)
|
|
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
|
return
|
|
}
|
|
|
|
// Require dim >= 0.3.10 (fail-closed): earlier builds bind sessions to
|
|
// the creating process permanently, so cross-run session/load fails with
|
|
// "held by another process" and follow-up runs lose context. 0.3.10
|
|
// releases the lock on graceful exit (session/close) or after ~5s. A
|
|
// version that is empty or cannot be parsed is rejected rather than
|
|
// assumed supported, so a runtime that cannot prove it is >= 0.3.10 is
|
|
// blocked from session resume (review #3).
|
|
dimVersion := extractACPAgentVersion(initResult)
|
|
if !dimVersionSupported(dimVersion) {
|
|
finalStatus = "failed"
|
|
if dimVersion == "" {
|
|
finalError = "dim did not report an agent version: cross-run session resume requires dim >= 0.3.10 (0.3.10+ releases the per-process session lock on exit); please upgrade dimcode"
|
|
} else {
|
|
finalError = fmt.Sprintf("dim %s is too old: cross-run session resume requires dim >= 0.3.10 (0.3.10+ releases the per-process session lock on exit); please upgrade dimcode", dimVersion)
|
|
}
|
|
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
|
return
|
|
}
|
|
|
|
// Drop MCP entries whose remote transport the runtime didn't advertise.
|
|
// See hermes.go for why sending an unsupported transport tanks session/new.
|
|
mcpServers = filterACPMcpServersByCapability(mcpServers, extractACPMcpCapabilities(initResult), "dim", b.cfg)
|
|
|
|
cwd := opts.Cwd
|
|
if cwd == "" {
|
|
cwd = "."
|
|
}
|
|
|
|
// Dim (dimcode 0.3.10+) releases its per-process session lock within
|
|
// ~5s of the owning process exiting (graceful close or SIGTERM), so a
|
|
// follow-up run in a fresh `dim acp` process can resume the prior
|
|
// session via the standard ACP `session/load`. Earlier 0.3.x builds
|
|
// bound sessions to the creating process permanently; on those the
|
|
// load fails with "held by another process" and ResumeRejected lets
|
|
// the daemon retry on a fresh session.
|
|
//
|
|
// A loaded session retains its permission/mode config (verified: a
|
|
// full-access session stays full-access after load), but
|
|
// set_config_option is re-applied on both fresh and resumed sessions
|
|
// to guard against a partially configured session being resumed
|
|
// (review #4).
|
|
var freshSession bool
|
|
var sessionResult json.RawMessage
|
|
if opts.ResumeSessionID != "" {
|
|
// Retry session/load when the prior process's lock has not been
|
|
// released yet ("held by another process"). With graceful
|
|
// session/close the lock releases immediately, but a force-killed
|
|
// process can take up to ~5s. Retry instead of silently starting
|
|
// a fresh session and losing the conversation (review #1).
|
|
var result json.RawMessage
|
|
var loadErr error
|
|
loadRetry:
|
|
for attempt := 0; attempt <= dimSessionLoadRetryAttempts; attempt++ {
|
|
result, loadErr = c.request(runCtx, "session/load", map[string]any{
|
|
"cwd": cwd,
|
|
"sessionId": opts.ResumeSessionID,
|
|
"mcpServers": mcpServers,
|
|
})
|
|
if loadErr == nil {
|
|
break
|
|
}
|
|
if isACPSessionNotFound(loadErr) {
|
|
break // session is gone — no point retrying
|
|
}
|
|
if !isACPHeldByProcess(loadErr) {
|
|
break // different error — let the normal failure path handle it
|
|
}
|
|
if attempt < dimSessionLoadRetryAttempts {
|
|
b.cfg.Logger.Warn("dim session/load: lock not yet released, retrying",
|
|
"backend", "dim",
|
|
"attempt", attempt+1,
|
|
"delay", dimSessionLoadRetryDelay.String(),
|
|
)
|
|
select {
|
|
case <-time.After(dimSessionLoadRetryDelay):
|
|
case <-runCtx.Done():
|
|
loadErr = fmt.Errorf("dim session/load cancelled: %w", runCtx.Err())
|
|
break loadRetry
|
|
}
|
|
}
|
|
}
|
|
err := loadErr
|
|
if err != nil {
|
|
if isACPSessionNotFound(err) {
|
|
b.cfg.Logger.Warn("dim resumed session not found; the daemon will retry fresh",
|
|
"backend", "dim",
|
|
"requested_session", opts.ResumeSessionID,
|
|
)
|
|
resumeRejected = true
|
|
resCh <- Result{Status: "failed", Error: fmt.Sprintf("dim session/load: %v", err), DurationMs: time.Since(startTime).Milliseconds(), ResumeRejected: resumeRejected}
|
|
return
|
|
}
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
finalStatus = "timeout"
|
|
finalError = fmt.Sprintf("dim timed out during session/load: %v", timeout)
|
|
} else if runCtx.Err() == context.Canceled {
|
|
finalStatus = "aborted"
|
|
finalError = fmt.Sprintf("dim aborted: %v", err)
|
|
} else {
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("dim session/load failed: %v", err)
|
|
}
|
|
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), ResumeRejected: resumeRejected}
|
|
return
|
|
}
|
|
var changed bool
|
|
sessionID, changed = resolveResumedSessionID(opts.ResumeSessionID, result)
|
|
sessionResult = result
|
|
if changed {
|
|
b.cfg.Logger.Warn("dim returned a different session id on resume — original was likely lost; continuing with the new id",
|
|
"backend", "dim",
|
|
"requested", opts.ResumeSessionID,
|
|
"actual", sessionID,
|
|
)
|
|
}
|
|
if effectiveModel == "" {
|
|
effectiveModel = extractACPCurrentModelID(result)
|
|
}
|
|
} else {
|
|
freshSession = true
|
|
result, err := c.request(runCtx, "session/new", map[string]any{
|
|
"cwd": cwd,
|
|
"mcpServers": mcpServers,
|
|
})
|
|
if err != nil {
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
finalStatus = "timeout"
|
|
finalError = fmt.Sprintf("dim timed out during session/new: %v", timeout)
|
|
} else if runCtx.Err() == context.Canceled {
|
|
finalStatus = "aborted"
|
|
finalError = fmt.Sprintf("dim aborted: %v", err)
|
|
} else {
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("dim session/new failed: %v", err)
|
|
}
|
|
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), ResumeRejected: resumeRejected}
|
|
return
|
|
}
|
|
sessionID = extractACPSessionID(result)
|
|
sessionResult = result
|
|
if sessionID == "" {
|
|
finalStatus = "failed"
|
|
finalError = "dim session/new returned no session ID"
|
|
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), ResumeRejected: resumeRejected}
|
|
return
|
|
}
|
|
if effectiveModel == "" {
|
|
effectiveModel = extractACPCurrentModelID(result)
|
|
}
|
|
}
|
|
|
|
c.sessionID = sessionID
|
|
// Early session pin so a cancelled run still preserves resume pointer.
|
|
msgStream.send(Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
|
|
b.cfg.Logger.Info("dim session ready", "session_id", sessionID, "resumed", !freshSession)
|
|
|
|
// Dim's ACP server hardcodes a read-only permission preset at session
|
|
// creation. Raise it to full-access and pin agent mode. This runs on
|
|
// BOTH fresh and resumed sessions (review #4): a resumed session whose
|
|
// first run failed partway through config would carry a half-configured
|
|
// state into the resume. set_config_option is idempotent. On failure we
|
|
// close the session and abort.
|
|
for _, cfgOpt := range []struct {
|
|
id string
|
|
value string
|
|
}{
|
|
{"permission", "full-access"},
|
|
{"mode", "agent"},
|
|
} {
|
|
if _, err := c.request(runCtx, "session/set_config_option", map[string]any{
|
|
"sessionId": sessionID,
|
|
"configId": cfgOpt.id,
|
|
"value": cfgOpt.value,
|
|
}); err != nil {
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("dim could not set session config %s=%s: %v", cfgOpt.id, cfgOpt.value, err)
|
|
// Best-effort close so a partially configured session is not
|
|
// left behind for the next resume to inherit.
|
|
closeCtx, closeCancel := context.WithTimeout(context.Background(), dimSessionCloseTimeout)
|
|
_, _ = c.request(closeCtx, "session/close", map[string]any{"sessionId": sessionID})
|
|
closeCancel()
|
|
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), SessionID: sessionID, ResumeRejected: resumeRejected}
|
|
return
|
|
}
|
|
b.cfg.Logger.Info("dim session config set", "config", cfgOpt.id, "value", cfgOpt.value, "session_id", sessionID)
|
|
}
|
|
|
|
if opts.Model != "" {
|
|
if _, err := c.request(runCtx, "session/set_model", map[string]any{
|
|
"sessionId": sessionID,
|
|
"modelId": opts.Model,
|
|
}); err != nil {
|
|
b.cfg.Logger.Warn("dim set_session_model failed", "error", err, "requested_model", opts.Model)
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("dim could not switch to model %q: %v", opts.Model, err)
|
|
// Close the session so a partially configured one (permission/mode
|
|
// set, model not) is not left for the next resume to inherit.
|
|
closeCtx, closeCancel := context.WithTimeout(context.Background(), dimSessionCloseTimeout)
|
|
_, _ = c.request(closeCtx, "session/close", map[string]any{"sessionId": sessionID})
|
|
closeCancel()
|
|
resCh <- Result{
|
|
Status: finalStatus,
|
|
Error: finalError,
|
|
DurationMs: time.Since(startTime).Milliseconds(),
|
|
SessionID: sessionID,
|
|
ResumeRejected: resumeRejected,
|
|
}
|
|
return
|
|
}
|
|
b.cfg.Logger.Info("dim session model set", "model", opts.Model)
|
|
}
|
|
|
|
// Apply the thinking level (if any) via the ACP session config. Dim
|
|
// advertises a `thought_level` configOption in session/new (auto/high/
|
|
// max), so it joins the acpCatalogThinkingProviders list. The effort
|
|
// option may depend on the current model, so apply it after set_model.
|
|
applyACPEffortOption(runCtx, c.request, "dim", b.cfg.Logger,
|
|
sessionID, sessionResult, opts.ThinkingLevel, opts.Model == "")
|
|
|
|
userText := prompt
|
|
if opts.SystemPrompt != "" {
|
|
userText = opts.SystemPrompt + "\n\n---\n\n" + prompt
|
|
}
|
|
|
|
_, err = c.request(runCtx, "session/prompt", map[string]any{
|
|
"sessionId": sessionID,
|
|
"prompt": []map[string]any{
|
|
{"type": "text", "text": userText},
|
|
},
|
|
})
|
|
if err != nil {
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
finalStatus = "timeout"
|
|
finalError = fmt.Sprintf("dim timed out after %s", timeout)
|
|
} else if runCtx.Err() == context.Canceled {
|
|
finalStatus = "aborted"
|
|
finalError = "execution cancelled"
|
|
} else {
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("dim session/prompt failed: %v", err)
|
|
}
|
|
} else {
|
|
// Check if we got a promptDone result from the response parsing
|
|
// (non-blocking — the response is parsed synchronously during
|
|
// c.request, so promptDone is usually already ready).
|
|
select {
|
|
case pr := <-promptDone:
|
|
if pr.stopReason == "cancelled" {
|
|
finalStatus = "aborted"
|
|
finalError = "dim cancelled the prompt"
|
|
}
|
|
if effectiveModel == "" {
|
|
effectiveModel = pr.modelID
|
|
}
|
|
c.mergeUsage(pr.usage)
|
|
default:
|
|
}
|
|
// Give the stdout reader a bounded chance to consume notifications
|
|
// dim may emit just after session/prompt returns (agent_message_chunk,
|
|
// usage updates). Closing stdin at the response boundary otherwise
|
|
// races the reader and truncates the final text — the same race
|
|
// fixed for hermes (TestHermesBackendDrainsLateFinalNotification).
|
|
waitForACPNotificationQuiescence(runCtx, activity, readerDone, dimNotificationQuietTime, dimReaderDrainGrace)
|
|
}
|
|
|
|
duration := time.Since(startTime)
|
|
b.cfg.Logger.Info("dim finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
|
|
|
// Best-effort session/close before tearing down the process: it lets
|
|
// dim release the per-process session lock promptly (the next run can
|
|
// resume immediately) instead of waiting for the 5s idle-release
|
|
// timeout. Bounded and best-effort — a miss just means the next run
|
|
// waits up to 5s for the lock.
|
|
if sessionID != "" {
|
|
closeCtx, closeCancel := context.WithTimeout(context.Background(), dimSessionCloseTimeout)
|
|
_, _ = c.request(closeCtx, "session/close", map[string]any{"sessionId": sessionID})
|
|
closeCancel()
|
|
}
|
|
|
|
stdin.Close()
|
|
cancel()
|
|
|
|
// Dim's ACP server may keep the process — and the stdout/stderr
|
|
// pipes — open briefly after session/prompt returns. Bound the drain.
|
|
drainCtx, drainCancel := context.WithTimeout(context.Background(), dimReaderDrainGrace)
|
|
select {
|
|
case <-readerDone:
|
|
case <-drainCtx.Done():
|
|
}
|
|
select {
|
|
case <-stderrDone:
|
|
case <-drainCtx.Done():
|
|
}
|
|
drainCancel()
|
|
|
|
finalOutput, providerErrorOutput := deliverable.result()
|
|
|
|
// Promote completed→failed when stderr or the agent text stream show a
|
|
// terminal upstream-LLM failure (auth / rate-limit / HTTP 4xx). It reads
|
|
// the full text stream, not the deliverable, so a give-up turn that
|
|
// lands before a tool call stays visible.
|
|
finalStatus, finalError = promoteACPResultOnProviderError(finalStatus, finalError, providerErrorOutput, providerErr)
|
|
|
|
u := c.accumulatedUsage()
|
|
|
|
var usageMap map[string]TokenUsage
|
|
if acpUsagePresent(u) {
|
|
model := effectiveModel
|
|
if model == "" {
|
|
model = "unknown"
|
|
}
|
|
usageMap = map[string]TokenUsage{model: u}
|
|
}
|
|
|
|
resCh <- Result{
|
|
Status: finalStatus,
|
|
Output: finalOutput,
|
|
Error: finalError,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: sessionID,
|
|
ResumeRejected: resumeRejected,
|
|
Usage: usageMap,
|
|
}
|
|
}()
|
|
|
|
return &Session{Messages: msgStream.ch, Result: resCh}, nil
|
|
}
|