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

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

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

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

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

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

Two gated tests behind MULTICA_RUN_REAL_AGENT_SMOKE=1:

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

Verified against dimcode 0.3.2 and 0.3.8.

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

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

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

Address review feedback on the Dim ACP runtime:

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

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

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

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

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

Address all remaining blockers from the second review:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Address all 5 remaining blockers from review round 4:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Address all 4 findings from review round 5:

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

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

* chore: trigger CI

* chore: refresh PR

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

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

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

* fix: restore all remaining mcode references lost during merge

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

Verified: no file has fewer mcode references than upstream.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(migrations): renumber dim migration to 370

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

---------

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

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
}