mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46: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]>
406 lines
19 KiB
Go
406 lines
19 KiB
Go
package execenv
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/agent"
|
|
)
|
|
|
|
// runtimeMarkerBegin and runtimeMarkerEnd delimit the Multica-managed brief
|
|
// inside the runtime config file (CLAUDE.md / AGENTS.md). The
|
|
// markers exist so writeRuntimeConfigFile can:
|
|
//
|
|
// - preserve user-authored content in the same file (the user's repo may
|
|
// already ship a CLAUDE.md / AGENTS.md when the agent is pointed at a
|
|
// local_directory project resource),
|
|
// - replace the brief idempotently on subsequent runs in the same workdir
|
|
// instead of appending duplicate copies, and
|
|
// - leave a precise excision target for a future cleanup pass.
|
|
//
|
|
// HTML comments are used so the markers are inert in every Markdown renderer
|
|
// and harmless when fed to the agent as instructions. Changing the marker
|
|
// text is a breaking change for any file that already carries the previous
|
|
// markers — bump deliberately.
|
|
const (
|
|
runtimeMarkerBegin = "<!-- BEGIN MULTICA-RUNTIME (auto-managed; do not edit) -->"
|
|
runtimeMarkerEnd = "<!-- END MULTICA-RUNTIME -->"
|
|
|
|
// runtimeManagedSeparator is the fixed separator inserted between any
|
|
// pre-existing user content and the marker block whenever Inject
|
|
// appends to a file that already exists. The separator is considered
|
|
// part of the managed region: Cleanup strips it together with the
|
|
// block, so the file rolls back to its exact pre-injection bytes
|
|
// regardless of whether the user file ended with no newline, one
|
|
// newline, or multiple trailing newlines. Without a fixed-width
|
|
// separator the cleanup path would have to renormalise the user's
|
|
// trailing bytes and would leave a subtle but real diff every run
|
|
// (see MUL-2753 review on PR #3438).
|
|
//
|
|
// Cleanup distinguishes "file we created" (no managed separator
|
|
// precedes the block — write a missing file from scratch) from "file
|
|
// that pre-existed" (managed separator precedes the block) so the
|
|
// file's existence is preserved exactly across the inject→cleanup
|
|
// cycle, including empty / whitespace-only pre-existing files.
|
|
runtimeManagedSeparator = "\n\n"
|
|
)
|
|
|
|
// runtimeGOOS is the host-platform string used by buildMetaSkillContent and
|
|
// BuildCommentReplyInstructions to emit Windows-specific guidance. Defaults
|
|
// to runtime.GOOS; tests override it to exercise the cross-platform branches
|
|
// deterministically without having to run on every target OS.
|
|
var runtimeGOOS = runtime.GOOS
|
|
|
|
// sanitizeNameForBriefMarkdown turns a possibly-multiline display name into a
|
|
// single-line, plain-text token that is safe to embed inside markdown inline
|
|
// constructs (e.g. `**%s**`) in the agent brief. The brief is loaded as
|
|
// trusted instructions, so user-controlled name fields must not be able to
|
|
// introduce headings, lists, or close the surrounding bold span.
|
|
//
|
|
// CR/LF and other whitespace control bytes collapse to a single space; other
|
|
// C0 controls and DEL are dropped; markdown structural characters that have
|
|
// meaning in inline context (`*`, `_`, “ ` “, `\`, `[`, `]`, `<`) are
|
|
// backslash-escaped. Trailing whitespace is trimmed.
|
|
func sanitizeNameForBriefMarkdown(name string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(name))
|
|
prevSpace := false
|
|
for _, r := range name {
|
|
switch {
|
|
case r == '\r' || r == '\n' || r == '\t' || r == '\v' || r == '\f':
|
|
if !prevSpace && b.Len() > 0 {
|
|
b.WriteByte(' ')
|
|
prevSpace = true
|
|
}
|
|
case r < 0x20 || r == 0x7f:
|
|
continue
|
|
case r == '*' || r == '_' || r == '`' || r == '\\' || r == '[' || r == ']' || r == '<':
|
|
b.WriteByte('\\')
|
|
b.WriteRune(r)
|
|
prevSpace = false
|
|
default:
|
|
b.WriteRune(r)
|
|
prevSpace = false
|
|
}
|
|
}
|
|
return strings.TrimSpace(b.String())
|
|
}
|
|
|
|
// sanitizeEmailForBrief returns the email verbatim when it is safe to embed
|
|
// inline in the brief, or "" when it carries a character a real address never
|
|
// has (whitespace, control chars, or a markdown-break risk). Unlike
|
|
// sanitizeNameForBriefMarkdown it does NOT backslash-escape markdown specials:
|
|
// an agent may want to match the initiator's address exactly, and escaping
|
|
// `_`/`+` would corrupt it, while a valid email can't contain a newline to
|
|
// inject a heading anyway. Emails are validated at signup, so this is
|
|
// defense-in-depth, not the primary guard. See MUL-2645.
|
|
func sanitizeEmailForBrief(email string) string {
|
|
email = strings.TrimSpace(email)
|
|
if email == "" || !strings.Contains(email, "@") {
|
|
return ""
|
|
}
|
|
for _, r := range email {
|
|
if r < 0x20 || r == 0x7f || r == ' ' || r == '\\' || r == '`' || r == '*' || r == '<' || r == '>' || r == '[' || r == ']' {
|
|
return ""
|
|
}
|
|
}
|
|
return email
|
|
}
|
|
|
|
// formatProjectResource renders a single resource as a human-readable bullet.
|
|
// Unknown resource types fall back to a JSON-encoded ref so the agent can
|
|
// still read what the user attached. New resource types should add a case
|
|
// here AND in the API validator (handler/project_resource.go).
|
|
func formatProjectResource(r ProjectResourceForEnv) string {
|
|
label := r.Label
|
|
switch r.ResourceType {
|
|
case "github_repo":
|
|
var payload struct {
|
|
URL string `json:"url"`
|
|
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
|
|
Ref string `json:"ref,omitempty"`
|
|
}
|
|
_ = json.Unmarshal(r.ResourceRef, &payload)
|
|
out := fmt.Sprintf("**GitHub repo**: %s", payload.URL)
|
|
details := make([]string, 0, 2)
|
|
if payload.Ref != "" {
|
|
details = append(details, fmt.Sprintf("checkout ref: `%s`", payload.Ref))
|
|
}
|
|
if payload.DefaultBranchHint != "" {
|
|
details = append(details, fmt.Sprintf("default branch hint: `%s`", payload.DefaultBranchHint))
|
|
}
|
|
if len(details) > 0 {
|
|
out += " (" + strings.Join(details, ", ") + ")"
|
|
}
|
|
if label != "" {
|
|
out += " — " + label
|
|
}
|
|
return out
|
|
default:
|
|
ref := string(r.ResourceRef)
|
|
if ref == "" {
|
|
ref = "{}"
|
|
}
|
|
out := fmt.Sprintf("**%s**: `%s`", r.ResourceType, ref)
|
|
if label != "" {
|
|
out += " — " + label
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
|
|
// config file so the agent discovers its environment through its native mechanism.
|
|
//
|
|
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
|
|
// For CodeBuddy: writes {workDir}/CODEBUDDY.md (CodeBuddy's native memory filename; skills discovered natively from .codebuddy/skills/)
|
|
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
|
|
// For Copilot: writes {workDir}/AGENTS.md (skills discovered natively from .github/skills/)
|
|
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .opencode/skills/)
|
|
// For DevEco Code: writes {workDir}/AGENTS.md (skills discovered natively from .deveco/skills/)
|
|
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from {workDir}/skills/ via per-task openclaw-config.json that pins agents.defaults.workspace)
|
|
// For Hermes: writes {workDir}/AGENTS.md (skills discovered natively from a per-task HERMES_HOME/skills seeded by the daemon; see hermes_home.go)
|
|
// For Pi: writes {workDir}/AGENTS.md (skills discovered natively from .pi/skills/)
|
|
// For Oh-My-Pi (omp): writes {workDir}/AGENTS.md (omp is a pi fork; skills discovered from .omp/skills/)
|
|
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
|
|
// For Kimi: writes {workDir}/AGENTS.md (Kimi Code CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
|
|
// For Reasonix: writes {workDir}/AGENTS.md (Reasonix reads AGENTS.md and .reasonix/skills/ natively)
|
|
// For DSH: writes {workDir}/AGENTS.md (DSH reads AGENTS.md and .dsh/skills/ natively)
|
|
// For Kiro: writes {workDir}/AGENTS.md (Kiro CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
|
|
// For Qoder/Qoder CN: writes {workDir}/AGENTS.md (skills discovered from .qoder/skills/; user-level roots are unaffected)
|
|
// For Antigravity: writes {workDir}/AGENTS.md (agy CLI reads AGENTS.md natively; skills discovered natively from .agents/skills/ — see https://antigravity.google/docs/gcli-migration)
|
|
// For Traecli: writes {workDir}/AGENTS.md (traecli reads .trae/rules/ not AGENTS.md, so the brief is delivered inline via providerNeedsInlineSystemPrompt; the file is written for parity/visibility only)
|
|
// For Grok: writes {workDir}/AGENTS.md (Grok Build CLI reads AGENTS.md natively from the workdir)
|
|
// For Qwen: writes {workDir}/QWEN.md (Qwen Code's native context file; it also reads AGENTS.md, but QWEN.md avoids cross-runtime ambiguity)
|
|
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) (string, error) {
|
|
content := buildMetaSkillContent(provider, ctx)
|
|
path := runtimeConfigPath(workDir, provider)
|
|
if path == "" {
|
|
// Unknown provider — skip config injection, prompt-only mode.
|
|
return content, nil
|
|
}
|
|
return content, writeRuntimeConfigFile(path, content)
|
|
}
|
|
|
|
// runtimeConfigPath returns the absolute path to the runtime config file that
|
|
// InjectRuntimeConfig writes for the given provider, or "" when the provider
|
|
// has no file-based config target. Centralising the mapping keeps Inject /
|
|
// Cleanup in lockstep — both paths consult the same table so a new provider
|
|
// added to one side cannot drift past the other.
|
|
func runtimeConfigPath(workDir, provider string) string {
|
|
// Built-in runtime identities (e.g. "omp") inherit their config file
|
|
// from their protocol family — resolve the family from the descriptor
|
|
// and delegate to the family's switch case. This avoids hardcoding
|
|
// "AGENTS.md" for every descriptor; a compatible runtime on Claude,
|
|
// CodeBuddy, or Qwen would otherwise write the wrong file.
|
|
if desc, ok := agent.BuiltinRuntimeByID(provider); ok {
|
|
return runtimeConfigPath(workDir, desc.ProtocolFamily)
|
|
}
|
|
switch provider {
|
|
case "claude":
|
|
return filepath.Join(workDir, "CLAUDE.md")
|
|
case "codebuddy":
|
|
// CodeBuddy Code's native memory file is CODEBUDDY.md, not
|
|
// CLAUDE.md — see https://www.codebuddy.ai/docs/cli/codebuddy-dir
|
|
// ("CODEBUDDY.md / .codebuddy/CODEBUDDY.md — Project-level memory
|
|
// file"). CodeBuddy only reads CLAUDE.md if the user manually
|
|
// migrates/symlinks it in.
|
|
return filepath.Join(workDir, "CODEBUDDY.md")
|
|
case "qwen":
|
|
return filepath.Join(workDir, "QWEN.md")
|
|
case "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "reasonix", "dsh", "kiro", "antigravity", "qoder", "qoderclicn", "traecli", "grok", "qwenpaw", "mcode", "dim":
|
|
return filepath.Join(workDir, "AGENTS.md")
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// writeRuntimeConfigFile writes the Multica runtime brief to path without
|
|
// clobbering any user-authored content already present. Behaviour by file
|
|
// state:
|
|
//
|
|
// - file missing → create the file containing only the marker block, no
|
|
// leading separator. Cleanup detects the absence of the separator and
|
|
// restores the missing-file state by removing the file outright.
|
|
// - file present (any content, including empty), no marker block →
|
|
// append `<runtimeManagedSeparator>` + the marker block. The
|
|
// separator's bytes are part of the managed region so Cleanup can
|
|
// restore the user's pre-injection bytes exactly (no trailing-newline
|
|
// normalisation, no surprises for files that ended without a newline
|
|
// or with extra trailing newlines).
|
|
// - file present, marker block already there → replace the body between
|
|
// the markers in place so repeated runs in the same workdir don't grow
|
|
// the file unboundedly. The pre-block content (including any managed
|
|
// separator established by the first inject) is preserved verbatim.
|
|
//
|
|
// The previous implementation called os.WriteFile unconditionally, which
|
|
// silently truncated a repository's CLAUDE.md / AGENTS.md the
|
|
// first time the agent was pointed at the user's own directory via the
|
|
// local_directory project resource flow. See MUL-2753.
|
|
func writeRuntimeConfigFile(path, brief string) error {
|
|
block := runtimeMarkerBegin + "\n" + strings.TrimRight(brief, "\n") + "\n" + runtimeMarkerEnd + "\n"
|
|
|
|
existing, err := os.ReadFile(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return os.WriteFile(path, []byte(block), 0o644)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read existing runtime config %s: %w", path, err)
|
|
}
|
|
|
|
existingStr := string(existing)
|
|
if start, end, ok := locateMarkerBlock(existingStr); ok {
|
|
// Replace the existing block in place. locateMarkerBlock already
|
|
// consumes the trailing newline that closed the previous block, so
|
|
// successive runs don't accumulate blank lines around the block.
|
|
// The managed separator (if any) lives in existingStr[:start] and
|
|
// is preserved untouched.
|
|
newContent := existingStr[:start] + block + existingStr[end:]
|
|
return os.WriteFile(path, []byte(newContent), 0o644)
|
|
}
|
|
|
|
// No marker block present. Append the fixed managed separator followed
|
|
// by the block. The separator is unconditional — including for files
|
|
// that already end in two or more newlines — so the byte boundary
|
|
// between user content and the managed region is deterministic, which
|
|
// is what lets Cleanup roll back to the user's exact original bytes.
|
|
return os.WriteFile(path, []byte(existingStr+runtimeManagedSeparator+block), 0o644)
|
|
}
|
|
|
|
// locateMarkerBlock finds the [start, end) byte range of the Multica marker
|
|
// block inside content. The returned `end` is one past the block's trailing
|
|
// newline (if any) so callers can splice the block out without leaving an
|
|
// orphan blank line behind.
|
|
//
|
|
// The end marker is searched for strictly after the begin marker. This
|
|
// matters for two malformed cases that the previous naive `strings.Index`
|
|
// pair would mishandle:
|
|
//
|
|
// - User content carries a stray `<!-- END MULTICA-RUNTIME -->` (e.g. a
|
|
// documentation snippet showing what the wire format looks like) before
|
|
// any begin marker. The naive parser would find that end and reject the
|
|
// block (`endIdx > startIdx` false), then append a fresh block — and
|
|
// since the stray end stays in place, every subsequent run would append
|
|
// yet another block, growing the file unboundedly.
|
|
// - A previous run crashed between writing begin and end and left the file
|
|
// with a half-block. The naive parser would not find an end, fall
|
|
// through to the append branch, and stack a new block after the
|
|
// half-block. Treating "begin found, no end after" as "the block ends
|
|
// at EOF" makes the next write replace the half-block in place.
|
|
func locateMarkerBlock(content string) (start, end int, found bool) {
|
|
start = strings.Index(content, runtimeMarkerBegin)
|
|
if start < 0 {
|
|
return 0, 0, false
|
|
}
|
|
afterBegin := start + len(runtimeMarkerBegin)
|
|
endRel := strings.Index(content[afterBegin:], runtimeMarkerEnd)
|
|
if endRel < 0 {
|
|
// Malformed — no end marker after begin. Treat the rest of the file
|
|
// as the block so the next write replaces it cleanly instead of
|
|
// stacking another block beneath the half-block.
|
|
return start, len(content), true
|
|
}
|
|
end = afterBegin + endRel + len(runtimeMarkerEnd)
|
|
if end < len(content) && content[end] == '\n' {
|
|
end++
|
|
}
|
|
return start, end, true
|
|
}
|
|
|
|
// CleanupRuntimeConfig excises the Multica marker block from the runtime
|
|
// config file for the given provider and restores the file to its exact
|
|
// pre-injection state, byte for byte. The cleanup is the second half of
|
|
// the contract `writeRuntimeConfigFile` establishes: together they must
|
|
// round-trip a user's local repository config across an arbitrary number
|
|
// of Multica runs without ever touching a single non-managed byte.
|
|
//
|
|
// Behaviour, mirroring the three Inject states:
|
|
//
|
|
// - file has no marker block → no-op (nothing was ever injected here);
|
|
// - block is at the start of the file with no preceding managed
|
|
// separator → the file was created by Inject from a missing-file
|
|
// state. Remove the file outright so the post-cleanup directory
|
|
// listing is byte-identical to the pre-Inject one.
|
|
// - block is preceded by the fixed managed separator → strip the
|
|
// separator together with the block; whatever remains (which may be
|
|
// an empty pre-existing file, a whitespace-only file, or arbitrary
|
|
// user content) is the user's original file, written back verbatim
|
|
// with NO trailing-newline normalisation and NO TrimSpace-based file
|
|
// removal heuristic. Both of those were sources of subtle diff in
|
|
// PR #3438 review feedback.
|
|
//
|
|
// Required for the local_directory flow (WorkDir is the user's own repo):
|
|
// without this pass, a manual `claude` / `codex` run started by
|
|
// the user inside the same directory after a Multica task would pick up
|
|
// the stale brief and act on the previous task's issue id, trigger
|
|
// comment id, and reply rules. Cloud workspace runs never trigger this
|
|
// pollution because their workdir is daemon scratch that the GC loop
|
|
// deletes wholesale; the daemon skips this Cleanup on those workdirs.
|
|
//
|
|
// Missing files, unknown providers, and files without a marker block are
|
|
// no-ops — Cleanup is safe to call defensively.
|
|
func CleanupRuntimeConfig(workDir, provider string) error {
|
|
path := runtimeConfigPath(workDir, provider)
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
existing, err := os.ReadFile(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read runtime config %s: %w", path, err)
|
|
}
|
|
existingStr := string(existing)
|
|
start, end, ok := locateMarkerBlock(existingStr)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
pre := existingStr[:start]
|
|
post := existingStr[end:]
|
|
|
|
// Detect — and strip — the fixed managed separator that Inject puts
|
|
// immediately before the block whenever it appended to a file that
|
|
// pre-existed. The absence of the separator is the marker that says
|
|
// "Inject created this file from scratch", which is the only case
|
|
// where Cleanup is allowed to delete the file.
|
|
hadManagedSeparator := strings.HasSuffix(pre, runtimeManagedSeparator)
|
|
if hadManagedSeparator {
|
|
pre = pre[:len(pre)-len(runtimeManagedSeparator)]
|
|
}
|
|
remainder := pre + post
|
|
|
|
if !hadManagedSeparator && remainder == "" {
|
|
// Inject created the file (no managed separator → block was the
|
|
// only content). Restore the missing-file state.
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("remove runtime config %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
// File pre-existed (possibly empty, possibly whitespace-only,
|
|
// possibly with user content) — write the remainder back exactly,
|
|
// without any normalisation. An empty `remainder` here means the
|
|
// user's original file was empty; we still write it (zero-byte file)
|
|
// so the file's existence is preserved.
|
|
return os.WriteFile(path, []byte(remainder), 0o644)
|
|
}
|
|
|
|
// buildMetaSkillContent generates the meta skill markdown that teaches the
|
|
// agent about the Multica runtime environment and available CLI tools.
|
|
//
|
|
// The brief is assembled by buildMetaSkillContentSlim (runtime_config_sections.go),
|
|
// which applies kind-driven section gating + per-section prose compression.
|
|
// This used to be gated behind the `runtime_brief_slim` feature flag against a
|
|
// legacy verbose brief; the flag has been retired (MUL-4297) and the slim brief
|
|
// is now the only path.
|
|
func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
|
return buildMetaSkillContentSlim(provider, ctx)
|
|
}
|