mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* feat(agent): add Kimi CLI as agent runtime
Adds support for Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/kimi-cli)
as a new agent runtime, alongside Claude, Codex, OpenCode, OpenClaw, Hermes,
Gemini, Pi, Cursor and Copilot.
Kimi Code CLI implements the standard Agent Client Protocol (ACP) via the
`kimi acp` subcommand, so the new `kimiBackend` reuses the existing
hermesClient JSON-RPC transport in the agent package — only the binary,
client identity, log prefix, and tool-name extraction differ.
Wiring:
- server/pkg/agent: new kimiBackend + kimi_test.go; registered in New(),
LaunchHeader map, and the supported-types coverage test.
- server/internal/daemon/config.go: probes `kimi` (overridable via
MULTICA_KIMI_PATH / MULTICA_KIMI_MODEL).
- server/internal/daemon/execenv: writes AGENTS.md as the runtime context
file (Kimi reads AGENTS.md natively via /init), and writes skills under
`.kimi/skills/` so they are auto-discovered by the project-level skill
loader.
- packages/views/runtimes: ProviderLogo gains a Kimi mark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(agent/kimi): support per-agent model selection via ACP set_model
Wire Kimi into the model dropdown introduced in #1399:
- ListModels gets a 'kimi' case that drives the same ACP
initialize + session/new handshake as Hermes; both share a new
discoverACPModels helper and parseACPSessionNewModels parser
so future ACP backends only need a small provider entry.
- kimiBackend now issues session/set_model after session/new when
opts.Model is non-empty, mirroring the Hermes flow. Failures
fail the task instead of silently falling back to Kimi's
default model — silent fallback would hide that the dropdown
pick wasn't honoured.
Verified: go build ./..., go test ./pkg/agent/... ./internal/daemon/... ./internal/handler/..., pnpm typecheck and pnpm test (138 passed).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(agent): address code review feedback on Kimi runtime
- Share ACP provider-error sniffer between hermes and kimi. Previously
only hermes promoted stderr-observed 4xx/5xx into a failed task;
kimi would report "completed + empty output" when the Moonshot
upstream rejected a request (expired token, rate limit, …). Rename
hermesProviderErrorSniffer → acpProviderErrorSniffer and parameterise
the provider name; wire it into kimiBackend.Execute the same way.
- Rename extractHermesSessionID → extractACPSessionID (shared by all
ACP backends) so the name matches parseACPSessionNewModels.
- Drop the redundant second argument to kimiToolNameFromTitle; the
Message struct has only one relevant field (Tool), so passing it
twice was a dead fallback. Document that the function normalises
residual capitalised kimi titles not caught by hermesToolNameFromTitle.
- Remove kimi-only cmd.WaitDelay override; the hermes baseline is
fine for both and divergence adds noise.
- Add TestKimiBackendSetModelFailureFailsTask: fake `kimi acp` binary
that returns a JSON-RPC error for session/set_model, asserts that
the task result surfaces status=failed with the model name + upstream
message and preserves the session id.
- Fix stale agent listings in agent.go / daemon/config.go doc comments
(missing cursor, gemini, copilot).
All: `go build ./...`, `go vet ./...`, `go test ./pkg/agent/...
./internal/daemon/... ./internal/handler/...` green.
* fix(agent/kimi): pass --yolo so Shell tools don't hang on approval
Kimi's default config has `default_yolo = false`. Every Shell/file-mutating
tool call causes kimi acp to send a `session/request_permission` request
and block (up to 300s) waiting for a response. The daemon's hermesClient
only handles `session/update` notifications — permission requests go
unanswered, the tool call times out, and the UI loop eventually dies
("UI loop timed out"). Observed with the first real kimi task: agent sat
as Live for ~7 minutes before the daemon killed it.
The fix mirrors hermes' HERMES_YOLO_MODE=1 override: pass `--yolo` to
`kimi` so it auto-approves everything. `--yolo` is a top-level flag on
the `kimi` CLI (not a flag on `kimi acp`), so it must come before the
`acp` subcommand in argv. Added to kimiBlockedArgs so user custom_args
can't strip it.
While here, fix a related bug that made kimi tool names show up empty
in the daemon log ("tool #1: "): hermesToolNameFromTitle's fallback
returned `kind` when neither title-with-colon nor kind matched a known
tool. Kimi's ACP `tool_call` emits bare titles like "Shell" or "Read
file" with no `kind` at all, so we'd drop the title on the floor before
kimiToolNameFromTitle ever got a chance to map it. Now: preserve the
title when kind is unclassified; hermes titles always carry a colon so
this branch never fires for hermes.
Tests:
- TestKimiBackendPassesYoloFlag — fake binary that records its argv,
asserts --yolo comes before acp.
- TestHermesToolNameFromTitle rows for bare kimi-style titles.
- Existing suite green: go build, go vet, full pkg/agent + daemon +
handler test packages.
* fix(agent/acp): auto-approve session/request_permission from agent
The previous attempt (`kimi --yolo acp`) was a no-op. Inspected the
kimi-cli source: the `acp` Typer subcommand takes no parameters, so
flags on the root `kimi` command are dropped before `acp_main()` runs
— it's impossible to opt into YOLO mode through CLI flags for ACP.
The real fix is on our side: respond to session/request_permission.
ACP is bidirectional. When kimi runs a Shell or file-write tool, it
sends `session/request_permission` (agent → client, JSON-RPC request
with id + method) and waits up to 300s for a response. Our existing
hermesClient.handleLine only dispatched: (id + result/error) →
handleResponse, and (no id + method) → handleNotification. A request
with BOTH id and method fell through and got silently dropped — kimi
timed out, UI loop died, task sat stuck for 7 minutes.
Add handleAgentRequest: for session/request_permission, echo the id
and respond with outcome=selected, optionId=approve_for_session. The
daemon is headless; there's no user to prompt. `approve_for_session`
lets the agent remember the action so subsequent identical calls
(every Shell, every file write) skip the round-trip entirely. For any
other agent → client method, reply with standard -32601 method-not-
found so the agent doesn't block.
Also:
- Add writeMu so request() (main goroutine) and handleAgentRequest
(reader goroutine) don't interleave JSON frames on stdin.
- Revert the `--yolo acp` flag — it's a no-op, and carrying it in
kimiBlockedArgs gives the wrong impression that it does something.
Comment in kimi.go now points at handleAgentRequest as the real fix.
Tests:
- TestHermesClientAutoApprovesPermissionRequest: inject a
session/request_permission, assert the reply echoes the id and
carries {outcome: selected, optionId: approve_for_session}.
- TestHermesClientReplesMethodNotFoundForUnknownAgentRequest: confirm
unknown agent → client methods get JSON-RPC -32601 instead of silence.
- TestKimiBackendInvokesACPSubcommand replaces the yolo-flag assertion
with a negative assertion: no dead --yolo / --auto-approve / -y on
argv, since they'd pretend to do something they can't.
All: go build ./..., go vet ./..., go test ./pkg/agent/... green.
* fix(agent/acp): surface kimi tool input/output via content blocks
Kimi-cli emits tool_call and tool_call_update ACP frames with the
input/output inside a `content` array of ContentToolCallContent
blocks (shape: {type:"content", content:{type:"text", text:"..."}}),
not in the hermes-style `rawInput` map / `rawOutput` string. Our
parser only looked at rawInput/rawOutput, so the daemon recorded
empty Input and Output for every kimi tool — the execution-history
UI showed blank terminal panels even for commands that ran fine.
Add extractACPToolCallText() and a fallback in handleToolCallStart /
handleToolCallUpdate: when rawInput is nil / rawOutput is empty, pull
the text out of the content blocks. rawInput / rawOutput still take
precedence so hermes' behaviour is untouched. Terminal /
FileEditToolCallContent blocks are skipped (we have nothing to render
them as — kimi only emits TerminalToolCallContent when the client
advertises terminal capability, which we don't).
Tests:
- TestHermesClientHandleToolCallStartKimiContent — content array →
Input.text populated.
- TestHermesClientHandleToolCallCompleteKimiContent — multi-block
content → Output concatenated with newline separator.
- TestHermesClientHandleToolCallRawOutputTakesPrecedence — hermes
rawOutput still wins when both are present.
- TestExtractACPToolCallText — unit coverage for the helper
(single/multiple text blocks, terminal-block skip, empty input).
* fix(agent/acp): buffer streaming tool args so Input isn't empty in UI
kimi-cli streams tool args token-by-token via tool_call_update frames
— the initial tool_call carries an empty content block and each
subsequent in_progress update carries the cumulative JSON so far
(`{`, `{"comma`, `{"command": "echo`, …). The final completed update
then carries the tool's stdout, not the args. Observed per kimi-cli
acp/session.py::_send_tool_call{,_part,_result} and confirmed by
driving a real Shell call end-to-end: 10 in_progress frames, last
with `{"command": "echo hello world"}`, then completed with `hello
world\n`.
Our previous handleToolCallStart emitted MessageToolUse on the first
tool_call frame, capturing the empty content — so every kimi tool
appeared in the execution-history UI with a blank input. Output was
correct (fix 4335c198) but command was missing.
Changes:
- hermesClient now tracks pending tool calls per toolCallId. Hermes
path is unchanged — rawInput is present at tool_call time, so
emit-immediately-then-flag-emitted still fires on the initial frame.
- kimi path defers MessageToolUse until status=completed / failed.
tool_call_update in_progress frames update the buffered argsText
(cumulative, so overwrite); on completion we parse the accumulated
JSON into Message.Input. Malformed JSON falls back to `{"text": …}`
so non-JSON tool args still render.
- Orphan completion frames (no matching tool_call seen — e.g. daemon
restarted mid-task) synthesise ToolUse from the update's own
title/kind/rawInput so the UI still gets a header.
- extractACPToolCallText now also renders FileEditToolCallContent
blocks as a compact header ("--- path / +++ path / (edited: N → M
bytes)"). kimi emits these for Write / StrReplaceFile / Patch when
the tool's display block is a DiffDisplayBlock.
Tests:
- TestHermesClientKimiStreamingToolCall: empty tool_call + 5 streaming
in_progress + completed. Asserts no emission until complete, then
[ToolUse(Input.command="echo hi"), ToolResult(Output="hi\n")].
- TestHermesClientKimiMalformedArgsFallback: non-JSON argsText → falls
back to Input.text.
- TestHermesClientHandleToolCallCompleteOrphan: completed frame
without a start → ToolUse synthesised from update's rawInput.
- TestExtractACPToolCallText: diff + new-file-diff cases.
All agent / daemon / handler test packages green.
---------
Co-authored-by: Eve <8b0578a3-cf72-4394-9e38-b328eca92463@users.noreply.multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
336 lines
12 KiB
Go
336 lines
12 KiB
Go
package daemon
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DefaultServerURL = "ws://localhost:8080/ws"
|
|
DefaultPollInterval = 3 * time.Second
|
|
DefaultHeartbeatInterval = 15 * time.Second
|
|
DefaultAgentTimeout = 2 * time.Hour
|
|
DefaultRuntimeName = "Local Agent"
|
|
DefaultWorkspaceSyncInterval = 30 * time.Second
|
|
DefaultHealthPort = 19514
|
|
DefaultMaxConcurrentTasks = 20
|
|
DefaultGCInterval = 1 * time.Hour
|
|
DefaultGCTTL = 5 * 24 * time.Hour // 5 days
|
|
DefaultGCOrphanTTL = 30 * 24 * time.Hour // 30 days
|
|
)
|
|
|
|
// Config holds all daemon configuration.
|
|
type Config struct {
|
|
ServerBaseURL string
|
|
DaemonID string
|
|
LegacyDaemonIDs []string // historical daemon_ids this machine may have registered under; reported at register time so the server can merge old runtime rows
|
|
DeviceName string
|
|
RuntimeName string
|
|
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
|
LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
|
|
Profile string // profile name (empty = default)
|
|
Agents map[string]AgentEntry // keyed by provider: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi
|
|
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
|
KeepEnvAfterTask bool // preserve env after task for debugging
|
|
HealthPort int // local HTTP port for health checks (default: 19514)
|
|
MaxConcurrentTasks int // max tasks running in parallel (default: 20)
|
|
GCEnabled bool // enable periodic workspace garbage collection (default: true)
|
|
GCInterval time.Duration // how often the GC loop runs (default: 1h)
|
|
GCTTL time.Duration // clean dirs whose issue is done/canceled and updated_at < now()-TTL (default: 5d)
|
|
GCOrphanTTL time.Duration // clean orphan dirs (no meta or unknown issue) older than this (default: 30d)
|
|
PollInterval time.Duration
|
|
HeartbeatInterval time.Duration
|
|
AgentTimeout time.Duration
|
|
}
|
|
|
|
// Overrides allows CLI flags to override environment variables and defaults.
|
|
// Zero values are ignored and the env/default value is used instead.
|
|
type Overrides struct {
|
|
ServerURL string
|
|
WorkspacesRoot string
|
|
PollInterval time.Duration
|
|
HeartbeatInterval time.Duration
|
|
AgentTimeout time.Duration
|
|
MaxConcurrentTasks int
|
|
DaemonID string
|
|
DeviceName string
|
|
RuntimeName string
|
|
Profile string // profile name (empty = default)
|
|
HealthPort int // health check port (0 = use default)
|
|
}
|
|
|
|
// LoadConfig builds the daemon configuration from environment variables
|
|
// and optional CLI flag overrides.
|
|
func LoadConfig(overrides Overrides) (Config, error) {
|
|
// Server URL: override > env > default
|
|
rawServerURL := envOrDefault("MULTICA_SERVER_URL", DefaultServerURL)
|
|
if overrides.ServerURL != "" {
|
|
rawServerURL = overrides.ServerURL
|
|
}
|
|
serverBaseURL, err := NormalizeServerBaseURL(rawServerURL)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
// Probe available agent CLIs
|
|
agents := map[string]AgentEntry{}
|
|
claudePath := envOrDefault("MULTICA_CLAUDE_PATH", "claude")
|
|
if _, err := exec.LookPath(claudePath); err == nil {
|
|
agents["claude"] = AgentEntry{
|
|
Path: claudePath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_CLAUDE_MODEL")),
|
|
}
|
|
}
|
|
codexPath := envOrDefault("MULTICA_CODEX_PATH", "codex")
|
|
if _, err := exec.LookPath(codexPath); err == nil {
|
|
agents["codex"] = AgentEntry{
|
|
Path: codexPath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")),
|
|
}
|
|
}
|
|
opencodePath := envOrDefault("MULTICA_OPENCODE_PATH", "opencode")
|
|
if _, err := exec.LookPath(opencodePath); err == nil {
|
|
agents["opencode"] = AgentEntry{
|
|
Path: opencodePath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCODE_MODEL")),
|
|
}
|
|
}
|
|
openclawPath := envOrDefault("MULTICA_OPENCLAW_PATH", "openclaw")
|
|
if _, err := exec.LookPath(openclawPath); err == nil {
|
|
agents["openclaw"] = AgentEntry{
|
|
Path: openclawPath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCLAW_MODEL")),
|
|
}
|
|
}
|
|
hermesPath := envOrDefault("MULTICA_HERMES_PATH", "hermes")
|
|
if _, err := exec.LookPath(hermesPath); err == nil {
|
|
agents["hermes"] = AgentEntry{
|
|
Path: hermesPath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_HERMES_MODEL")),
|
|
}
|
|
}
|
|
geminiPath := envOrDefault("MULTICA_GEMINI_PATH", "gemini")
|
|
if _, err := exec.LookPath(geminiPath); err == nil {
|
|
agents["gemini"] = AgentEntry{
|
|
Path: geminiPath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_GEMINI_MODEL")),
|
|
}
|
|
}
|
|
piPath := envOrDefault("MULTICA_PI_PATH", "pi")
|
|
if _, err := exec.LookPath(piPath); err == nil {
|
|
agents["pi"] = AgentEntry{
|
|
Path: piPath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_PI_MODEL")),
|
|
}
|
|
}
|
|
cursorPath := envOrDefault("MULTICA_CURSOR_PATH", "cursor-agent")
|
|
if _, err := exec.LookPath(cursorPath); err == nil {
|
|
agents["cursor"] = AgentEntry{
|
|
Path: cursorPath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_CURSOR_MODEL")),
|
|
}
|
|
}
|
|
copilotPath := envOrDefault("MULTICA_COPILOT_PATH", "copilot")
|
|
if _, err := exec.LookPath(copilotPath); err == nil {
|
|
agents["copilot"] = AgentEntry{
|
|
Path: copilotPath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_COPILOT_MODEL")),
|
|
}
|
|
}
|
|
kimiPath := envOrDefault("MULTICA_KIMI_PATH", "kimi")
|
|
if _, err := exec.LookPath(kimiPath); err == nil {
|
|
agents["kimi"] = AgentEntry{
|
|
Path: kimiPath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_KIMI_MODEL")),
|
|
}
|
|
}
|
|
if len(agents) == 0 {
|
|
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, or kimi and ensure it is on PATH")
|
|
}
|
|
|
|
// Host info
|
|
host, err := os.Hostname()
|
|
if err != nil || strings.TrimSpace(host) == "" {
|
|
host = "local-machine"
|
|
}
|
|
|
|
// Durations: override > env > default
|
|
pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", DefaultPollInterval)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if overrides.PollInterval > 0 {
|
|
pollInterval = overrides.PollInterval
|
|
}
|
|
|
|
heartbeatInterval, err := durationFromEnv("MULTICA_DAEMON_HEARTBEAT_INTERVAL", DefaultHeartbeatInterval)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if overrides.HeartbeatInterval > 0 {
|
|
heartbeatInterval = overrides.HeartbeatInterval
|
|
}
|
|
|
|
agentTimeout, err := durationFromEnv("MULTICA_AGENT_TIMEOUT", DefaultAgentTimeout)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if overrides.AgentTimeout > 0 {
|
|
agentTimeout = overrides.AgentTimeout
|
|
}
|
|
|
|
maxConcurrentTasks, err := intFromEnv("MULTICA_DAEMON_MAX_CONCURRENT_TASKS", DefaultMaxConcurrentTasks)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if overrides.MaxConcurrentTasks > 0 {
|
|
maxConcurrentTasks = overrides.MaxConcurrentTasks
|
|
}
|
|
|
|
// Profile
|
|
profile := overrides.Profile
|
|
|
|
// daemon_id resolution: override > env > persistent UUID on disk.
|
|
// The persistent UUID is written once to `<profile-dir>/daemon.id` and
|
|
// then reused forever so hostname drift (.local suffix, system rename,
|
|
// mDNS state, profile switch) no longer mints a new runtime identity.
|
|
// Callers may still pin a specific id via MULTICA_DAEMON_ID or the
|
|
// override field (e.g. for tests or embedded environments).
|
|
daemonID := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_ID"))
|
|
if overrides.DaemonID != "" {
|
|
daemonID = overrides.DaemonID
|
|
}
|
|
if daemonID == "" {
|
|
persisted, err := EnsureDaemonID(profile)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("ensure daemon id: %w", err)
|
|
}
|
|
daemonID = persisted
|
|
}
|
|
// Historical daemon_ids derived from the current hostname/profile. The
|
|
// server uses these at register time to merge any pre-UUID runtime rows
|
|
// for this machine into the new UUID-keyed row and delete the stale ones.
|
|
legacyDaemonIDs := LegacyDaemonIDs(host, profile)
|
|
// Pre-change (#1220) daemon identity was stored per profile, which means
|
|
// the same machine could end up with multiple leftover daemon.id files
|
|
// — e.g. ~/.multica/daemon.id (default) plus ~/.multica/profiles/<x>/
|
|
// daemon.id. Surface those UUIDs so the server can merge their runtime
|
|
// rows into the canonical machine UUID. Fatal-free: a broken profiles
|
|
// dir shouldn't block startup.
|
|
if uuids, err := LegacyDaemonUUIDs(); err == nil {
|
|
legacyDaemonIDs = append(legacyDaemonIDs, uuids...)
|
|
}
|
|
// Strip anything that collides with the resolved daemon_id (e.g. when
|
|
// the user explicitly pins MULTICA_DAEMON_ID=<hostname>, or when the
|
|
// canonical id was itself promoted from a pre-change profile file).
|
|
legacyDaemonIDs = filterLegacyIDs(legacyDaemonIDs, daemonID)
|
|
|
|
deviceName := envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host)
|
|
if overrides.DeviceName != "" {
|
|
deviceName = overrides.DeviceName
|
|
}
|
|
|
|
runtimeName := envOrDefault("MULTICA_AGENT_RUNTIME_NAME", DefaultRuntimeName)
|
|
if overrides.RuntimeName != "" {
|
|
runtimeName = overrides.RuntimeName
|
|
}
|
|
|
|
// Workspaces root: override > env > default (~/multica_workspaces or ~/multica_workspaces_<profile>)
|
|
workspacesRoot := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACES_ROOT"))
|
|
if overrides.WorkspacesRoot != "" {
|
|
workspacesRoot = overrides.WorkspacesRoot
|
|
}
|
|
if workspacesRoot == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("resolve home directory: %w (set MULTICA_WORKSPACES_ROOT to override)", err)
|
|
}
|
|
if profile != "" {
|
|
workspacesRoot = filepath.Join(home, "multica_workspaces_"+profile)
|
|
} else {
|
|
workspacesRoot = filepath.Join(home, "multica_workspaces")
|
|
}
|
|
}
|
|
workspacesRoot, err = filepath.Abs(workspacesRoot)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("resolve absolute workspaces root: %w", err)
|
|
}
|
|
|
|
// Health port: override > default
|
|
healthPort := DefaultHealthPort
|
|
if overrides.HealthPort > 0 {
|
|
healthPort = overrides.HealthPort
|
|
}
|
|
|
|
// Keep env after task: env > default (false)
|
|
keepEnv := os.Getenv("MULTICA_KEEP_ENV_AFTER_TASK") == "true" || os.Getenv("MULTICA_KEEP_ENV_AFTER_TASK") == "1"
|
|
|
|
// GC config: env > defaults
|
|
gcEnabled := true
|
|
if v := os.Getenv("MULTICA_GC_ENABLED"); v == "false" || v == "0" {
|
|
gcEnabled = false
|
|
}
|
|
gcInterval, err := durationFromEnv("MULTICA_GC_INTERVAL", DefaultGCInterval)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
gcTTL, err := durationFromEnv("MULTICA_GC_TTL", DefaultGCTTL)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
gcOrphanTTL, err := durationFromEnv("MULTICA_GC_ORPHAN_TTL", DefaultGCOrphanTTL)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
return Config{
|
|
ServerBaseURL: serverBaseURL,
|
|
DaemonID: daemonID,
|
|
LegacyDaemonIDs: legacyDaemonIDs,
|
|
DeviceName: deviceName,
|
|
RuntimeName: runtimeName,
|
|
Profile: profile,
|
|
Agents: agents,
|
|
WorkspacesRoot: workspacesRoot,
|
|
KeepEnvAfterTask: keepEnv,
|
|
GCEnabled: gcEnabled,
|
|
GCInterval: gcInterval,
|
|
GCTTL: gcTTL,
|
|
GCOrphanTTL: gcOrphanTTL,
|
|
HealthPort: healthPort,
|
|
MaxConcurrentTasks: maxConcurrentTasks,
|
|
PollInterval: pollInterval,
|
|
HeartbeatInterval: heartbeatInterval,
|
|
AgentTimeout: agentTimeout,
|
|
}, nil
|
|
}
|
|
|
|
// NormalizeServerBaseURL converts a WebSocket or HTTP URL to a base HTTP URL.
|
|
func NormalizeServerBaseURL(raw string) (string, error) {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid MULTICA_SERVER_URL: %w", err)
|
|
}
|
|
switch u.Scheme {
|
|
case "ws":
|
|
u.Scheme = "http"
|
|
case "wss":
|
|
u.Scheme = "https"
|
|
case "http", "https":
|
|
default:
|
|
return "", fmt.Errorf("MULTICA_SERVER_URL must use ws, wss, http, or https")
|
|
}
|
|
if u.Path == "/ws" {
|
|
u.Path = ""
|
|
}
|
|
u.RawPath = ""
|
|
u.RawQuery = ""
|
|
u.Fragment = ""
|
|
return strings.TrimRight(u.String(), "/"), nil
|
|
}
|